[api] migrate custom avatar items for incoming RRO items

This commit is contained in:
Devin Zuczek
2026-09-15 20:39:24 -04:00
parent 823dd57c0c
commit 741604be24
7 changed files with 535 additions and 132 deletions
@@ -0,0 +1,69 @@
/**
* Turns an export of first-party custom avatar items into a D1 migration that seeds the
* `custom_avatar_item` table with them, one `INSERT` per record, the way
* `apps/rooms/migrations/0002_import_rooms.sql` seeds the rooms.
*
* bun apps/api/scripts/import-custom-avatar-items.ts <export.json> <NNNN>
*
* `export.json` is a JSON array of `CustomAvatarItem` records as the official service serves
* them (a `{ Results: [...] }` page or a single record are accepted too). `NNNN` is the
* migration number; the file is written to `apps/api/migrations/NNNN_import_custom_avatar_items.sql`.
*
* Each record is stored VERBATIM as the row's JSON (the table is a JSON blob per row, see
* src/custom-avatar-items-db.ts) minus `PurchaseInfo`, which is a store-side projection and
* never stored. A record whose id is already present is REPLACED, so re-running an import
* with corrected data lands; a `DELETE` is not issued, so player-made items and previously
* imported items the new export lacks are left alone.
*
* The saves name assetbundles and thumbnails by bare filename; the migration does nothing
* about those files, which have to be served from wherever the client fetches them.
*/
import { readFileSync, writeFileSync } from 'node:fs'
interface Record_ {
CustomAvatarItemId: string
PurchaseInfo?: unknown
[key: string]: unknown
}
const [input, number] = process.argv.slice(2)
if (!input || !number || !/^\d{4}$/.test(number)) {
console.error('usage: bun apps/api/scripts/import-custom-avatar-items.ts <export.json> <NNNN>')
process.exit(2)
}
const parsed: unknown = JSON.parse(readFileSync(input, 'utf8'))
const records: Record_[] = Array.isArray(parsed)
? parsed
: parsed && typeof parsed === 'object' && Array.isArray((parsed as { Results?: unknown }).Results)
? (parsed as { Results: Record_[] }).Results
: [parsed as Record_]
const seen = new Set<string>()
const lines: string[] = [
`-- Seed first-party custom avatar items (${records.length} records) from ${input.split('/').pop()}.`,
'-- Generated by apps/api/scripts/import-custom-avatar-items.ts — do not edit by hand.',
'-- Each record is the official `CustomAvatarItem` verbatim; an existing id is replaced.',
'',
]
for (const record of records) {
if (typeof record.CustomAvatarItemId !== 'string' || record.CustomAvatarItemId === '') {
throw new Error(`record without a CustomAvatarItemId: ${JSON.stringify(record).slice(0, 80)}`)
}
if (seen.has(record.CustomAvatarItemId)) {
throw new Error(`duplicate CustomAvatarItemId in export: ${record.CustomAvatarItemId}`)
}
seen.add(record.CustomAvatarItemId)
const { PurchaseInfo: _purchaseInfo, ...stored } = record
const json = JSON.stringify(stored).replaceAll("'", "''")
lines.push(
`INSERT INTO custom_avatar_item (data) VALUES ('${json}') ON CONFLICT(custom_avatar_item_id) DO UPDATE SET data = excluded.data;`
)
}
const out = decodeURIComponent(
new URL(`../migrations/${number}_import_custom_avatar_items.sql`, import.meta.url).pathname
)
writeFileSync(out, lines.join('\n') + '\n')
console.log(`wrote ${records.length} records to ${out}`)