mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[studio] prefix assets
This commit is contained in:
+4
-3
@@ -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/`
|
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.
|
(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
|
`/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`
|
`studio/<roomId>/` (the game's own `unity/<roomId>/` layout, under another prefix), its key
|
||||||
(the studio worker owns that table) before streaming the same way. `HEAD` on that path is
|
recorded as the filename of a `unity_asset` row (the rooms worker owns that table; the
|
||||||
200 when exactly one bundle has the name.
|
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
|
Reads are unauthenticated: a caller needs the exact key, which only comes from an
|
||||||
authenticated call to another worker.
|
authenticated call to another worker.
|
||||||
|
|
||||||
|
|||||||
+20
-23
@@ -354,10 +354,11 @@ const app = new Hono<App>()
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Rec Room Studio room bundles. The filename is the one a room save or
|
// 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
|
// `GET …/unityasset` advertises — the R2 key itself (`studio/<roomId>/…`), as
|
||||||
// wrote (`studio-room-bundles/…`), which is not the URL, so this looks the name
|
// `unity_asset.filename` records it. Only a recorded filename is served, so this
|
||||||
// up. Reads stay unauthenticated, same as `/avatar/` and `/room/`. HEAD is the
|
// checks the table before streaming the key. Reads stay unauthenticated, same as
|
||||||
// editor's "already uploaded?" check: 200 exists, 404 does not.
|
// `/avatar/` and `/room/`. HEAD is the editor's "already uploaded?" check: 200
|
||||||
|
// exists, 404 does not.
|
||||||
.on(
|
.on(
|
||||||
'HEAD',
|
'HEAD',
|
||||||
'/unityasset/:filename{.+}',
|
'/unityasset/:filename{.+}',
|
||||||
@@ -365,7 +366,7 @@ const app = new Hono<App>()
|
|||||||
tags: ['Assets'],
|
tags: ['Assets'],
|
||||||
summary: 'Check that a Rec Room Studio room bundle exists',
|
summary: 'Check that a Rec Room Studio room bundle exists',
|
||||||
description: [
|
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',
|
'Studio editor treats this HEAD as “already uploaded?” before it downloads the bytes',
|
||||||
'with GET. No body.',
|
'with GET. No body.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
@@ -386,12 +387,9 @@ const app = new Hono<App>()
|
|||||||
description: [
|
description: [
|
||||||
'Streams the Studio asset bundle stored for `filename`. The name comes from a',
|
'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`',
|
'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',
|
'on the rooms worker) and is the R2 key the studio upload stored the bytes under.',
|
||||||
'recorded, so this route looks the filename up rather than using it as the key.',
|
'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.',
|
||||||
'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.',
|
|
||||||
].join(' '),
|
].join(' '),
|
||||||
parameters: [
|
parameters: [
|
||||||
keyParam('filename', 'The bundle filename from the room save.', true),
|
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,
|
* The R2 key for a Studio bundle filename — the filename itself, once `unity_asset` says
|
||||||
* or the studio tables have not been migrated yet.
|
* 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> {
|
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
// Same lookup as `@repo/domain` `findStudioUnityAssetByFilename`. Inlined so this
|
// Inlined rather than imported so this worker does not take a dependency on the
|
||||||
// worker does not take a dependency on the domain package. More than one row is a
|
// domain package.
|
||||||
// miss: two rooms must not be served each other's bundle.
|
const row = await db
|
||||||
const { results } = await db
|
.prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`)
|
||||||
.prepare(`SELECT r2_key FROM studio_unity_asset_file WHERE filename = ?1`)
|
|
||||||
.bind(filename)
|
.bind(filename)
|
||||||
.all<{ r2_key: string }>()
|
.first<{ stored: number }>()
|
||||||
if (results.length !== 1) return null
|
return row ? filename : null
|
||||||
return results[0]!.r2_key
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
if (message.toLowerCase().includes('no such table')) return null
|
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',
|
'`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',
|
'are unauthenticated — a caller needs the exact key, which only comes from an',
|
||||||
'authenticated call to another worker. `/unityasset/{filename}` is a Studio room',
|
'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',
|
'bundle: the filename is the R2 key, streamed once `unity_asset` records it.',
|
||||||
'key is streamed. `HEAD` on that path is 200 when exactly one bundle has the name.',
|
'`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',
|
'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.',
|
'same bucket, and images are served by `img` rather than from here.',
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ export type Env = SharedHonoEnv & {
|
|||||||
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
|
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
|
||||||
// signed by `auth` verify here.
|
// signed by `auth` verify here.
|
||||||
JWT_SECRET: SecretsStoreSecret
|
JWT_SECRET: SecretsStoreSecret
|
||||||
// Shared `recflare` database, read-only here. Studio bundle downloads resolve a
|
// Shared `recflare` database, read-only here. Studio bundle downloads check that
|
||||||
// public filename to its R2 key through `studio_unity_asset_file`. The studio
|
// `unity_asset` records the requested filename (which is the R2 key) before
|
||||||
// worker owns that table's migration; this binding has no migrations_dir.
|
// streaming it. The rooms worker owns that table's migration; this binding has no
|
||||||
|
// migrations_dir.
|
||||||
DB: D1Database
|
DB: D1Database
|
||||||
// R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
|
// R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
|
||||||
// room build data under `room/<name>`.
|
// room build data under `room/<name>`.
|
||||||
|
|||||||
@@ -286,33 +286,35 @@ describe('cdn endpoints', () => {
|
|||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET and HEAD /unityasset/:filename stream one stored studio bundle', async () => {
|
test('GET and HEAD /unityasset/:filename stream a stored studio bundle', async () => {
|
||||||
await env.DB.prepare('DROP TABLE IF EXISTS studio_unity_asset_file').run()
|
await env.DB.prepare('DROP TABLE IF EXISTS unity_asset').run()
|
||||||
const absent = await exports.default.fetch(`${ORIGIN}/unityasset/not-stored.assetbundle`)
|
const absent = await exports.default.fetch(`${ORIGIN}/unityasset/not-stored.assetbundle`)
|
||||||
expect(absent.status).toBe(404)
|
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(
|
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,
|
unity_asset_id TEXT NOT NULL,
|
||||||
platform TEXT NOT NULL,
|
target INTEGER NOT NULL,
|
||||||
kind TEXT NOT NULL,
|
version INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'main',
|
||||||
filename TEXT NOT NULL,
|
filename TEXT NOT NULL,
|
||||||
sha256 TEXT NOT NULL,
|
hash TEXT NOT NULL,
|
||||||
byte_length INTEGER NOT NULL,
|
PRIMARY KEY (unity_asset_id, target, version, kind)
|
||||||
r2_key TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (unity_asset_id, platform, kind)
|
|
||||||
)`
|
)`
|
||||||
).run()
|
).run()
|
||||||
const filename = 'abc111.windows.main.assetbundle'
|
// The filename IS the R2 key.
|
||||||
const key = `studio-room-bundles/asset-a/windows/main/${filename}`
|
const filename = 'studio/20930/abc111.windows.main.assetbundle'
|
||||||
const insert = (unityAssetId: string, r2Key: string) =>
|
await env.DB.prepare(
|
||||||
env.DB.prepare(
|
`INSERT INTO unity_asset (unity_asset_id, target, version, kind, filename, hash)
|
||||||
`INSERT INTO studio_unity_asset_file
|
VALUES ('asset-a', 0, 1, 'main', ?1, 'n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo=')`
|
||||||
(unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key)
|
)
|
||||||
VALUES (?1, 'windows', 'main', ?2, 'ab', 4, ?3)`
|
.bind(filename)
|
||||||
).bind(unityAssetId, filename, r2Key)
|
.run()
|
||||||
await insert('asset-a', key).run()
|
await env.CDN_ASSETS.put(filename, new Uint8Array([1, 2, 3, 4]))
|
||||||
await env.CDN_ASSETS.put(key, 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}`)
|
const res = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -325,10 +327,11 @@ describe('cdn endpoints', () => {
|
|||||||
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
|
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/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)
|
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 () => {
|
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
@@ -8,6 +8,7 @@ import {
|
|||||||
applyRoomTagEdit,
|
applyRoomTagEdit,
|
||||||
attachStudioUnityAssets,
|
attachStudioUnityAssets,
|
||||||
autocompleteRoomSearch,
|
autocompleteRoomSearch,
|
||||||
|
bakedUnityAssets,
|
||||||
banPlayerFromRoom,
|
banPlayerFromRoom,
|
||||||
canManageRoom,
|
canManageRoom,
|
||||||
canManageRoomById,
|
canManageRoomById,
|
||||||
@@ -53,7 +54,6 @@ import {
|
|||||||
getVisitedRooms,
|
getVisitedRooms,
|
||||||
incrementRoomExperience,
|
incrementRoomExperience,
|
||||||
inviteRoomRole,
|
inviteRoomRole,
|
||||||
isMissingStudioAssetTable,
|
|
||||||
isPlayerBannedFromRoom,
|
isPlayerBannedFromRoom,
|
||||||
isRoomOwner,
|
isRoomOwner,
|
||||||
MessageType,
|
MessageType,
|
||||||
@@ -78,9 +78,6 @@ import {
|
|||||||
setRoomProgression,
|
setRoomProgression,
|
||||||
setRoomRole,
|
setRoomRole,
|
||||||
setSubRoomPermissions,
|
setSubRoomPermissions,
|
||||||
sha256HexToBase64,
|
|
||||||
STUDIO_ASSET_VERSION,
|
|
||||||
studioAssetTarget,
|
|
||||||
toggleCheer,
|
toggleCheer,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
transferRoomOwnership,
|
transferRoomOwnership,
|
||||||
@@ -3819,33 +3816,27 @@ const app = new Hono<App>()
|
|||||||
const save = saves.find((row) => row.UnityAssetId === unityAssetId)
|
const save = saves.find((row) => row.UnityAssetId === unityAssetId)
|
||||||
if (!save) return c.notFound()
|
if (!save) return c.notFound()
|
||||||
|
|
||||||
let asset
|
const asset = await getStudioUnityAsset(c.env.DB, unityAssetId)
|
||||||
try {
|
|
||||||
asset = await getStudioUnityAsset(c.env.DB, unityAssetId)
|
|
||||||
} catch (err) {
|
|
||||||
if (isMissingStudioAssetTable(err)) return c.notFound()
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
if (!asset) return c.notFound()
|
if (!asset) return c.notFound()
|
||||||
|
|
||||||
const mains = asset.files
|
// Main bundles, Windows first; the Windows one (or the first there is) supplies
|
||||||
.filter((file) => file.kind === 'main')
|
// the top-level `filename`/`hash`.
|
||||||
.sort((a, b) => studioAssetTarget(a.platform) - studioAssetTarget(b.platform))
|
const mains = bakedUnityAssets(asset.builds)
|
||||||
const source = mains.find((file) => file.platform === 'windows') ?? mains[0]
|
const source = mains.find((build) => build.Target === 0) ?? mains[0]
|
||||||
if (!source) return c.notFound()
|
if (!source) return c.notFound()
|
||||||
|
|
||||||
const savedBy = typeof save.SavedByAccountId === 'number' ? save.SavedByAccountId : 0
|
const savedBy = typeof save.SavedByAccountId === 'number' ? save.SavedByAccountId : 0
|
||||||
return c.json({
|
return c.json({
|
||||||
unityAssetId: asset.unityAssetId,
|
unityAssetId: asset.unityAssetId,
|
||||||
createdByAccountId: asset.createdByAccountId || savedBy,
|
createdByAccountId: asset.createdByAccountId || savedBy,
|
||||||
bakedUnityAssets: mains.map((file) => ({
|
bakedUnityAssets: mains.map((build) => ({
|
||||||
unityAssetId: file.unityAssetId,
|
unityAssetId: build.UnityAssetId,
|
||||||
target: studioAssetTarget(file.platform),
|
target: build.Target,
|
||||||
version: STUDIO_ASSET_VERSION,
|
version: build.Version,
|
||||||
filename: file.filename,
|
filename: build.Filename,
|
||||||
})),
|
})),
|
||||||
filename: source.filename,
|
filename: source.Filename,
|
||||||
hash: sha256HexToBase64(source.sha256),
|
hash: source.Hash,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import {
|
|||||||
ROOM_SCHEMA_DDL,
|
ROOM_SCHEMA_DDL,
|
||||||
ROOM_XP_SCHEMA_DDL,
|
ROOM_XP_SCHEMA_DDL,
|
||||||
seedRoomWithSubRooms,
|
seedRoomWithSubRooms,
|
||||||
sha256HexToBase64,
|
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
|
||||||
STUDIO_UNITY_ASSET_SCHEMA_DDL,
|
|
||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
UNITY_ASSET_SCHEMA_DDL,
|
UNITY_ASSET_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} 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
|
// A Rec Room Studio build is a unity asset on the save: `unity_asset` rows the studio
|
||||||
// `CurrentSave.DataBlob` and the bundles from `UnitySubAssets`. Maker-pen saves stay
|
// worker wrote. The game loads the scene from `CurrentSave.DataBlob` and the bundles from
|
||||||
// empty arrays, and a missing studio table must not fail the room read.
|
// `UnitySubAssets`. Maker-pen saves stay empty arrays, and a missing studio cloud-build
|
||||||
|
// table must not fail the room read.
|
||||||
describe('studio room bundles', () => {
|
describe('studio room bundles', () => {
|
||||||
const ASSET = '11111111-2222-4333-8444-555555555555'
|
const ASSET = '11111111-2222-4333-8444-555555555555'
|
||||||
const WIN_HEX = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
const winName = publicStudioBundleFilename(9901, ASSET, 'windows', 'main')
|
||||||
const ANDROID_HEX = 'ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb'
|
const androidName = publicStudioBundleFilename(9901, ASSET, 'android', 'main')
|
||||||
const winName = publicStudioBundleFilename(ASSET, 'windows', 'main')
|
const winHash = '47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
|
||||||
const androidName = publicStudioBundleFilename(ASSET, 'android', 'main')
|
const androidHash = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs='
|
||||||
const winHash = sha256HexToBase64(WIN_HEX)
|
|
||||||
const androidHash = sha256HexToBase64(ANDROID_HEX)
|
|
||||||
|
|
||||||
const save = (unityAssetId?: string) => ({
|
const save = (unityAssetId?: string) => ({
|
||||||
UnitySubAssets: [],
|
UnitySubAssets: [],
|
||||||
@@ -6038,8 +6036,8 @@ describe('studio room bundles', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function reset() {
|
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('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_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 subroom WHERE room_id IN (9901, 9902)').run()
|
||||||
await env.DB.prepare('DELETE FROM room 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() {
|
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(
|
await env.DB.prepare(
|
||||||
`INSERT INTO studio_cloud_build
|
`INSERT INTO studio_cloud_build
|
||||||
(cloud_build_id, room_id, sub_room_id, sub_room_data_save_id, unity_asset_id,
|
(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)
|
.bind(ASSET)
|
||||||
.run()
|
.run()
|
||||||
const insert = async (platform: string, kind: string, filename: string, sha256: string) => {
|
const insert = (target: number, kind: 'main' | 'stripped', filename: string, hash: string) =>
|
||||||
await env.DB.prepare(
|
putUnityAsset(
|
||||||
`INSERT INTO studio_unity_asset_file
|
env.DB,
|
||||||
(unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key)
|
{ UnityAssetId: ASSET, Target: target, Version: 1, Filename: filename, Hash: hash },
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, 4, ?6)`
|
kind
|
||||||
)
|
)
|
||||||
.bind(ASSET, platform, kind, filename, sha256, `studio-room-bundles/${filename}`)
|
await insert(0, 'main', winName, winHash)
|
||||||
.run()
|
await insert(2, 'main', androidName, androidHash)
|
||||||
}
|
|
||||||
await insert('windows', 'main', winName, WIN_HEX)
|
|
||||||
await insert('android', 'main', androidName, ANDROID_HEX)
|
|
||||||
await insert(
|
await insert(
|
||||||
'windows',
|
0,
|
||||||
'stripped',
|
'stripped',
|
||||||
publicStudioBundleFilename(ASSET, 'windows', 'stripped'),
|
publicStudioBundleFilename(9901, ASSET, 'windows', 'stripped'),
|
||||||
WIN_HEX
|
winHash
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
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`)
|
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 |
|
| Method | Path | Purpose |
|
||||||
| ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -37,10 +37,12 @@ pnpm turbo deploy
|
|||||||
|
|
||||||
### Migrate
|
### Migrate
|
||||||
|
|
||||||
This worker owns `studio_cloud_build` and `studio_unity_asset_file` on the shared
|
This worker owns `studio_cloud_build` on the shared `recflare` database (`migrations/`,
|
||||||
`recflare` database (`migrations/`, tracked in its own `d1_migrations_studio` table).
|
tracked in its own `d1_migrations_studio` table). The bundles themselves are rows of the
|
||||||
Deploying does not apply them; the cdn worker reads `studio_unity_asset_file` and
|
rooms worker's `unity_asset` (one per platform and kind: target 0 Windows / 2 Android,
|
||||||
answers 404 for every Studio bundle until this has run.
|
`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
|
```sh
|
||||||
just migrate -F studio # remote
|
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;
|
||||||
@@ -10,11 +10,12 @@ export type Env = SharedHonoEnv & {
|
|||||||
JWT_SECRET: SecretsStoreSecret
|
JWT_SECRET: SecretsStoreSecret
|
||||||
// Shared `recflare` D1. Room ownership lives in the `room` blob (the `rooms`
|
// Shared `recflare` D1. Room ownership lives in the `room` blob (the `rooms`
|
||||||
// worker owns that schema) and who is standing where lives in `presence`.
|
// worker owns that schema) and who is standing where lives in `presence`.
|
||||||
// This worker migrates its own cloud-build tables (`studio_cloud_build`,
|
// This worker migrates its own cloud-build table (`studio_cloud_build`), writes
|
||||||
// `studio_unity_asset_file`) and only reads the room tables.
|
// each bundle it stores as a row of the rooms worker's `unity_asset`, and only
|
||||||
|
// reads the room tables.
|
||||||
DB: D1Database
|
DB: D1Database
|
||||||
// Shared `recflare-cdn` bucket. Local Studio builds land under
|
// 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
|
CDN_ASSETS: R2Bucket
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
|
deleteUnityAssetBuilds,
|
||||||
getSubRoom,
|
getSubRoom,
|
||||||
markRoomAsRecRoomStudio,
|
markRoomAsRecRoomStudio,
|
||||||
publicStudioBundleFilename,
|
publicStudioBundleFilename,
|
||||||
setSubRoomSaveUnityAssetId,
|
setSubRoomSaveUnityAssetId,
|
||||||
|
STUDIO_ASSET_VERSION,
|
||||||
|
studioAssetTarget,
|
||||||
|
studioBundleR2Key,
|
||||||
|
unityAssetUpsert,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
/** Mirrors migrations/0001_studio_cloud_build.sql. Tests apply this; deploy uses the file. */
|
import type { StudioBundlePlatform, UnityAssetKind } from '@repo/domain'
|
||||||
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)
|
|
||||||
)`,
|
|
||||||
]
|
|
||||||
|
|
||||||
export interface CloudBuildRow {
|
export interface CloudBuildRow {
|
||||||
cloudBuildId: string
|
cloudBuildId: string
|
||||||
@@ -41,8 +21,8 @@ export interface CloudBuildRow {
|
|||||||
createdByAccountId: number
|
createdByAccountId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BundlePlatform = 'windows' | 'android'
|
export type BundlePlatform = StudioBundlePlatform
|
||||||
export type BundleKind = 'main' | 'stripped'
|
export type BundleKind = UnityAssetKind
|
||||||
|
|
||||||
export interface LocalBundleFile {
|
export interface LocalBundleFile {
|
||||||
platform: BundlePlatform
|
platform: BundlePlatform
|
||||||
@@ -103,16 +83,22 @@ export async function listCloudBuilds(
|
|||||||
return { results: results.map(toCloudBuild), totalResults: Number(total?.n ?? 0) }
|
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)
|
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.
|
* 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
|
* The bytes go to R2 under `room/` and each bundle becomes a `unity_asset` row — target
|
||||||
* `UnityAssetId` is set to the new asset, and the room is marked as a Studio room the
|
* 0 Windows / 2 Android, its kind, the `filename` the client prefixes with `/room/` to
|
||||||
* first time (`BecameRRStudioRoomAt`).
|
* 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
|
* Returns null when the subroom has no current save — the caller uploaded bundles
|
||||||
* before Upload created one.
|
* before Upload created one.
|
||||||
@@ -140,34 +126,30 @@ export async function storeLocalCloudBuild(
|
|||||||
|
|
||||||
const stored = await Promise.all(
|
const stored = await Promise.all(
|
||||||
files.map(async (file) => {
|
files.map(async (file) => {
|
||||||
const sha256 = await sha256Hex(file.bytes)
|
// The client downloads `/room/<filename>` from the bucket, so the stored name is
|
||||||
// The download route looks a bundle up by filename alone, so the stored
|
// this build's, not the uploaded basename two rooms can share.
|
||||||
// name is this build's, not the uploaded basename two rooms can share.
|
const filename = publicStudioBundleFilename(roomId, unityAssetId, file.platform, file.kind)
|
||||||
const filename = publicStudioBundleFilename(unityAssetId, file.platform, file.kind)
|
const hash = await sha256Base64(file.bytes)
|
||||||
const r2Key = `studio-room-bundles/${unityAssetId}/${file.platform}/${file.kind}/${filename}`
|
await bucket.put(studioBundleR2Key(filename), file.bytes)
|
||||||
await bucket.put(r2Key, file.bytes)
|
return { ...file, filename, hash }
|
||||||
return { ...file, filename, sha256, r2Key }
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
const discardObjects = () => Promise.all(stored.map((file) => bucket.delete(file.r2Key)))
|
const discardObjects = () =>
|
||||||
|
Promise.all(stored.map((file) => bucket.delete(studioBundleR2Key(file.filename))))
|
||||||
try {
|
try {
|
||||||
await db.batch([
|
await db.batch([
|
||||||
...stored.map((file) =>
|
...stored.map((file) =>
|
||||||
db
|
unityAssetUpsert(
|
||||||
.prepare(
|
db,
|
||||||
`INSERT INTO studio_unity_asset_file
|
{
|
||||||
(unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key)
|
UnityAssetId: unityAssetId,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`
|
Target: studioAssetTarget(file.platform),
|
||||||
)
|
Version: STUDIO_ASSET_VERSION,
|
||||||
.bind(
|
Filename: file.filename,
|
||||||
unityAssetId,
|
Hash: file.hash,
|
||||||
file.platform,
|
},
|
||||||
file.kind,
|
file.kind
|
||||||
file.filename,
|
|
||||||
file.sha256,
|
|
||||||
file.bytes.byteLength,
|
|
||||||
file.r2Key
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
db
|
db
|
||||||
@@ -190,10 +172,7 @@ export async function storeLocalCloudBuild(
|
|||||||
.prepare('DELETE FROM studio_cloud_build WHERE cloud_build_id = ?1')
|
.prepare('DELETE FROM studio_cloud_build WHERE cloud_build_id = ?1')
|
||||||
.bind(cloudBuildId)
|
.bind(cloudBuildId)
|
||||||
.run()
|
.run()
|
||||||
await db
|
await deleteUnityAssetBuilds(db, unityAssetId)
|
||||||
.prepare('DELETE FROM studio_unity_asset_file WHERE unity_asset_id = ?1')
|
|
||||||
.bind(unityAssetId)
|
|
||||||
.run()
|
|
||||||
await discardObjects()
|
await discardObjects()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||||
import { beforeAll, describe, expect, it } from 'vitest'
|
import { beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { PRESENCE_SCHEMA_DDL, ROOM_SCHEMA_DDL, setPresence, SUBROOM_SCHEMA_DDL } from '@repo/domain'
|
import {
|
||||||
|
PRESENCE_SCHEMA_DDL,
|
||||||
import { STUDIO_CLOUD_BUILD_SCHEMA_DDL } from '../../local-builds'
|
ROOM_SCHEMA_DDL,
|
||||||
|
setPresence,
|
||||||
|
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
|
||||||
|
SUBROOM_SCHEMA_DDL,
|
||||||
|
UNITY_ASSET_SCHEMA_DDL,
|
||||||
|
} from '@repo/domain'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
@@ -90,6 +95,8 @@ async function standIn(
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
|
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
|
||||||
for (const stmt of STUDIO_CLOUD_BUILD_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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 () => {
|
it('response with hello world', async () => {
|
||||||
@@ -357,30 +364,33 @@ describe('local cloud builds', () => {
|
|||||||
completed_at: first.value.startedAt,
|
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(
|
const { results: files } = await env.DB.prepare(
|
||||||
`SELECT platform, kind, filename, byte_length, r2_key
|
`SELECT target, version, kind, filename, hash
|
||||||
FROM studio_unity_asset_file WHERE unity_asset_id = ?1
|
FROM unity_asset WHERE unity_asset_id = ?1
|
||||||
ORDER BY platform, kind`
|
ORDER BY target, kind`
|
||||||
)
|
)
|
||||||
.bind(first.value.unityAssetId)
|
.bind(first.value.unityAssetId)
|
||||||
.all<{
|
.all<{ target: number; version: number; kind: string; filename: string; hash: string }>()
|
||||||
platform: string
|
|
||||||
kind: string
|
|
||||||
filename: string
|
|
||||||
byte_length: number
|
|
||||||
r2_key: string
|
|
||||||
}>()
|
|
||||||
const publicName = (platform: string, kind: 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(
|
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([
|
).toEqual([
|
||||||
['android', 'main', publicName('android', 'main'), 2],
|
[0, 1, 'main', publicName('windows', 'main'), 'n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo='],
|
||||||
['windows', 'main', publicName('windows', 'main'), 4],
|
[
|
||||||
['windows', 'stripped', publicName('windows', 'stripped'), 3],
|
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 win = files.find((file) => file.target === 0 && file.kind === 'main')
|
||||||
const stored = await env.CDN_ASSETS.get(win!.r2_key)
|
// 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(stored).not.toBeNull()
|
||||||
expect([...new Uint8Array(await stored!.arrayBuffer())]).toEqual([1, 2, 3, 4])
|
expect([...new Uint8Array(await stored!.arrayBuffer())]).toEqual([1, 2, 3, 4])
|
||||||
|
|
||||||
@@ -477,16 +487,13 @@ describe('local cloud builds', () => {
|
|||||||
expect(created.success).toBe(true)
|
expect(created.success).toBe(true)
|
||||||
|
|
||||||
const { results: files } = await env.DB.prepare(
|
const { results: files } = await env.DB.prepare(
|
||||||
`SELECT platform, byte_length, r2_key FROM studio_unity_asset_file
|
`SELECT target, filename FROM unity_asset
|
||||||
WHERE unity_asset_id = ?1 AND kind = 'main' ORDER BY platform`
|
WHERE unity_asset_id = ?1 AND kind = 'main' ORDER BY target`
|
||||||
)
|
)
|
||||||
.bind(created.value.unityAssetId)
|
.bind(created.value.unityAssetId)
|
||||||
.all<{ platform: string; byte_length: number; r2_key: string }>()
|
.all<{ target: number; filename: string }>()
|
||||||
expect(files.map((f) => [f.platform, f.byte_length])).toEqual([
|
expect(files.map((f) => f.target)).toEqual([0, 2])
|
||||||
['android', droidBytes.length],
|
const win = await env.CDN_ASSETS.get(`room/${files[0]!.filename}`)
|
||||||
['windows', winBytes.length],
|
|
||||||
])
|
|
||||||
const win = await env.CDN_ASSETS.get(files[1]!.r2_key)
|
|
||||||
expect([...new Uint8Array(await win!.arrayBuffer())]).toEqual(winBytes)
|
expect([...new Uint8Array(await win!.arrayBuffer())]).toEqual(winBytes)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
"compatibility_date": "2026-06-16",
|
"compatibility_date": "2026-06-16",
|
||||||
"compatibility_flags": ["nodejs_compat"],
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
// Shared `recflare` D1. Room co-owners come off the `room` blob and who is in a
|
// 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
|
// subroom comes off `presence` (other workers own those tables), and a stored
|
||||||
// owns the cloud-build tables, migrated from `migrations/`; its own
|
// 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_table keeps that history separate from the other workers'
|
||||||
// migrations on the shared database. The "local" placeholder is replaced with
|
// migrations on the shared database. The "local" placeholder is replaced with
|
||||||
// RECFLARE_D1 at deploy time.
|
// RECFLARE_D1 at deploy time.
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
// Shared `recflare-cdn` R2 bucket (also bound by `rooms` / `cdn` / `storage`).
|
// 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.
|
// bucket_name is a literal, so nothing is spliced at deploy time.
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,13 +21,9 @@ import { bindPlaceholders, chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds'
|
|||||||
import { Accessibility, Role } from './enums'
|
import { Accessibility, Role } from './enums'
|
||||||
import { countPlayersByRoom } from './presence-db'
|
import { countPlayersByRoom } from './presence-db'
|
||||||
import { resyncInstanceCapacity } from './room-instance-db'
|
import { resyncInstanceCapacity } from './room-instance-db'
|
||||||
import {
|
import { bakedUnityAssets, listUnityAssetBuilds } from './unity-assets-db'
|
||||||
bakedStudioUnityAssets,
|
|
||||||
isMissingStudioAssetTable,
|
|
||||||
listStudioUnityAssetFiles,
|
|
||||||
} from './studio-unity-assets'
|
|
||||||
|
|
||||||
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). */
|
/** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
|
||||||
export const ROOM_SCHEMA_DDL: string[] = [
|
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
|
* Fill `UnitySubAssets` on saves that point at a stored Studio build (rows of
|
||||||
* saves have no `UnityAssetId` and are left untouched, including their empty
|
* `unity_asset`). Maker-pen saves have no `UnityAssetId` and are left untouched,
|
||||||
* arrays. A missing studio table is the same answer: the scene blob still loads,
|
* including their empty arrays; so is a save whose asset has no stored build —
|
||||||
* and the bundle list stays empty until the migration exists.
|
* the scene blob still loads, and the bundle list stays empty.
|
||||||
*
|
*
|
||||||
* Only main bundles are listed. Stripped bundles stay in the bucket.
|
* Only main bundles are listed. Stripped bundles stay in the bucket.
|
||||||
*/
|
*/
|
||||||
@@ -2306,23 +2302,18 @@ export async function attachStudioUnityAssets(
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
let files: StudioUnityAssetFile[]
|
const builds = await listUnityAssetBuilds(db, ids)
|
||||||
try {
|
// Rows come back with the id lowercased; a save may spell its GUID either way.
|
||||||
files = await listStudioUnityAssetFiles(db, ids)
|
const byAsset = new Map<string, UnityAssetBuild[]>()
|
||||||
} catch (err) {
|
for (const build of builds) {
|
||||||
if (isMissingStudioAssetTable(err)) return
|
const list = byAsset.get(build.UnityAssetId) ?? []
|
||||||
throw err
|
list.push(build)
|
||||||
}
|
byAsset.set(build.UnityAssetId, list)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
for (const save of saves) {
|
for (const save of saves) {
|
||||||
const id = save.UnityAssetId
|
const id = save.UnityAssetId
|
||||||
if (typeof id !== 'string') continue
|
if (typeof id !== 'string') continue
|
||||||
const baked = bakedStudioUnityAssets(byAsset.get(id) ?? [])
|
const baked = bakedUnityAssets(byAsset.get(id.toLowerCase()) ?? [])
|
||||||
if (baked.length === 0) continue
|
if (baked.length === 0) continue
|
||||||
save.UnitySubAssets = baked
|
save.UnitySubAssets = baked
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* The editor builds Windows and Android asset bundles on the creator's PC and posts them
|
||||||
* apply {@link STUDIO_UNITY_ASSET_SCHEMA_DDL} directly. A room load must keep
|
* to the `studio` worker, which stores the bytes in the shared `recflare-cdn` bucket,
|
||||||
* working before that migration has been applied, so a missing table is
|
* records each bundle as a row of `unity_asset` (see unity-assets-db.ts — the same table
|
||||||
* reported by {@link isMissingStudioAssetTable} and the caller leaves the save
|
* the game's bulk lookup reads) and files the build itself in `studio_cloud_build`, the
|
||||||
* the way a maker-pen room already looks.
|
* 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
|
* A room load must keep working before that migration has been applied, so a missing
|
||||||
* contains the unity asset id, because the download route looks the file up by
|
* table is reported by {@link isMissingStudioAssetTable} and the caller falls back to
|
||||||
* filename alone and two rooms can otherwise both upload `win.assetbundle`.
|
* 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 (
|
`CREATE TABLE IF NOT EXISTS studio_cloud_build (
|
||||||
cloud_build_id TEXT PRIMARY KEY,
|
cloud_build_id TEXT PRIMARY KEY,
|
||||||
room_id INTEGER NOT NULL,
|
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
|
`CREATE INDEX IF NOT EXISTS idx_studio_cloud_build_room
|
||||||
ON studio_cloud_build (room_id, sub_room_id, started_at)`,
|
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
|
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 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
|
* A Studio bundle's `unity_asset.filename` — the name the client downloads, which it
|
||||||
* `CurrentSave`. `Target` uses the numbers the game sends as `unityAssetTarget`
|
* prefixes with `/room/` (see {@link studioBundleR2Key}). Laid out like the official
|
||||||
* on a custom avatar: 0 is Windows, 2 is Android/Quest.
|
* 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,
|
||||||
export interface BakedStudioUnityAsset {
|
* and kind, so two rooms that both uploaded `win.assetbundle` cannot collide.
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
export function publicStudioBundleFilename(
|
export function publicStudioBundleFilename(
|
||||||
|
roomId: number,
|
||||||
unityAssetId: string,
|
unityAssetId: string,
|
||||||
platform: string,
|
platform: StudioBundlePlatform,
|
||||||
kind: string
|
kind: string
|
||||||
): string {
|
): string {
|
||||||
const id = unityAssetId.replace(/-/g, '').toLowerCase()
|
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:
|
* Where the bytes behind a `unity_asset.filename` live in the shared `recflare-cdn`
|
||||||
* those are the only two platforms a local build stores.
|
* 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
|
return platform === 'android' ? 2 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Base64 SHA-256, which is the hash encoding avatar and invention downloads use. */
|
/** True when D1 failed because the studio cloud-build table has not been migrated. */
|
||||||
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. */
|
|
||||||
export function isMissingStudioAssetTable(err: unknown): boolean {
|
export function isMissingStudioAssetTable(err: unknown): boolean {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
return message.toLowerCase().includes('no such table')
|
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 {
|
export interface StudioUnityAssetRecord {
|
||||||
unityAssetId: string
|
unityAssetId: string
|
||||||
/** 0 when the cloud-build row is gone. The route can fall back to the save's author. */
|
/** 0 when the cloud-build row is gone. The route can fall back to the save's author. */
|
||||||
createdByAccountId: number
|
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.
|
* One unity asset and the account that built it. Null when no bundle is stored. A
|
||||||
* A missing cloud-build table leaves `createdByAccountId` at 0; a missing file
|
* missing cloud-build table leaves `createdByAccountId` at 0.
|
||||||
* table throws, and {@link isMissingStudioAssetTable} tells the caller apart.
|
|
||||||
*/
|
*/
|
||||||
export async function getStudioUnityAsset(
|
export async function getStudioUnityAsset(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
unityAssetId: string
|
unityAssetId: string
|
||||||
): Promise<StudioUnityAssetRecord | null> {
|
): Promise<StudioUnityAssetRecord | null> {
|
||||||
const files = await listStudioUnityAssetFiles(db, [unityAssetId])
|
const builds = await listUnityAssetBuilds(db, [unityAssetId])
|
||||||
if (files.length === 0) return null
|
if (builds.length === 0) return null
|
||||||
let createdByAccountId = 0
|
let createdByAccountId = 0
|
||||||
try {
|
try {
|
||||||
const row = await db
|
const row = await db
|
||||||
@@ -209,23 +111,5 @@ export async function getStudioUnityAsset(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isMissingStudioAssetTable(err)) throw err
|
if (!isMissingStudioAssetTable(err)) throw err
|
||||||
}
|
}
|
||||||
return { unityAssetId, createdByAccountId, files }
|
return { unityAssetId, createdByAccountId, builds }
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,42 +3,54 @@
|
|||||||
*
|
*
|
||||||
* `unity_asset` holds one row per BUILD of a unity asset: the asset's GUID, the `Target`
|
* `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
|
* 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
|
* `unityAssetTarget` uses), its `Version`, the `Kind` of bundle (`main`, the scene the
|
||||||
* (`Filename`, a `<date>/<guid>` path, and its base64 SHA-256 `Hash`). The client asks for
|
* client loads; `stripped`, the editor-only variant the Studio upload also stores), and
|
||||||
* these in bulk from `POST /unity_assets/baked/bulk` (the `rooms` worker) while loading a
|
* the storage blob the client downloads (`Filename`, fetched as `/room/<Filename>` straight
|
||||||
* room — the step Player.log times as `AssetBundle_Download` — and downloads `Filename`
|
* from the shared `recflare-cdn` bucket, and its base64 SHA-256 `Hash`).
|
||||||
* from the CDN; a lookup that answers no row, or a row without a `Filename`, simply
|
|
||||||
* downloads nothing, with no error anywhere.
|
|
||||||
*
|
*
|
||||||
* This is a separate store from the Rec Room Studio builds in `studio_unity_asset_file`
|
* Two things read it. The client asks for main bundles in bulk from
|
||||||
* (see studio-unity-assets.ts), which the studio worker writes per platform and kind and
|
* `POST /unity_assets/baked/bulk` (the `rooms` worker) while loading a room — the step
|
||||||
* which `GET /rooms/{id}` folds onto `CurrentSave.UnitySubAssets`. Nothing writes this
|
* Player.log times as `AssetBundle_Download` — and `GET /rooms/{id}` folds the same main
|
||||||
* table yet; it is the record the bulk lookup reads.
|
* 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
|
* The `studio` worker writes it: a Rec Room Studio build posts a Windows + Android bundle
|
||||||
* apps/rooms/migrations/0024_unity_asset.sql.
|
* 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'
|
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[] = [
|
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
|
// A unity asset is built once per target and kind, and may be rebuilt at a new version,
|
||||||
// three together name a row. `unity_asset_id` is stored lowercase: a GUID's case is not
|
// so the four together name a row. `unity_asset_id` is stored lowercase: a GUID's case
|
||||||
// part of its identity and the client is not consistent about it.
|
// is not part of its identity and the client is not consistent about it.
|
||||||
`CREATE TABLE IF NOT EXISTS unity_asset (
|
`CREATE TABLE IF NOT EXISTS unity_asset (
|
||||||
unity_asset_id TEXT NOT NULL,
|
unity_asset_id TEXT NOT NULL,
|
||||||
target INTEGER NOT NULL,
|
target INTEGER NOT NULL,
|
||||||
version INTEGER NOT NULL,
|
version INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'main',
|
||||||
filename TEXT NOT NULL,
|
filename TEXT NOT NULL,
|
||||||
hash 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
|
* 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 {
|
export interface BakedUnityAsset {
|
||||||
/** GUID. */
|
/** GUID. */
|
||||||
@@ -46,16 +58,22 @@ export interface BakedUnityAsset {
|
|||||||
/** 0 Windows, 2 Android/Quest. */
|
/** 0 Windows, 2 Android/Quest. */
|
||||||
Target: number
|
Target: number
|
||||||
Version: 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
|
Filename: string
|
||||||
/** Base64 SHA-256 of the blob. */
|
/** Base64 SHA-256 of the blob. */
|
||||||
Hash: string
|
Hash: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One stored row: a baked bundle and which kind of bundle it is. */
|
||||||
|
export interface UnityAssetBuild extends BakedUnityAsset {
|
||||||
|
Kind: UnityAssetKind
|
||||||
|
}
|
||||||
|
|
||||||
interface UnityAssetRow {
|
interface UnityAssetRow {
|
||||||
unity_asset_id: string
|
unity_asset_id: string
|
||||||
target: number
|
target: number
|
||||||
version: number
|
version: number
|
||||||
|
kind: string
|
||||||
filename: string
|
filename: string
|
||||||
hash: string
|
hash: string
|
||||||
}
|
}
|
||||||
@@ -68,19 +86,54 @@ const toBakedUnityAsset = (row: UnityAssetRow): BakedUnityAsset => ({
|
|||||||
Hash: row.hash,
|
Hash: row.hash,
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Record a build. Replaces the row for the same (asset, target, version). */
|
const toUnityAssetBuild = (row: UnityAssetRow): UnityAssetBuild => ({
|
||||||
export async function putUnityAsset(db: D1Database, asset: BakedUnityAsset): Promise<void> {
|
...toBakedUnityAsset(row),
|
||||||
await db
|
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(
|
.prepare(
|
||||||
`INSERT OR REPLACE INTO unity_asset (unity_asset_id, target, version, filename, hash)
|
`INSERT OR REPLACE INTO unity_asset (unity_asset_id, target, version, kind, filename, hash)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)`
|
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()
|
.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
|
* 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
|
* 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
|
* 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) =>
|
chunkForBinds(ids, 2).map((chunk) =>
|
||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT unity_asset_id, target, version, filename, hash FROM unity_asset
|
`SELECT unity_asset_id, target, version, kind, filename, hash FROM unity_asset
|
||||||
WHERE target = ?1 AND unity_asset_id IN (${bindPlaceholders(chunk, 2)})
|
WHERE target = ?1 AND kind = 'main'
|
||||||
|
AND unity_asset_id IN (${bindPlaceholders(chunk, 2)})
|
||||||
ORDER BY (version = ?2) DESC, version DESC`
|
ORDER BY (version = ?2) DESC, version DESC`
|
||||||
)
|
)
|
||||||
.bind(target, version, ...chunk)
|
.bind(target, version, ...chunk)
|
||||||
@@ -117,3 +171,39 @@ export async function getBakedUnityAssets(
|
|||||||
}
|
}
|
||||||
return ids.flatMap((id) => best.get(id) ?? [])
|
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))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user