diff --git a/apps/cdn/README.md b/apps/cdn/README.md index 2469343..a9347b8 100644 --- a/apps/cdn/README.md +++ b/apps/cdn/README.md @@ -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//` (the game's own `unity//` 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. diff --git a/apps/cdn/src/cdn.app.ts b/apps/cdn/src/cdn.app.ts index b561e37..cce568b 100644 --- a/apps/cdn/src/cdn.app.ts +++ b/apps/cdn/src/cdn.app.ts @@ -354,10 +354,11 @@ const app = new Hono() ) // 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//…`), 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() 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() 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() ) /** - * 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 { 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.', diff --git a/apps/cdn/src/context.ts b/apps/cdn/src/context.ts index 5bbc7e6..d37a7bc 100644 --- a/apps/cdn/src/context.ts +++ b/apps/cdn/src/context.ts @@ -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/` and // room build data under `room/`. diff --git a/apps/cdn/src/test/integration/api.test.ts b/apps/cdn/src/test/integration/api.test.ts index f958887..13c49ab 100644 --- a/apps/cdn/src/test/integration/api.test.ts +++ b/apps/cdn/src/test/integration/api.test.ts @@ -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 () => { diff --git a/apps/rooms/migrations/0026_unity_asset_kind.sql b/apps/rooms/migrations/0026_unity_asset_kind.sql new file mode 100644 index 0000000..33a9c67 --- /dev/null +++ b/apps/rooms/migrations/0026_unity_asset_kind.sql @@ -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; diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 2e295a3..3a80a79 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -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() 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, }) } ) diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 50672f7..d0d8bf6 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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 ) } diff --git a/apps/studio/README.md b/apps/studio/README.md index 36223e8..0cded81 100644 --- a/apps/studio/README.md +++ b/apps/studio/README.md @@ -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//.assetbundle` as `filename` — the bytes sit at `room/` 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 diff --git a/apps/studio/migrations/0002_drop_studio_unity_asset_file.sql b/apps/studio/migrations/0002_drop_studio_unity_asset_file.sql new file mode 100644 index 0000000..1793486 --- /dev/null +++ b/apps/studio/migrations/0002_drop_studio_unity_asset_file.sql @@ -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; diff --git a/apps/studio/src/context.ts b/apps/studio/src/context.ts index f918308..6d7e60a 100644 --- a/apps/studio/src/context.ts +++ b/apps/studio/src/context.ts @@ -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//`. The same bucket name the rooms and cdn workers bind. CDN_ASSETS: R2Bucket } diff --git a/apps/studio/src/local-builds.ts b/apps/studio/src/local-builds.ts index e510ad9..247575e 100644 --- a/apps/studio/src/local-builds.ts +++ b/apps/studio/src/local-builds.ts @@ -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 { +/** Base64 SHA-256 — the encoding `unity_asset.hash` (and every other bundle hash) uses. */ +async function sha256Base64(bytes: ArrayBuffer): Promise { 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/`), 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/` 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 } diff --git a/apps/studio/src/test/integration/api.test.ts b/apps/studio/src/test/integration/api.test.ts index 6f97d44..3070085 100644 --- a/apps/studio/src/test/integration/api.test.ts +++ b/apps/studio/src/test/integration/api.test.ts @@ -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) }) }) diff --git a/apps/studio/wrangler.jsonc b/apps/studio/wrangler.jsonc index 0f6c6c8..22f8383 100644 --- a/apps/studio/wrangler.jsonc +++ b/apps/studio/wrangler.jsonc @@ -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//`. // bucket_name is a literal, so nothing is spliced at deploy time. "r2_buckets": [ { diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index bce42ab..e57080f 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -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() - 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() + 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 } diff --git a/packages/domain/src/studio-unity-assets.ts b/packages/domain/src/studio-unity-assets.ts index b0b78ef..edc6ab8 100644 --- a/packages/domain/src/studio-unity-assets.ts +++ b/packages/domain/src/studio-unity-assets.ts @@ -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//.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//`. */ -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 { - 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() - ) - ) - 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 { - 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() - 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 { - 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 } } diff --git a/packages/domain/src/unity-assets-db.ts b/packages/domain/src/unity-assets-db.ts index da3a3c6..a89f52b 100644 --- a/packages/domain/src/unity-assets-db.ts +++ b/packages/domain/src/unity-assets-db.ts @@ -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 `/` 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/` 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/` 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, `/`. */ + /** The storage blob the client downloads, as `/room/` 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 { - 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 { + 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 { + 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 { + 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() + ) + ) + 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)) +}