[studio] prefix assets

This commit is contained in:
Devin Zuczek
2026-10-09 01:37:19 -04:00
parent f9beba0dae
commit ef65b505cc
16 changed files with 384 additions and 410 deletions
+7 -5
View File
@@ -7,7 +7,7 @@ page is WWW `/device`. This worker answers the editor's build list, stores a
locally built Windows and Android bundle pair, and answers the co-owner
presence list. It does not serve the bundle bytes. A stored build is loaded from
Rooms (`CurrentSave.UnitySubAssets`, and `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`)
and from the CDN at `GET /unityasset/{filename}`.
and from the bucket at `/room/{filename}`.
| Method | Path | Purpose |
| ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -37,10 +37,12 @@ pnpm turbo deploy
### Migrate
This worker owns `studio_cloud_build` and `studio_unity_asset_file` on the shared
`recflare` database (`migrations/`, tracked in its own `d1_migrations_studio` table).
Deploying does not apply them; the cdn worker reads `studio_unity_asset_file` and
answers 404 for every Studio bundle until this has run.
This worker owns `studio_cloud_build` on the shared `recflare` database (`migrations/`,
tracked in its own `d1_migrations_studio` table). The bundles themselves are rows of the
rooms worker's `unity_asset` (one per platform and kind: target 0 Windows / 2 Android,
`studio/<roomId>/<name>.assetbundle` as `filename` — the bytes sit at `room/<filename>` in
the bucket, the prefix the client adds — base64 SHA-256 as `hash`), so a stored build needs
the rooms migrations too. Deploying does not apply either.
```sh
just migrate -F studio # remote
@@ -0,0 +1,5 @@
-- A stored bundle is a row of the rooms worker's `unity_asset` now (its migration 0026
-- added the `kind` column this table carried): platform → target, r2_key → filename,
-- sha256 → hash. `studio_cloud_build` stays this worker's. Nothing is carried over.
DROP TABLE IF EXISTS studio_unity_asset_file;
+4 -3
View File
@@ -10,11 +10,12 @@ export type Env = SharedHonoEnv & {
JWT_SECRET: SecretsStoreSecret
// Shared `recflare` D1. Room ownership lives in the `room` blob (the `rooms`
// worker owns that schema) and who is standing where lives in `presence`.
// This worker migrates its own cloud-build tables (`studio_cloud_build`,
// `studio_unity_asset_file`) and only reads the room tables.
// This worker migrates its own cloud-build table (`studio_cloud_build`), writes
// each bundle it stores as a row of the rooms worker's `unity_asset`, and only
// reads the room tables.
DB: D1Database
// Shared `recflare-cdn` bucket. Local Studio builds land under
// `studio-room-bundles/`. The same bucket name the rooms and cdn workers bind.
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
CDN_ASSETS: R2Bucket
}
+39 -60
View File
@@ -1,36 +1,16 @@
import {
deleteUnityAssetBuilds,
getSubRoom,
markRoomAsRecRoomStudio,
publicStudioBundleFilename,
setSubRoomSaveUnityAssetId,
STUDIO_ASSET_VERSION,
studioAssetTarget,
studioBundleR2Key,
unityAssetUpsert,
} from '@repo/domain'
/** Mirrors migrations/0001_studio_cloud_build.sql. Tests apply this; deploy uses the file. */
export const STUDIO_CLOUD_BUILD_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS studio_cloud_build (
cloud_build_id TEXT PRIMARY KEY,
room_id INTEGER NOT NULL,
sub_room_id INTEGER NOT NULL,
sub_room_data_save_id INTEGER NOT NULL,
unity_asset_id TEXT NOT NULL,
created_by_account_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
completed_at TEXT NOT NULL,
error TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_studio_cloud_build_room
ON studio_cloud_build (room_id, sub_room_id, started_at)`,
`CREATE TABLE IF NOT EXISTS studio_unity_asset_file (
unity_asset_id TEXT NOT NULL,
platform TEXT NOT NULL,
kind TEXT NOT NULL,
filename TEXT NOT NULL,
sha256 TEXT NOT NULL,
byte_length INTEGER NOT NULL,
r2_key TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, platform, kind)
)`,
]
import type { StudioBundlePlatform, UnityAssetKind } from '@repo/domain'
export interface CloudBuildRow {
cloudBuildId: string
@@ -41,8 +21,8 @@ export interface CloudBuildRow {
createdByAccountId: number
}
export type BundlePlatform = 'windows' | 'android'
export type BundleKind = 'main' | 'stripped'
export type BundlePlatform = StudioBundlePlatform
export type BundleKind = UnityAssetKind
export interface LocalBundleFile {
platform: BundlePlatform
@@ -103,16 +83,22 @@ export async function listCloudBuilds(
return { results: results.map(toCloudBuild), totalResults: Number(total?.n ?? 0) }
}
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
/** Base64 SHA-256 — the encoding `unity_asset.hash` (and every other bundle hash) uses. */
async function sha256Base64(bytes: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes)
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
let binary = ''
for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte)
return btoa(binary)
}
/**
* Store a PC-built Windows + Android pair as a cloud build that is already finished.
* The bytes go to R2. The build row points at the subroom's current save, that save's
* `UnityAssetId` is set to the new asset, and the room is marked as a Studio room the
* first time (`BecameRRStudioRoomAt`).
* The bytes go to R2 under `room/` and each bundle becomes a `unity_asset` row — target
* 0 Windows / 2 Android, its kind, the `filename` the client prefixes with `/room/` to
* download it (so the R2 key is `room/<filename>`), the base64 SHA-256 as `hash`. The
* build row points at the subroom's current save, that save's `UnityAssetId` is set
* to the new asset, and the room is marked as a Studio room the first time
* (`BecameRRStudioRoomAt`).
*
* Returns null when the subroom has no current save — the caller uploaded bundles
* before Upload created one.
@@ -140,35 +126,31 @@ export async function storeLocalCloudBuild(
const stored = await Promise.all(
files.map(async (file) => {
const sha256 = await sha256Hex(file.bytes)
// The download route looks a bundle up by filename alone, so the stored
// name is this build's, not the uploaded basename two rooms can share.
const filename = publicStudioBundleFilename(unityAssetId, file.platform, file.kind)
const r2Key = `studio-room-bundles/${unityAssetId}/${file.platform}/${file.kind}/${filename}`
await bucket.put(r2Key, file.bytes)
return { ...file, filename, sha256, r2Key }
// The client downloads `/room/<filename>` from the bucket, so the stored name is
// this build's, not the uploaded basename two rooms can share.
const filename = publicStudioBundleFilename(roomId, unityAssetId, file.platform, file.kind)
const hash = await sha256Base64(file.bytes)
await bucket.put(studioBundleR2Key(filename), file.bytes)
return { ...file, filename, hash }
})
)
const discardObjects = () => Promise.all(stored.map((file) => bucket.delete(file.r2Key)))
const discardObjects = () =>
Promise.all(stored.map((file) => bucket.delete(studioBundleR2Key(file.filename))))
try {
await db.batch([
...stored.map((file) =>
db
.prepare(
`INSERT INTO studio_unity_asset_file
(unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`
)
.bind(
unityAssetId,
file.platform,
file.kind,
file.filename,
file.sha256,
file.bytes.byteLength,
file.r2Key
)
unityAssetUpsert(
db,
{
UnityAssetId: unityAssetId,
Target: studioAssetTarget(file.platform),
Version: STUDIO_ASSET_VERSION,
Filename: file.filename,
Hash: file.hash,
},
file.kind
)
),
db
.prepare(
@@ -190,10 +172,7 @@ export async function storeLocalCloudBuild(
.prepare('DELETE FROM studio_cloud_build WHERE cloud_build_id = ?1')
.bind(cloudBuildId)
.run()
await db
.prepare('DELETE FROM studio_unity_asset_file WHERE unity_asset_id = ?1')
.bind(unityAssetId)
.run()
await deleteUnityAssetBuilds(db, unityAssetId)
await discardObjects()
return null
}
+35 -28
View File
@@ -1,9 +1,14 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, describe, expect, it } from 'vitest'
import { PRESENCE_SCHEMA_DDL, ROOM_SCHEMA_DDL, setPresence, SUBROOM_SCHEMA_DDL } from '@repo/domain'
import { STUDIO_CLOUD_BUILD_SCHEMA_DDL } from '../../local-builds'
import {
PRESENCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
setPresence,
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
SUBROOM_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import type { Env } from '../../context'
@@ -90,6 +95,8 @@ async function standIn(
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
for (const stmt of STUDIO_CLOUD_BUILD_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// `unity_asset` is the rooms worker's table; a build writes one row per bundle into it.
for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
it('response with hello world', async () => {
@@ -357,30 +364,33 @@ describe('local cloud builds', () => {
completed_at: first.value.startedAt,
})
// Each bundle is a `unity_asset` row: target 0 Windows / 2 Android, its kind, the R2
// key as the filename, and the base64 SHA-256 of the bytes as the hash.
const { results: files } = await env.DB.prepare(
`SELECT platform, kind, filename, byte_length, r2_key
FROM studio_unity_asset_file WHERE unity_asset_id = ?1
ORDER BY platform, kind`
`SELECT target, version, kind, filename, hash
FROM unity_asset WHERE unity_asset_id = ?1
ORDER BY target, kind`
)
.bind(first.value.unityAssetId)
.all<{
platform: string
kind: string
filename: string
byte_length: number
r2_key: string
}>()
.all<{ target: number; version: number; kind: string; filename: string; hash: string }>()
const publicName = (platform: string, kind: string) =>
`${first.value.unityAssetId.replace(/-/g, '')}.${platform}.${kind}.assetbundle`
`studio/${LOCAL_ROOM}/${first.value.unityAssetId.replace(/-/g, '')}.${platform}.${kind}.assetbundle`
expect(
files.map((file) => [file.platform, file.kind, file.filename, file.byte_length])
files.map((file) => [file.target, file.version, file.kind, file.filename, file.hash])
).toEqual([
['android', 'main', publicName('android', 'main'), 2],
['windows', 'main', publicName('windows', 'main'), 4],
['windows', 'stripped', publicName('windows', 'stripped'), 3],
[0, 1, 'main', publicName('windows', 'main'), 'n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo='],
[
0,
1,
'stripped',
publicName('windows', 'stripped'),
'ZqZ1cVH47lXbEncWx+Pc4L6AdLZOIO2lQuXB5GypxB4=',
],
[2, 1, 'main', publicName('android', 'main'), 'xCUiEotJGT3ozUXY91ic1+CF5l8ThkDVfUSC5fcYliM='],
])
const win = files.find((file) => file.platform === 'windows' && file.kind === 'main')
const stored = await env.CDN_ASSETS.get(win!.r2_key)
const win = files.find((file) => file.target === 0 && file.kind === 'main')
// The bytes sit under `room/` — the prefix the client adds to the filename.
const stored = await env.CDN_ASSETS.get(`room/${win!.filename}`)
expect(stored).not.toBeNull()
expect([...new Uint8Array(await stored!.arrayBuffer())]).toEqual([1, 2, 3, 4])
@@ -477,16 +487,13 @@ describe('local cloud builds', () => {
expect(created.success).toBe(true)
const { results: files } = await env.DB.prepare(
`SELECT platform, byte_length, r2_key FROM studio_unity_asset_file
WHERE unity_asset_id = ?1 AND kind = 'main' ORDER BY platform`
`SELECT target, filename FROM unity_asset
WHERE unity_asset_id = ?1 AND kind = 'main' ORDER BY target`
)
.bind(created.value.unityAssetId)
.all<{ platform: string; byte_length: number; r2_key: string }>()
expect(files.map((f) => [f.platform, f.byte_length])).toEqual([
['android', droidBytes.length],
['windows', winBytes.length],
])
const win = await env.CDN_ASSETS.get(files[1]!.r2_key)
.all<{ target: number; filename: string }>()
expect(files.map((f) => f.target)).toEqual([0, 2])
const win = await env.CDN_ASSETS.get(`room/${files[0]!.filename}`)
expect([...new Uint8Array(await win!.arrayBuffer())]).toEqual(winBytes)
})
})
+4 -3
View File
@@ -5,8 +5,9 @@
"compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"],
// Shared `recflare` D1. Room co-owners come off the `room` blob and who is in a
// subroom comes off `presence` (other workers own those tables). This worker
// owns the cloud-build tables, migrated from `migrations/`; its own
// subroom comes off `presence` (other workers own those tables), and a stored
// bundle is a row of the rooms worker's `unity_asset`. This worker owns the
// cloud-build table, migrated from `migrations/`; its own
// migrations_table keeps that history separate from the other workers'
// migrations on the shared database. The "local" placeholder is replaced with
// RECFLARE_D1 at deploy time.
@@ -20,7 +21,7 @@
}
],
// Shared `recflare-cdn` R2 bucket (also bound by `rooms` / `cdn` / `storage`).
// Local Studio asset bundles live here under `studio-room-bundles/`.
// Local Studio asset bundles live here under `room/studio/<roomId>/`.
// bucket_name is a literal, so nothing is spliced at deploy time.
"r2_buckets": [
{