diff --git a/.releases/released/v0.10.1/20260222232200-auto-sync-static-catalog-on-startup.md b/.releases/released/v0.10.1/20260222232200-auto-sync-static-catalog-on-startup.md new file mode 100644 index 00000000..d614d151 --- /dev/null +++ b/.releases/released/v0.10.1/20260222232200-auto-sync-static-catalog-on-startup.md @@ -0,0 +1,9 @@ +--- +type: patch +area: db +summary: Add idempotent static catalog sync and run it on container startup +--- + +- Add `syncStaticCatalog` to upsert items, recipes, recipe inputs, research nodes, unlock links, and prerequisites without resetting world state. +- Ensure missing `CompanyRecipe` links are created for existing companies so newly added recipes become available. +- Add `pnpm sim:sync-static` and wire startup to run catalog sync automatically in `APP_ROLE=all` (or when `CORPSIM_SYNC_STATIC_DATA_ON_START=true`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 05a60b16..b8e23509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -301,3 +301,9 @@ All notable changes to CorpSim are documented in this file. - [db] Run Prisma generate without relying on dotenv-cli shell binary - [web] Fix buildings definitions API response parsing in web client - [web] Accept legacy object-shaped buildings definitions payloads + +## 0.10.1 - 2026-02-22 + +### What's Changed + +- [db] Add idempotent static catalog sync and run it on container startup diff --git a/package.json b/package.json index 92a529ae..ef267bd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "corpsim", - "version": "0.10.0", + "version": "0.10.1", "private": true, "workspaces": [ "apps/*", @@ -31,6 +31,7 @@ "sim:advance": "tsx scripts/sim-advance.ts", "sim:reset": "tsx scripts/sim-reset.ts", "sim:seed": "pnpm -C packages/db seed", + "sim:sync-static": "tsx scripts/sim-sync-static.ts", "sim:stats": "tsx scripts/sim-stats.ts", "release:entry": "node scripts/release-entry.mjs", "release:check": "node scripts/check-release-entry.mjs", diff --git a/packages/db/src/seed-world.ts b/packages/db/src/seed-world.ts index d7746f74..b00b070f 100644 --- a/packages/db/src/seed-world.ts +++ b/packages/db/src/seed-world.ts @@ -577,6 +577,245 @@ function isRecipeAutoUnlocked(recipeCode: string): boolean { ); } +function chunkArray(rows: T[], chunkSize: number): T[][] { + if (rows.length === 0) { + return []; + } + + const chunks: T[][] = []; + for (let index = 0; index < rows.length; index += chunkSize) { + chunks.push(rows.slice(index, index + chunkSize)); + } + return chunks; +} + +export interface SyncStaticCatalogResult { + itemsSynced: number; + recipesSynced: number; + researchNodesSynced: number; + prerequisitesSynced: number; + companyRecipeLinksCreated: number; +} + +export async function syncStaticCatalog(prisma: PrismaClient): Promise { + return prisma.$transaction(async (tx) => { + const itemsByKey: Record = {}; + + for (const definition of ITEM_DEFINITIONS) { + const item = await tx.item.upsert({ + where: { code: definition.code }, + update: { name: definition.name }, + create: { + code: definition.code, + name: definition.name + } + }); + itemsByKey[definition.key] = item; + } + + const recipesByKey: Record = {}; + for (const definition of RECIPE_DEFINITIONS) { + const outputItem = itemsByKey[definition.outputItemKey]; + if (!outputItem) { + throw new Error(`seed recipe ${definition.code} references unknown output item key ${definition.outputItemKey}`); + } + + const recipe = await tx.recipe.upsert({ + where: { code: definition.code }, + update: { + name: definition.name, + durationTicks: definition.durationTicks, + outputItemId: outputItem.id, + outputQuantity: definition.outputQuantity + }, + create: { + code: definition.code, + name: definition.name, + durationTicks: definition.durationTicks, + outputItemId: outputItem.id, + outputQuantity: definition.outputQuantity + } + }); + + recipesByKey[definition.key] = recipe; + + const expectedInputItemIds: string[] = []; + for (const input of definition.inputs) { + const inputItem = itemsByKey[input.itemKey]; + if (!inputItem) { + throw new Error(`seed recipe ${definition.code} references unknown input item key ${input.itemKey}`); + } + + expectedInputItemIds.push(inputItem.id); + + await tx.recipeInput.upsert({ + where: { + recipeId_itemId: { + recipeId: recipe.id, + itemId: inputItem.id + } + }, + update: { + quantity: input.quantity + }, + create: { + recipeId: recipe.id, + itemId: inputItem.id, + quantity: input.quantity + } + }); + } + + if (expectedInputItemIds.length === 0) { + await tx.recipeInput.deleteMany({ + where: { + recipeId: recipe.id + } + }); + } else { + await tx.recipeInput.deleteMany({ + where: { + recipeId: recipe.id, + itemId: { + notIn: expectedInputItemIds + } + } + }); + } + } + + const researchNodesByKey: Record = {}; + for (const definition of RESEARCH_DEFINITIONS) { + const node = await tx.researchNode.upsert({ + where: { code: definition.code }, + update: { + name: definition.name, + description: definition.description, + costCashCents: definition.costCashCents, + durationTicks: definition.durationTicks + }, + create: { + code: definition.code, + name: definition.name, + description: definition.description, + costCashCents: definition.costCashCents, + durationTicks: definition.durationTicks + } + }); + + researchNodesByKey[definition.key] = node; + + const expectedUnlockRecipeIds: string[] = []; + for (const recipeKey of definition.unlockRecipeKeys) { + const recipe = recipesByKey[recipeKey]; + if (!recipe) { + throw new Error(`research node ${definition.code} references unknown recipe key ${recipeKey}`); + } + expectedUnlockRecipeIds.push(recipe.id); + await tx.researchNodeUnlockRecipe.upsert({ + where: { + nodeId_recipeId: { + nodeId: node.id, + recipeId: recipe.id + } + }, + update: {}, + create: { + nodeId: node.id, + recipeId: recipe.id + } + }); + } + + if (expectedUnlockRecipeIds.length === 0) { + await tx.researchNodeUnlockRecipe.deleteMany({ + where: { + nodeId: node.id + } + }); + } else { + await tx.researchNodeUnlockRecipe.deleteMany({ + where: { + nodeId: node.id, + recipeId: { + notIn: expectedUnlockRecipeIds + } + } + }); + } + } + + await tx.researchPrerequisite.deleteMany(); + await tx.researchPrerequisite.createMany({ + data: RESEARCH_PREREQUISITES.map((entry) => { + const node = researchNodesByKey[entry.nodeKey]; + const prerequisiteNode = researchNodesByKey[entry.prerequisiteKey]; + if (!node) { + throw new Error(`research prerequisite references unknown node key ${entry.nodeKey}`); + } + if (!prerequisiteNode) { + throw new Error(`research prerequisite references unknown prerequisite key ${entry.prerequisiteKey}`); + } + return { + nodeId: node.id, + prerequisiteNodeId: prerequisiteNode.id + }; + }) + }); + + const allRecipes = Object.values(recipesByKey); + const autoUnlockedRecipeIdSet = new Set( + allRecipes.filter((recipe) => isRecipeAutoUnlocked(recipe.code)).map((recipe) => recipe.id) + ); + + let companyRecipeLinksCreated = 0; + if (allRecipes.length > 0) { + const companies = await tx.company.findMany({ + select: { id: true } + }); + + for (const company of companies) { + const rows = allRecipes.map((recipe) => ({ + companyId: company.id, + recipeId: recipe.id, + isUnlocked: autoUnlockedRecipeIdSet.has(recipe.id) + })); + + for (const batch of chunkArray(rows, 1000)) { + const created = await tx.companyRecipe.createMany({ + data: batch, + skipDuplicates: true + }); + companyRecipeLinksCreated += created.count; + } + } + + const autoUnlockedRecipeIds = Array.from(autoUnlockedRecipeIdSet); + if (autoUnlockedRecipeIds.length > 0) { + await tx.companyRecipe.updateMany({ + where: { + recipeId: { + in: autoUnlockedRecipeIds + }, + isUnlocked: false + }, + data: { + isUnlocked: true + } + }); + } + } + + return { + itemsSynced: ITEM_DEFINITIONS.length, + recipesSynced: RECIPE_DEFINITIONS.length, + researchNodesSynced: RESEARCH_DEFINITIONS.length, + prerequisitesSynced: RESEARCH_PREREQUISITES.length, + companyRecipeLinksCreated + }; + }); +} + export async function seedWorld( prisma: PrismaClient, options: SeedWorldOptions = {} diff --git a/scripts/sim-sync-static.ts b/scripts/sim-sync-static.ts new file mode 100644 index 00000000..7949ec4b --- /dev/null +++ b/scripts/sim-sync-static.ts @@ -0,0 +1,26 @@ +import { createPrismaClient, syncStaticCatalog } from "@corpsim/db"; + +async function main(): Promise { + const prisma = createPrismaClient(); + + try { + const result = await syncStaticCatalog(prisma); + console.log( + [ + "Static catalog sync complete.", + `Items: ${result.itemsSynced}`, + `Recipes: ${result.recipesSynced}`, + `Research nodes: ${result.researchNodesSynced}`, + `Prerequisites: ${result.prerequisitesSynced}`, + `Company recipe links created: ${result.companyRecipeLinksCreated}` + ].join(" ") + ); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((error: unknown) => { + console.error("Static catalog sync failed", error); + process.exitCode = 1; +}); diff --git a/scripts/start-container.sh b/scripts/start-container.sh index 05b58288..599fb48e 100644 --- a/scripts/start-container.sh +++ b/scripts/start-container.sh @@ -36,9 +36,37 @@ apply_migrations() { pnpm exec prisma migrate deploy --schema packages/db/prisma/schema.prisma } +is_truthy() { + local value="${1:-}" + local normalized + normalized="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')" + [[ "$normalized" == "1" || "$normalized" == "true" || "$normalized" == "yes" || "$normalized" == "on" ]] +} + +should_sync_static_catalog() { + local configured="${CORPSIM_SYNC_STATIC_DATA_ON_START:-}" + if [[ -n "$configured" ]]; then + is_truthy "$configured" + return $? + fi + + if [[ "$role" == "all" ]]; then + return 0 + fi + + return 1 +} + +sync_static_catalog() { + if should_sync_static_catalog; then + pnpm sim:sync-static + fi +} + run_all() { # Ensure schema is current before starting long-running processes in single-container mode. apply_migrations + sync_static_catalog pnpm --filter @corpsim/api start & api_pid=$! @@ -59,6 +87,7 @@ run_all() { case "$role" in api) + sync_static_catalog run_api ;; web)