[studio] prefix assets

This commit is contained in:
Devin Zuczek
2026-10-09 01:37:19 -04:00
parent f9beba0dae
commit ef65b505cc
16 changed files with 384 additions and 410 deletions
+4 -3
View File
@@ -18,9 +18,10 @@ Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved
scenes, and room images by their bare `ImageName`), `invention/` (invention data), `data/`
(generic client uploads), `avatar/` (custom avatar item assetbundles) — and served as `application/octet-stream`; the worker never interprets what it hands back.
`/unityasset/{filename}` is the exception: a Rec Room Studio room bundle is stored under
`studio-room-bundles/`, and the route looks the filename up in `studio_unity_asset_file`
(the studio worker owns that table) before streaming the same way. `HEAD` on that path is
200 when exactly one bundle has the name.
`studio/<roomId>/` (the game's own `unity/<roomId>/` layout, under another prefix), its key
recorded as the filename of a `unity_asset` row (the rooms worker owns that table; the
studio worker writes the rows), and the route serves only a filename that table records.
`HEAD` on that path is 200 when a bundle is stored under the name.
Reads are unauthenticated: a caller needs the exact key, which only comes from an
authenticated call to another worker.
+20 -23
View File
@@ -354,10 +354,11 @@ const app = new Hono<App>()
)
// Rec Room Studio room bundles. The filename is the one a room save or
// `GET …/unityasset` advertises. The bytes live under the key the studio upload
// wrote (`studio-room-bundles/…`), which is not the URL, so this looks the name
// up. Reads stay unauthenticated, same as `/avatar/` and `/room/`. HEAD is the
// editor's "already uploaded?" check: 200 exists, 404 does not.
// `GET …/unityasset` advertises — the R2 key itself (`studio/<roomId>/…`), as
// `unity_asset.filename` records it. Only a recorded filename is served, so this
// checks the table before streaming the key. Reads stay unauthenticated, same as
// `/avatar/` and `/room/`. HEAD is the editor's "already uploaded?" check: 200
// exists, 404 does not.
.on(
'HEAD',
'/unityasset/:filename{.+}',
@@ -365,7 +366,7 @@ const app = new Hono<App>()
tags: ['Assets'],
summary: 'Check that a Rec Room Studio room bundle exists',
description: [
'200 when `filename` names exactly one stored Studio bundle, 404 otherwise. The',
'200 when `filename` names a stored Studio bundle, 404 otherwise. The',
'Studio editor treats this HEAD as “already uploaded?” before it downloads the bytes',
'with GET. No body.',
].join(' '),
@@ -386,12 +387,9 @@ const app = new Hono<App>()
description: [
'Streams the Studio asset bundle stored for `filename`. The name comes from a',
'room save’s `UnitySubAssets` (or from `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`',
'on the rooms worker). The object itself lives at the R2 key the studio upload',
'recorded, so this route looks the filename up rather than using it as the key.',
'',
'A filename that matches more than one stored bundle is a 404, so two rooms that',
'uploaded the same basename cannot be served as each other. A missing studio table',
'is the same 404. The worker does not interpret the bytes.',
'on the rooms worker) and is the R2 key the studio upload stored the bytes under.',
'Only a filename recorded in `unity_asset` is served: anything else is a 404, as is',
'a missing table. The worker does not interpret the bytes.',
].join(' '),
parameters: [
keyParam('filename', 'The bundle filename from the room save.', true),
@@ -403,20 +401,19 @@ const app = new Hono<App>()
)
/**
* The R2 key for a Studio bundle filename. Null when the name is unknown, ambiguous,
* or the studio tables have not been migrated yet.
* The R2 key for a Studio bundle filename — the filename itself, once `unity_asset` says
* a build is stored under it. Null when no build is, or the table has not been migrated
* yet (the rooms worker owns it).
*/
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> {
try {
// Same lookup as `@repo/domain` `findStudioUnityAssetByFilename`. Inlined so this
// worker does not take a dependency on the domain package. More than one row is a
// miss: two rooms must not be served each other's bundle.
const { results } = await db
.prepare(`SELECT r2_key FROM studio_unity_asset_file WHERE filename = ?1`)
// Inlined rather than imported so this worker does not take a dependency on the
// domain package.
const row = await db
.prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`)
.bind(filename)
.all<{ r2_key: string }>()
if (results.length !== 1) return null
return results[0]!.r2_key
.first<{ stored: number }>()
return row ? filename : null
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.toLowerCase().includes('no such table')) return null
@@ -476,8 +473,8 @@ app.get(
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
'are unauthenticated — a caller needs the exact key, which only comes from an',
'authenticated call to another worker. `/unityasset/{filename}` is a Studio room',
'bundle: the filename is looked up in `studio_unity_asset_file` and the stored R2',
'key is streamed. `HEAD` on that path is 200 when exactly one bundle has the name.',
'bundle: the filename is the R2 key, streamed once `unity_asset` records it.',
'`HEAD` on that path is 200 when a bundle is stored under the name.',
'',
'This worker only READS. Uploads go through the `storage` worker, which writes the',
'same bucket, and images are served by `img` rather than from here.',
+4 -3
View File
@@ -6,9 +6,10 @@ export type Env = SharedHonoEnv & {
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
// signed by `auth` verify here.
JWT_SECRET: SecretsStoreSecret
// Shared `recflare` database, read-only here. Studio bundle downloads resolve a
// public filename to its R2 key through `studio_unity_asset_file`. The studio
// worker owns that table's migration; this binding has no migrations_dir.
// Shared `recflare` database, read-only here. Studio bundle downloads check that
// `unity_asset` records the requested filename (which is the R2 key) before
// streaming it. The rooms worker owns that table's migration; this binding has no
// migrations_dir.
DB: D1Database
// R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
// room build data under `room/<name>`.
+25 -22
View File
@@ -286,33 +286,35 @@ describe('cdn endpoints', () => {
expect(res.status).toBe(404)
})
test('GET and HEAD /unityasset/:filename stream one stored studio bundle', async () => {
await env.DB.prepare('DROP TABLE IF EXISTS studio_unity_asset_file').run()
test('GET and HEAD /unityasset/:filename stream a stored studio bundle', async () => {
await env.DB.prepare('DROP TABLE IF EXISTS unity_asset').run()
const absent = await exports.default.fetch(`${ORIGIN}/unityasset/not-stored.assetbundle`)
expect(absent.status).toBe(404)
// The rooms worker's table (UNITY_ASSET_SCHEMA_DDL in @repo/domain), inlined like
// the route's own lookup is.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS studio_unity_asset_file (
`CREATE TABLE IF NOT EXISTS unity_asset (
unity_asset_id TEXT NOT NULL,
platform TEXT NOT NULL,
kind TEXT NOT NULL,
target INTEGER NOT NULL,
version INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'main',
filename TEXT NOT NULL,
sha256 TEXT NOT NULL,
byte_length INTEGER NOT NULL,
r2_key TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, platform, kind)
hash TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, target, version, kind)
)`
).run()
const filename = 'abc111.windows.main.assetbundle'
const key = `studio-room-bundles/asset-a/windows/main/${filename}`
const insert = (unityAssetId: string, r2Key: string) =>
env.DB.prepare(
`INSERT INTO studio_unity_asset_file
(unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key)
VALUES (?1, 'windows', 'main', ?2, 'ab', 4, ?3)`
).bind(unityAssetId, filename, r2Key)
await insert('asset-a', key).run()
await env.CDN_ASSETS.put(key, new Uint8Array([1, 2, 3, 4]))
// The filename IS the R2 key.
const filename = 'studio/20930/abc111.windows.main.assetbundle'
await env.DB.prepare(
`INSERT INTO unity_asset (unity_asset_id, target, version, kind, filename, hash)
VALUES ('asset-a', 0, 1, 'main', ?1, 'n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo=')`
)
.bind(filename)
.run()
await env.CDN_ASSETS.put(filename, new Uint8Array([1, 2, 3, 4]))
// A key in the bucket that no row records is not served.
await env.CDN_ASSETS.put('studio/20930/unrecorded.assetbundle', new Uint8Array([9]))
const res = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`)
expect(res.status).toBe(200)
@@ -325,10 +327,11 @@ describe('cdn endpoints', () => {
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
expect(
(await exports.default.fetch(`${ORIGIN}/unityasset/studio/20930/unrecorded.assetbundle`))
.status
).toBe(404)
expect((await exports.default.fetch(`${ORIGIN}/unityasset/a..b`)).status).toBe(400)
await insert('asset-b', `studio-room-bundles/asset-b/windows/main/${filename}`).run()
expect((await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`)).status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
@@ -0,0 +1,26 @@
-- `unity_asset` gains a `kind`: Rec Room Studio builds store a `main` bundle (the scene the
-- client loads) and, optionally, a `stripped` editor-only bundle per target, and both are
-- rows here now that the studio worker writes this table instead of its own
-- `studio_unity_asset_file` (dropped by the studio migration 0002). The studio rows map
-- platform → target (windows 0, android 2), the R2 key → `filename` and the base64
-- SHA-256 → `hash`.
--
-- Generated from packages/domain/src/unity-assets-db.ts (UNITY_ASSET_SCHEMA_DDL) — keep in
-- sync. `kind` joins the primary key, which SQLite cannot alter in place, so the rows are
-- copied into a new table and KEPT: every existing row is a main bundle.
CREATE TABLE unity_asset_new (
unity_asset_id TEXT NOT NULL,
target INTEGER NOT NULL,
version INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'main',
filename TEXT NOT NULL,
hash TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, target, version, kind)
);
INSERT INTO unity_asset_new (unity_asset_id, target, version, kind, filename, hash)
SELECT unity_asset_id, target, version, 'main', filename, hash FROM unity_asset;
DROP TABLE unity_asset;
ALTER TABLE unity_asset_new RENAME TO unity_asset;
+13 -22
View File
@@ -8,6 +8,7 @@ import {
applyRoomTagEdit,
attachStudioUnityAssets,
autocompleteRoomSearch,
bakedUnityAssets,
banPlayerFromRoom,
canManageRoom,
canManageRoomById,
@@ -53,7 +54,6 @@ import {
getVisitedRooms,
incrementRoomExperience,
inviteRoomRole,
isMissingStudioAssetTable,
isPlayerBannedFromRoom,
isRoomOwner,
MessageType,
@@ -78,9 +78,6 @@ import {
setRoomProgression,
setRoomRole,
setSubRoomPermissions,
sha256HexToBase64,
STUDIO_ASSET_VERSION,
studioAssetTarget,
toggleCheer,
toggleFavorite,
transferRoomOwnership,
@@ -3819,33 +3816,27 @@ const app = new Hono<App>()
const save = saves.find((row) => row.UnityAssetId === unityAssetId)
if (!save) return c.notFound()
let asset
try {
asset = await getStudioUnityAsset(c.env.DB, unityAssetId)
} catch (err) {
if (isMissingStudioAssetTable(err)) return c.notFound()
throw err
}
const asset = await getStudioUnityAsset(c.env.DB, unityAssetId)
if (!asset) return c.notFound()
const mains = asset.files
.filter((file) => file.kind === 'main')
.sort((a, b) => studioAssetTarget(a.platform) - studioAssetTarget(b.platform))
const source = mains.find((file) => file.platform === 'windows') ?? mains[0]
// Main bundles, Windows first; the Windows one (or the first there is) supplies
// the top-level `filename`/`hash`.
const mains = bakedUnityAssets(asset.builds)
const source = mains.find((build) => build.Target === 0) ?? mains[0]
if (!source) return c.notFound()
const savedBy = typeof save.SavedByAccountId === 'number' ? save.SavedByAccountId : 0
return c.json({
unityAssetId: asset.unityAssetId,
createdByAccountId: asset.createdByAccountId || savedBy,
bakedUnityAssets: mains.map((file) => ({
unityAssetId: file.unityAssetId,
target: studioAssetTarget(file.platform),
version: STUDIO_ASSET_VERSION,
filename: file.filename,
bakedUnityAssets: mains.map((build) => ({
unityAssetId: build.UnityAssetId,
target: build.Target,
version: build.Version,
filename: build.Filename,
})),
filename: source.filename,
hash: sha256HexToBase64(source.sha256),
filename: source.Filename,
hash: source.Hash,
})
}
)
+21 -26
View File
@@ -24,8 +24,7 @@ import {
ROOM_SCHEMA_DDL,
ROOM_XP_SCHEMA_DDL,
seedRoomWithSubRooms,
sha256HexToBase64,
STUDIO_UNITY_ASSET_SCHEMA_DDL,
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
SUBROOM_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
@@ -6006,17 +6005,16 @@ describe('rooms endpoints', () => {
})
})
// A Rec Room Studio build is a unity asset on the save. The game loads the scene from
// `CurrentSave.DataBlob` and the bundles from `UnitySubAssets`. Maker-pen saves stay
// empty arrays, and a missing studio table must not fail the room read.
// A Rec Room Studio build is a unity asset on the save: `unity_asset` rows the studio
// worker wrote. The game loads the scene from `CurrentSave.DataBlob` and the bundles from
// `UnitySubAssets`. Maker-pen saves stay empty arrays, and a missing studio cloud-build
// table must not fail the room read.
describe('studio room bundles', () => {
const ASSET = '11111111-2222-4333-8444-555555555555'
const WIN_HEX = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
const ANDROID_HEX = 'ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb'
const winName = publicStudioBundleFilename(ASSET, 'windows', 'main')
const androidName = publicStudioBundleFilename(ASSET, 'android', 'main')
const winHash = sha256HexToBase64(WIN_HEX)
const androidHash = sha256HexToBase64(ANDROID_HEX)
const winName = publicStudioBundleFilename(9901, ASSET, 'windows', 'main')
const androidName = publicStudioBundleFilename(9901, ASSET, 'android', 'main')
const winHash = '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
const androidHash = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs='
const save = (unityAssetId?: string) => ({
UnitySubAssets: [],
@@ -6038,8 +6036,8 @@ describe('studio room bundles', () => {
})
async function reset() {
await env.DB.prepare('DROP TABLE IF EXISTS studio_unity_asset_file').run()
await env.DB.prepare('DROP TABLE IF EXISTS studio_cloud_build').run()
await env.DB.prepare('DELETE FROM unity_asset WHERE unity_asset_id = ?1').bind(ASSET).run()
await env.DB.prepare('DELETE FROM subroom_save WHERE sub_room_id IN (99011, 99021)').run()
await env.DB.prepare('DELETE FROM subroom WHERE room_id IN (9901, 9902)').run()
await env.DB.prepare('DELETE FROM room WHERE room_id IN (9901, 9902)').run()
@@ -6080,7 +6078,7 @@ describe('studio room bundles', () => {
}
async function storeFiles() {
for (const sql of STUDIO_UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(sql).run()
for (const sql of STUDIO_CLOUD_BUILD_SCHEMA_DDL) await env.DB.prepare(sql).run()
await env.DB.prepare(
`INSERT INTO studio_cloud_build
(cloud_build_id, room_id, sub_room_id, sub_room_data_save_id, unity_asset_id,
@@ -6090,22 +6088,19 @@ describe('studio room bundles', () => {
)
.bind(ASSET)
.run()
const insert = async (platform: string, kind: string, filename: string, sha256: string) => {
await env.DB.prepare(
`INSERT INTO studio_unity_asset_file
(unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key)
VALUES (?1, ?2, ?3, ?4, ?5, 4, ?6)`
const insert = (target: number, kind: 'main' | 'stripped', filename: string, hash: string) =>
putUnityAsset(
env.DB,
{ UnityAssetId: ASSET, Target: target, Version: 1, Filename: filename, Hash: hash },
kind
)
.bind(ASSET, platform, kind, filename, sha256, `studio-room-bundles/${filename}`)
.run()
}
await insert('windows', 'main', winName, WIN_HEX)
await insert('android', 'main', androidName, ANDROID_HEX)
await insert(0, 'main', winName, winHash)
await insert(2, 'main', androidName, androidHash)
await insert(
'windows',
0,
'stripped',
publicStudioBundleFilename(ASSET, 'windows', 'stripped'),
WIN_HEX
publicStudioBundleFilename(9901, ASSET, 'windows', 'stripped'),
winHash
)
}
+7 -5
View File
@@ -7,7 +7,7 @@ page is WWW `/device`. This worker answers the editor's build list, stores a
locally built Windows and Android bundle pair, and answers the co-owner
presence list. It does not serve the bundle bytes. A stored build is loaded from
Rooms (`CurrentSave.UnitySubAssets`, and `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`)
and from the CDN at `GET /unityasset/{filename}`.
and from the bucket at `/room/{filename}`.
| Method | Path | Purpose |
| ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -37,10 +37,12 @@ pnpm turbo deploy
### Migrate
This worker owns `studio_cloud_build` and `studio_unity_asset_file` on the shared
`recflare` database (`migrations/`, tracked in its own `d1_migrations_studio` table).
Deploying does not apply them; the cdn worker reads `studio_unity_asset_file` and
answers 404 for every Studio bundle until this has run.
This worker owns `studio_cloud_build` on the shared `recflare` database (`migrations/`,
tracked in its own `d1_migrations_studio` table). The bundles themselves are rows of the
rooms worker's `unity_asset` (one per platform and kind: target 0 Windows / 2 Android,
`studio/<roomId>/<name>.assetbundle` as `filename` — the bytes sit at `room/<filename>` in
the bucket, the prefix the client adds — base64 SHA-256 as `hash`), so a stored build needs
the rooms migrations too. Deploying does not apply either.
```sh
just migrate -F studio # remote
@@ -0,0 +1,5 @@
-- A stored bundle is a row of the rooms worker's `unity_asset` now (its migration 0026
-- added the `kind` column this table carried): platform → target, r2_key → filename,
-- sha256 → hash. `studio_cloud_build` stays this worker's. Nothing is carried over.
DROP TABLE IF EXISTS studio_unity_asset_file;
+4 -3
View File
@@ -10,11 +10,12 @@ export type Env = SharedHonoEnv & {
JWT_SECRET: SecretsStoreSecret
// Shared `recflare` D1. Room ownership lives in the `room` blob (the `rooms`
// worker owns that schema) and who is standing where lives in `presence`.
// This worker migrates its own cloud-build tables (`studio_cloud_build`,
// `studio_unity_asset_file`) and only reads the room tables.
// This worker migrates its own cloud-build table (`studio_cloud_build`), writes
// each bundle it stores as a row of the rooms worker's `unity_asset`, and only
// reads the room tables.
DB: D1Database
// Shared `recflare-cdn` bucket. Local Studio builds land under
// `studio-room-bundles/`. The same bucket name the rooms and cdn workers bind.
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
CDN_ASSETS: R2Bucket
}
+39 -60
View File
@@ -1,36 +1,16 @@
import {
deleteUnityAssetBuilds,
getSubRoom,
markRoomAsRecRoomStudio,
publicStudioBundleFilename,
setSubRoomSaveUnityAssetId,
STUDIO_ASSET_VERSION,
studioAssetTarget,
studioBundleR2Key,
unityAssetUpsert,
} from '@repo/domain'
/** Mirrors migrations/0001_studio_cloud_build.sql. Tests apply this; deploy uses the file. */
export const STUDIO_CLOUD_BUILD_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS studio_cloud_build (
cloud_build_id TEXT PRIMARY KEY,
room_id INTEGER NOT NULL,
sub_room_id INTEGER NOT NULL,
sub_room_data_save_id INTEGER NOT NULL,
unity_asset_id TEXT NOT NULL,
created_by_account_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
completed_at TEXT NOT NULL,
error TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_studio_cloud_build_room
ON studio_cloud_build (room_id, sub_room_id, started_at)`,
`CREATE TABLE IF NOT EXISTS studio_unity_asset_file (
unity_asset_id TEXT NOT NULL,
platform TEXT NOT NULL,
kind TEXT NOT NULL,
filename TEXT NOT NULL,
sha256 TEXT NOT NULL,
byte_length INTEGER NOT NULL,
r2_key TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, platform, kind)
)`,
]
import type { StudioBundlePlatform, UnityAssetKind } from '@repo/domain'
export interface CloudBuildRow {
cloudBuildId: string
@@ -41,8 +21,8 @@ export interface CloudBuildRow {
createdByAccountId: number
}
export type BundlePlatform = 'windows' | 'android'
export type BundleKind = 'main' | 'stripped'
export type BundlePlatform = StudioBundlePlatform
export type BundleKind = UnityAssetKind
export interface LocalBundleFile {
platform: BundlePlatform
@@ -103,16 +83,22 @@ export async function listCloudBuilds(
return { results: results.map(toCloudBuild), totalResults: Number(total?.n ?? 0) }
}
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
/** Base64 SHA-256 — the encoding `unity_asset.hash` (and every other bundle hash) uses. */
async function sha256Base64(bytes: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes)
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
let binary = ''
for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte)
return btoa(binary)
}
/**
* Store a PC-built Windows + Android pair as a cloud build that is already finished.
* The bytes go to R2. The build row points at the subroom's current save, that save's
* `UnityAssetId` is set to the new asset, and the room is marked as a Studio room the
* first time (`BecameRRStudioRoomAt`).
* The bytes go to R2 under `room/` and each bundle becomes a `unity_asset` row — target
* 0 Windows / 2 Android, its kind, the `filename` the client prefixes with `/room/` to
* download it (so the R2 key is `room/<filename>`), the base64 SHA-256 as `hash`. The
* build row points at the subroom's current save, that save's `UnityAssetId` is set
* to the new asset, and the room is marked as a Studio room the first time
* (`BecameRRStudioRoomAt`).
*
* Returns null when the subroom has no current save — the caller uploaded bundles
* before Upload created one.
@@ -140,35 +126,31 @@ export async function storeLocalCloudBuild(
const stored = await Promise.all(
files.map(async (file) => {
const sha256 = await sha256Hex(file.bytes)
// The download route looks a bundle up by filename alone, so the stored
// name is this build's, not the uploaded basename two rooms can share.
const filename = publicStudioBundleFilename(unityAssetId, file.platform, file.kind)
const r2Key = `studio-room-bundles/${unityAssetId}/${file.platform}/${file.kind}/${filename}`
await bucket.put(r2Key, file.bytes)
return { ...file, filename, sha256, r2Key }
// The client downloads `/room/<filename>` from the bucket, so the stored name is
// this build's, not the uploaded basename two rooms can share.
const filename = publicStudioBundleFilename(roomId, unityAssetId, file.platform, file.kind)
const hash = await sha256Base64(file.bytes)
await bucket.put(studioBundleR2Key(filename), file.bytes)
return { ...file, filename, hash }
})
)
const discardObjects = () => Promise.all(stored.map((file) => bucket.delete(file.r2Key)))
const discardObjects = () =>
Promise.all(stored.map((file) => bucket.delete(studioBundleR2Key(file.filename))))
try {
await db.batch([
...stored.map((file) =>
db
.prepare(
`INSERT INTO studio_unity_asset_file
(unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`
)
.bind(
unityAssetId,
file.platform,
file.kind,
file.filename,
file.sha256,
file.bytes.byteLength,
file.r2Key
)
unityAssetUpsert(
db,
{
UnityAssetId: unityAssetId,
Target: studioAssetTarget(file.platform),
Version: STUDIO_ASSET_VERSION,
Filename: file.filename,
Hash: file.hash,
},
file.kind
)
),
db
.prepare(
@@ -190,10 +172,7 @@ export async function storeLocalCloudBuild(
.prepare('DELETE FROM studio_cloud_build WHERE cloud_build_id = ?1')
.bind(cloudBuildId)
.run()
await db
.prepare('DELETE FROM studio_unity_asset_file WHERE unity_asset_id = ?1')
.bind(unityAssetId)
.run()
await deleteUnityAssetBuilds(db, unityAssetId)
await discardObjects()
return null
}
+35 -28
View File
@@ -1,9 +1,14 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, describe, expect, it } from 'vitest'
import { PRESENCE_SCHEMA_DDL, ROOM_SCHEMA_DDL, setPresence, SUBROOM_SCHEMA_DDL } from '@repo/domain'
import { STUDIO_CLOUD_BUILD_SCHEMA_DDL } from '../../local-builds'
import {
PRESENCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
setPresence,
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
SUBROOM_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import type { Env } from '../../context'
@@ -90,6 +95,8 @@ async function standIn(
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
for (const stmt of STUDIO_CLOUD_BUILD_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// `unity_asset` is the rooms worker's table; a build writes one row per bundle into it.
for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
it('response with hello world', async () => {
@@ -357,30 +364,33 @@ describe('local cloud builds', () => {
completed_at: first.value.startedAt,
})
// Each bundle is a `unity_asset` row: target 0 Windows / 2 Android, its kind, the R2
// key as the filename, and the base64 SHA-256 of the bytes as the hash.
const { results: files } = await env.DB.prepare(
`SELECT platform, kind, filename, byte_length, r2_key
FROM studio_unity_asset_file WHERE unity_asset_id = ?1
ORDER BY platform, kind`
`SELECT target, version, kind, filename, hash
FROM unity_asset WHERE unity_asset_id = ?1
ORDER BY target, kind`
)
.bind(first.value.unityAssetId)
.all<{
platform: string
kind: string
filename: string
byte_length: number
r2_key: string
}>()
.all<{ target: number; version: number; kind: string; filename: string; hash: string }>()
const publicName = (platform: string, kind: string) =>
`${first.value.unityAssetId.replace(/-/g, '')}.${platform}.${kind}.assetbundle`
`studio/${LOCAL_ROOM}/${first.value.unityAssetId.replace(/-/g, '')}.${platform}.${kind}.assetbundle`
expect(
files.map((file) => [file.platform, file.kind, file.filename, file.byte_length])
files.map((file) => [file.target, file.version, file.kind, file.filename, file.hash])
).toEqual([
['android', 'main', publicName('android', 'main'), 2],
['windows', 'main', publicName('windows', 'main'), 4],
['windows', 'stripped', publicName('windows', 'stripped'), 3],
[0, 1, 'main', publicName('windows', 'main'), 'n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo='],
[
0,
1,
'stripped',
publicName('windows', 'stripped'),
'ZqZ1cVH47lXbEncWx+Pc4L6AdLZOIO2lQuXB5GypxB4=',
],
[2, 1, 'main', publicName('android', 'main'), 'xCUiEotJGT3ozUXY91ic1+CF5l8ThkDVfUSC5fcYliM='],
])
const win = files.find((file) => file.platform === 'windows' && file.kind === 'main')
const stored = await env.CDN_ASSETS.get(win!.r2_key)
const win = files.find((file) => file.target === 0 && file.kind === 'main')
// The bytes sit under `room/` — the prefix the client adds to the filename.
const stored = await env.CDN_ASSETS.get(`room/${win!.filename}`)
expect(stored).not.toBeNull()
expect([...new Uint8Array(await stored!.arrayBuffer())]).toEqual([1, 2, 3, 4])
@@ -477,16 +487,13 @@ describe('local cloud builds', () => {
expect(created.success).toBe(true)
const { results: files } = await env.DB.prepare(
`SELECT platform, byte_length, r2_key FROM studio_unity_asset_file
WHERE unity_asset_id = ?1 AND kind = 'main' ORDER BY platform`
`SELECT target, filename FROM unity_asset
WHERE unity_asset_id = ?1 AND kind = 'main' ORDER BY target`
)
.bind(created.value.unityAssetId)
.all<{ platform: string; byte_length: number; r2_key: string }>()
expect(files.map((f) => [f.platform, f.byte_length])).toEqual([
['android', droidBytes.length],
['windows', winBytes.length],
])
const win = await env.CDN_ASSETS.get(files[1]!.r2_key)
.all<{ target: number; filename: string }>()
expect(files.map((f) => f.target)).toEqual([0, 2])
const win = await env.CDN_ASSETS.get(`room/${files[0]!.filename}`)
expect([...new Uint8Array(await win!.arrayBuffer())]).toEqual(winBytes)
})
})
+4 -3
View File
@@ -5,8 +5,9 @@
"compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"],
// Shared `recflare` D1. Room co-owners come off the `room` blob and who is in a
// subroom comes off `presence` (other workers own those tables). This worker
// owns the cloud-build tables, migrated from `migrations/`; its own
// subroom comes off `presence` (other workers own those tables), and a stored
// bundle is a row of the rooms worker's `unity_asset`. This worker owns the
// cloud-build table, migrated from `migrations/`; its own
// migrations_table keeps that history separate from the other workers'
// migrations on the shared database. The "local" placeholder is replaced with
// RECFLARE_D1 at deploy time.
@@ -20,7 +21,7 @@
}
],
// Shared `recflare-cdn` R2 bucket (also bound by `rooms` / `cdn` / `storage`).
// Local Studio asset bundles live here under `studio-room-bundles/`.
// Local Studio asset bundles live here under `room/studio/<roomId>/`.
// bucket_name is a literal, so nothing is spliced at deploy time.
"r2_buckets": [
{
+14 -23
View File
@@ -21,13 +21,9 @@ import { bindPlaceholders, chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds'
import { Accessibility, Role } from './enums'
import { countPlayersByRoom } from './presence-db'
import { resyncInstanceCapacity } from './room-instance-db'
import {
bakedStudioUnityAssets,
isMissingStudioAssetTable,
listStudioUnityAssetFiles,
} from './studio-unity-assets'
import { bakedUnityAssets, listUnityAssetBuilds } from './unity-assets-db'
import type { StudioUnityAssetFile } from './studio-unity-assets'
import type { UnityAssetBuild } from './unity-assets-db'
/** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
export const ROOM_SCHEMA_DDL: string[] = [
@@ -2287,10 +2283,10 @@ async function attachCurrentSaves(
}
/**
* Fill `UnitySubAssets` on saves that point at a stored Studio build. Maker-pen
* saves have no `UnityAssetId` and are left untouched, including their empty
* arrays. A missing studio table is the same answer: the scene blob still loads,
* and the bundle list stays empty until the migration exists.
* Fill `UnitySubAssets` on saves that point at a stored Studio build (rows of
* `unity_asset`). Maker-pen saves have no `UnityAssetId` and are left untouched,
* including their empty arrays; so is a save whose asset has no stored build —
* the scene blob still loads, and the bundle list stays empty.
*
* Only main bundles are listed. Stripped bundles stay in the bucket.
*/
@@ -2306,23 +2302,18 @@ export async function attachStudioUnityAssets(
),
]
if (ids.length === 0) return
let files: StudioUnityAssetFile[]
try {
files = await listStudioUnityAssetFiles(db, ids)
} catch (err) {
if (isMissingStudioAssetTable(err)) return
throw err
}
const byAsset = new Map<string, StudioUnityAssetFile[]>()
for (const file of files) {
const list = byAsset.get(file.unityAssetId) ?? []
list.push(file)
byAsset.set(file.unityAssetId, list)
const builds = await listUnityAssetBuilds(db, ids)
// Rows come back with the id lowercased; a save may spell its GUID either way.
const byAsset = new Map<string, UnityAssetBuild[]>()
for (const build of builds) {
const list = byAsset.get(build.UnityAssetId) ?? []
list.push(build)
byAsset.set(build.UnityAssetId, list)
}
for (const save of saves) {
const id = save.UnityAssetId
if (typeof id !== 'string') continue
const baked = bakedStudioUnityAssets(byAsset.get(id) ?? [])
const baked = bakedUnityAssets(byAsset.get(id.toLowerCase()) ?? [])
if (baked.length === 0) continue
save.UnitySubAssets = baked
}
+45 -161
View File
@@ -1,20 +1,24 @@
/**
* Baked Rec Room Studio bundles, stored by the studio worker.
* Rec Room Studio cloud builds.
*
* The studio migration owns the tables. This module only reads them, and tests
* apply {@link STUDIO_UNITY_ASSET_SCHEMA_DDL} directly. A room load must keep
* working before that migration has been applied, so a missing table is
* reported by {@link isMissingStudioAssetTable} and the caller leaves the save
* the way a maker-pen room already looks.
* The editor builds Windows and Android asset bundles on the creator's PC and posts them
* to the `studio` worker, which stores the bytes in the shared `recflare-cdn` bucket,
* records each bundle as a row of `unity_asset` (see unity-assets-db.ts — the same table
* the game's bulk lookup reads) and files the build itself in `studio_cloud_build`, the
* one table this module describes. The studio migration owns that table; this module
* reads it, and tests apply {@link STUDIO_CLOUD_BUILD_SCHEMA_DDL} directly.
*
* The filename column is what a client requests. New builds store a name that
* contains the unity asset id, because the download route looks the file up by
* filename alone and two rooms can otherwise both upload `win.assetbundle`.
* A room load must keep working before that migration has been applied, so a missing
* table is reported by {@link isMissingStudioAssetTable} and the caller falls back to
* whatever it knows without the build record.
*/
import { bindPlaceholders, chunkForBinds } from './d1-binds'
import { listUnityAssetBuilds } from './unity-assets-db'
export const STUDIO_UNITY_ASSET_SCHEMA_DDL: string[] = [
import type { UnityAssetBuild } from './unity-assets-db'
/** Schema DDL (mirror of apps/studio/migrations/0001_studio_cloud_build.sql, less the dropped file table). */
export const STUDIO_CLOUD_BUILD_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS studio_cloud_build (
cloud_build_id TEXT PRIMARY KEY,
room_id INTEGER NOT NULL,
@@ -28,174 +32,72 @@ export const STUDIO_UNITY_ASSET_SCHEMA_DDL: string[] = [
)`,
`CREATE INDEX IF NOT EXISTS idx_studio_cloud_build_room
ON studio_cloud_build (room_id, sub_room_id, started_at)`,
`CREATE TABLE IF NOT EXISTS studio_unity_asset_file (
unity_asset_id TEXT NOT NULL,
platform TEXT NOT NULL,
kind TEXT NOT NULL,
filename TEXT NOT NULL,
sha256 TEXT NOT NULL,
byte_length INTEGER NOT NULL,
r2_key TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, platform, kind)
)`,
]
/** The version stamped on every baked file this server stores. */
/** The `Version` stamped on every bundle a Studio build stores. */
export const STUDIO_ASSET_VERSION = 1
/** The platforms a local Studio build posts, named as the multipart fields name them. */
export type StudioBundlePlatform = 'windows' | 'android'
export type StudioBundleKind = 'main' | 'stripped'
export interface StudioUnityAssetFile {
unityAssetId: string
platform: StudioBundlePlatform
kind: StudioBundleKind
filename: string
/** Lowercase hex SHA-256, the form the studio upload stores. */
sha256: string
byteLength: number
r2Key: string
}
/**
* One baked bundle as a room save lists it. PascalCase, like the rest of
* `CurrentSave`. `Target` uses the numbers the game sends as `unityAssetTarget`
* on a custom avatar: 0 is Windows, 2 is Android/Quest.
*/
export interface BakedStudioUnityAsset {
UnityAssetId: string
Target: number
Version: number
Filename: string
/** Base64 SHA-256. An empty string is not a hash. */
Hash: string
}
interface StudioUnityAssetFileRow {
unity_asset_id: string
platform: string
kind: string
filename: string
sha256: string
byte_length: number
r2_key: string
}
/**
* The name a client downloads. Unique per build, platform, and kind, so a
* filename lookup cannot return another room's bundle.
* A Studio bundle's `unity_asset.filename` — the name the client downloads, which it
* prefixes with `/room/` (see {@link studioBundleR2Key}). Laid out like the official
* game's room assets (`unity/<roomId>/<name>.assetbundle`) with `studio` in place of
* `unity`, so a room's bundles sit together under its id. Unique per build, platform,
* and kind, so two rooms that both uploaded `win.assetbundle` cannot collide.
*/
export function publicStudioBundleFilename(
roomId: number,
unityAssetId: string,
platform: string,
platform: StudioBundlePlatform,
kind: string
): string {
const id = unityAssetId.replace(/-/g, '').toLowerCase()
return `${id}.${platform}.${kind}.assetbundle`
return `studio/${roomId}/${id}.${platform}.${kind}.assetbundle`
}
/**
* 0 for Windows, 2 for Android/Quest. Anything else is treated as Windows:
* those are the only two platforms a local build stores.
* Where the bytes behind a `unity_asset.filename` live in the shared `recflare-cdn`
* bucket. The game client fetches every room asset under `/room/`, straight from the
* bucket, so the key is the filename with that prefix — `room/studio/<roomId>/<name>`.
*/
export function studioAssetTarget(platform: string): number {
export function studioBundleR2Key(filename: string): string {
return `room/${filename}`
}
/**
* 0 for Windows, 2 for Android/Quest — the `Target` a bundle is stored under, the same
* numbers the game sends as `unityAssetTarget` on a custom avatar.
*/
export function studioAssetTarget(platform: StudioBundlePlatform): number {
return platform === 'android' ? 2 : 0
}
/** Base64 SHA-256, which is the hash encoding avatar and invention downloads use. */
export function sha256HexToBase64(hex: string): string {
const clean = hex.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(clean)) return ''
const bytes = new Uint8Array(32)
for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary)
}
/** True when D1 failed because the studio asset tables have not been migrated. */
/** True when D1 failed because the studio cloud-build table has not been migrated. */
export function isMissingStudioAssetTable(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return message.toLowerCase().includes('no such table')
}
function toStudioUnityAssetFile(row: StudioUnityAssetFileRow): StudioUnityAssetFile | null {
if (row.platform !== 'windows' && row.platform !== 'android') return null
if (row.kind !== 'main' && row.kind !== 'stripped') return null
return {
unityAssetId: row.unity_asset_id,
platform: row.platform,
kind: row.kind,
filename: row.filename,
sha256: row.sha256,
byteLength: row.byte_length,
r2Key: row.r2_key,
}
}
/** Every stored file for the given unity asset ids. An empty id list reads nothing. */
export async function listStudioUnityAssetFiles(
db: D1Database,
unityAssetIds: string[]
): Promise<StudioUnityAssetFile[]> {
const ids = [...new Set(unityAssetIds.filter((id) => id !== ''))]
if (ids.length === 0) return []
const pages = await Promise.all(
chunkForBinds(ids).map((chunk) =>
db
.prepare(
`SELECT unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key
FROM studio_unity_asset_file
WHERE unity_asset_id IN (${bindPlaceholders(chunk)})`
)
.bind(...chunk)
.all<StudioUnityAssetFileRow>()
)
)
return pages
.flatMap((page) => page.results)
.map(toStudioUnityAssetFile)
.filter((file): file is StudioUnityAssetFile => file !== null)
}
/**
* The one file stored under `filename`. Null when none matches, and also when
* more than one row shares the name: serving either would hand the client the
* wrong room's bundle.
*/
export async function findStudioUnityAssetByFilename(
db: D1Database,
filename: string
): Promise<StudioUnityAssetFile | null> {
const { results } = await db
.prepare(
`SELECT unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key
FROM studio_unity_asset_file WHERE filename = ?1`
)
.bind(filename)
.all<StudioUnityAssetFileRow>()
if (results.length !== 1) return null
return toStudioUnityAssetFile(results[0]!)
}
export interface StudioUnityAssetRecord {
unityAssetId: string
/** 0 when the cloud-build row is gone. The route can fall back to the save's author. */
createdByAccountId: number
files: StudioUnityAssetFile[]
/** Every stored bundle of the asset, stripped ones included. */
builds: UnityAssetBuild[]
}
/**
* One unity asset and the account that built it. Null when no files are stored.
* A missing cloud-build table leaves `createdByAccountId` at 0; a missing file
* table throws, and {@link isMissingStudioAssetTable} tells the caller apart.
* One unity asset and the account that built it. Null when no bundle is stored. A
* missing cloud-build table leaves `createdByAccountId` at 0.
*/
export async function getStudioUnityAsset(
db: D1Database,
unityAssetId: string
): Promise<StudioUnityAssetRecord | null> {
const files = await listStudioUnityAssetFiles(db, [unityAssetId])
if (files.length === 0) return null
const builds = await listUnityAssetBuilds(db, [unityAssetId])
if (builds.length === 0) return null
let createdByAccountId = 0
try {
const row = await db
@@ -209,23 +111,5 @@ export async function getStudioUnityAsset(
} catch (err) {
if (!isMissingStudioAssetTable(err)) throw err
}
return { unityAssetId, createdByAccountId, files }
}
/**
* The main bundles of these files, Windows then Android. Stripped bundles stay
* in storage and are left off this list: a client that loads every entry would
* otherwise replace the scene with the stripped one.
*/
export function bakedStudioUnityAssets(files: StudioUnityAssetFile[]): BakedStudioUnityAsset[] {
return files
.filter((file) => file.kind === 'main')
.map((file) => ({
UnityAssetId: file.unityAssetId,
Target: studioAssetTarget(file.platform),
Version: STUDIO_ASSET_VERSION,
Filename: file.filename,
Hash: sha256HexToBase64(file.sha256),
}))
.sort((a, b) => a.Target - b.Target || a.Filename.localeCompare(b.Filename))
return { unityAssetId, createdByAccountId, builds }
}
+118 -28
View File
@@ -3,42 +3,54 @@
*
* `unity_asset` holds one row per BUILD of a unity asset: the asset's GUID, the `Target`
* it was built for (0 Windows, 2 Android/Quest — the same numbers a custom avatar's
* `unityAssetTarget` uses), its `Version`, and the storage blob the client downloads
* (`Filename`, a `<date>/<guid>` path, and its base64 SHA-256 `Hash`). The client asks for
* these in bulk from `POST /unity_assets/baked/bulk` (the `rooms` worker) while loading a
* room — the step Player.log times as `AssetBundle_Download` — and downloads `Filename`
* from the CDN; a lookup that answers no row, or a row without a `Filename`, simply
* downloads nothing, with no error anywhere.
* `unityAssetTarget` uses), its `Version`, the `Kind` of bundle (`main`, the scene the
* client loads; `stripped`, the editor-only variant the Studio upload also stores), and
* the storage blob the client downloads (`Filename`, fetched as `/room/<Filename>` straight
* from the shared `recflare-cdn` bucket, and its base64 SHA-256 `Hash`).
*
* This is a separate store from the Rec Room Studio builds in `studio_unity_asset_file`
* (see studio-unity-assets.ts), which the studio worker writes per platform and kind and
* which `GET /rooms/{id}` folds onto `CurrentSave.UnitySubAssets`. Nothing writes this
* table yet; it is the record the bulk lookup reads.
* Two things read it. The client asks for main bundles in bulk from
* `POST /unity_assets/baked/bulk` (the `rooms` worker) while loading a room — the step
* Player.log times as `AssetBundle_Download` — and `GET /rooms/{id}` folds the same main
* bundles onto `CurrentSave.UnitySubAssets`; either way the client then downloads
* `/room/<Filename>` from the bucket, and a lookup that answers no row,
* or a row without a `Filename`, simply downloads nothing, with no error anywhere.
*
* The `rooms` worker owns the table and its migration — see
* apps/rooms/migrations/0024_unity_asset.sql.
* The `studio` worker writes it: a Rec Room Studio build posts a Windows + Android bundle
* pair (plus optional stripped ones), stored here at {@link STUDIO_ASSET_VERSION} with the
* cloud-build record in `studio_cloud_build` (see studio-unity-assets.ts).
*
* The `rooms` worker owns the table and its migrations — see
* apps/rooms/migrations/0024_unity_asset.sql and 0026_unity_asset_kind.sql.
*/
import { bindPlaceholders, chunkForBinds } from './d1-binds'
/** Schema DDL (mirror of apps/rooms/migrations/0024_unity_asset.sql) — tests apply this. */
/** Schema DDL (mirror of apps/rooms/migrations/0026_unity_asset_kind.sql) — tests apply this. */
export const UNITY_ASSET_SCHEMA_DDL: string[] = [
// A unity asset is built once per target, and may be rebuilt at a new version, so the
// three together name a row. `unity_asset_id` is stored lowercase: a GUID's case is not
// part of its identity and the client is not consistent about it.
// A unity asset is built once per target and kind, and may be rebuilt at a new version,
// so the four together name a row. `unity_asset_id` is stored lowercase: a GUID's case
// is not part of its identity and the client is not consistent about it.
`CREATE TABLE IF NOT EXISTS unity_asset (
unity_asset_id TEXT NOT NULL,
target INTEGER NOT NULL,
version INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'main',
filename TEXT NOT NULL,
hash TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, target, version)
PRIMARY KEY (unity_asset_id, target, version, kind)
)`,
]
/**
* Which bundle of a build a row is. `main` is the scene the game loads; `stripped` is the
* editor-only variant Rec Room Studio uploads beside it, stored but never listed to the
* game — a client that loaded every row would replace the scene with the stripped one.
*/
export type UnityAssetKind = 'main' | 'stripped'
/**
* One baked bundle as the client reads it — its five-key DTO, in this order. An element of
* the bare array `POST /unity_assets/baked/bulk` answers.
* the bare array `POST /unity_assets/baked/bulk` answers, and of a save's `UnitySubAssets`.
*/
export interface BakedUnityAsset {
/** GUID. */
@@ -46,16 +58,22 @@ export interface BakedUnityAsset {
/** 0 Windows, 2 Android/Quest. */
Target: number
Version: number
/** The storage blob path the client downloads, `<date>/<guid>`. */
/** The storage blob the client downloads, as `/room/<Filename>` from the bucket. */
Filename: string
/** Base64 SHA-256 of the blob. */
Hash: string
}
/** One stored row: a baked bundle and which kind of bundle it is. */
export interface UnityAssetBuild extends BakedUnityAsset {
Kind: UnityAssetKind
}
interface UnityAssetRow {
unity_asset_id: string
target: number
version: number
kind: string
filename: string
hash: string
}
@@ -68,19 +86,54 @@ const toBakedUnityAsset = (row: UnityAssetRow): BakedUnityAsset => ({
Hash: row.hash,
})
/** Record a build. Replaces the row for the same (asset, target, version). */
export async function putUnityAsset(db: D1Database, asset: BakedUnityAsset): Promise<void> {
await db
const toUnityAssetBuild = (row: UnityAssetRow): UnityAssetBuild => ({
...toBakedUnityAsset(row),
Kind: row.kind === 'stripped' ? 'stripped' : 'main',
})
/**
* The statement that records a build, for callers that batch it with other writes.
* Replaces the row for the same (asset, target, version, kind).
*/
export function unityAssetUpsert(
db: D1Database,
asset: BakedUnityAsset,
kind: UnityAssetKind = 'main'
): D1PreparedStatement {
return db
.prepare(
`INSERT OR REPLACE INTO unity_asset (unity_asset_id, target, version, filename, hash)
VALUES (?1, ?2, ?3, ?4, ?5)`
`INSERT OR REPLACE INTO unity_asset (unity_asset_id, target, version, kind, filename, hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`
)
.bind(asset.UnityAssetId.toLowerCase(), asset.Target, asset.Version, asset.Filename, asset.Hash)
.bind(
asset.UnityAssetId.toLowerCase(),
asset.Target,
asset.Version,
kind,
asset.Filename,
asset.Hash
)
}
/** Record a build. Replaces the row for the same (asset, target, version, kind). */
export async function putUnityAsset(
db: D1Database,
asset: BakedUnityAsset,
kind: UnityAssetKind = 'main'
): Promise<void> {
await unityAssetUpsert(db, asset, kind).run()
}
/** Forget every build of `unityAssetId`, all targets and kinds. The blobs stay in R2. */
export async function deleteUnityAssetBuilds(db: D1Database, unityAssetId: string): Promise<void> {
await db
.prepare('DELETE FROM unity_asset WHERE unity_asset_id = ?1')
.bind(unityAssetId.toLowerCase())
.run()
}
/**
* The baked bundles for `unityAssetIds` on `target` — one entry per asset that has one, in
* The main bundles for `unityAssetIds` on `target` — one entry per asset that has one, in
* the order asked, assets with no build left out. An asset built more than once is served
* ONCE: the build at the requested `version` when there is one, otherwise the newest. The
* request's version is a preference, not a filter, because the one capture pairing the two
@@ -102,8 +155,9 @@ export async function getBakedUnityAssets(
chunkForBinds(ids, 2).map((chunk) =>
db
.prepare(
`SELECT unity_asset_id, target, version, filename, hash FROM unity_asset
WHERE target = ?1 AND unity_asset_id IN (${bindPlaceholders(chunk, 2)})
`SELECT unity_asset_id, target, version, kind, filename, hash FROM unity_asset
WHERE target = ?1 AND kind = 'main'
AND unity_asset_id IN (${bindPlaceholders(chunk, 2)})
ORDER BY (version = ?2) DESC, version DESC`
)
.bind(target, version, ...chunk)
@@ -117,3 +171,39 @@ export async function getBakedUnityAssets(
}
return ids.flatMap((id) => best.get(id) ?? [])
}
/**
* Every stored build of the given unity assets — all targets, versions and kinds. Ids are
* matched case-insensitively and served as stored (lowercase). An empty id list reads
* nothing.
*/
export async function listUnityAssetBuilds(
db: D1Database,
unityAssetIds: string[]
): Promise<UnityAssetBuild[]> {
const ids = [...new Set(unityAssetIds.filter((id) => id !== '').map((id) => id.toLowerCase()))]
if (ids.length === 0) return []
const pages = await Promise.all(
chunkForBinds(ids).map((chunk) =>
db
.prepare(
`SELECT unity_asset_id, target, version, kind, filename, hash FROM unity_asset
WHERE unity_asset_id IN (${bindPlaceholders(chunk)})`
)
.bind(...chunk)
.all<UnityAssetRow>()
)
)
return pages.flatMap((page) => page.results).map(toUnityAssetBuild)
}
/**
* The main bundles among `builds` as a save lists them: Windows then Android, the client's
* five-key DTO. Stripped bundles are left off — see {@link UnityAssetKind}.
*/
export function bakedUnityAssets(builds: UnityAssetBuild[]): BakedUnityAsset[] {
return builds
.filter((build) => build.Kind === 'main')
.map(({ Kind: _kind, ...baked }) => baked)
.sort((a, b) => a.Target - b.Target || a.Filename.localeCompare(b.Filename))
}