/** * 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` 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 ') 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() 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}`)