diff --git a/docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-data-preservation-notes.md b/docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-data-preservation-notes.md new file mode 100644 index 000000000..537fc7a85 --- /dev/null +++ b/docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-data-preservation-notes.md @@ -0,0 +1,19 @@ +# PR_26171_ALPHA_023 Migration And Data Preservation Notes + +TEAM ownership: ALPHA. + +Migration behavior: +- Active Game Journey completion metrics persistence now uses Postgres through `GAMEFOUNDRY_DATABASE_URL`. +- The legacy SQLite file path is treated as a data-preservation guard only. +- Existing legacy SQLite metrics data is preserved in place and is not deleted or overwritten. +- If the legacy file exists, the metrics store stops with an actionable diagnostic instead of silently replacing data with Postgres seed rows. +- No SQLite fallback is used after the Postgres path is active. + +Operator action when legacy data exists: +- Export or migrate legacy metrics into Postgres. +- Verify the Postgres `game_journey_completion_metrics` table contains the expected rows. +- Move or archive the legacy SQLite file after verification. +- Restart the Local API. + +Known local state: +- This workspace had an ignored legacy metrics file under `tmp/local-api/`; tests used injected Postgres stubs and disabled the legacy-path guard only for isolated validation. diff --git a/docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-instruction-compliance-checklist.md b/docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-instruction-compliance-checklist.md new file mode 100644 index 000000000..3a1c3609e --- /dev/null +++ b/docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-instruction-compliance-checklist.md @@ -0,0 +1,16 @@ +# PR_26171_ALPHA_023 Instruction Compliance Checklist + +TEAM ownership: ALPHA. + +- PASS: Read `docs_build/dev/PROJECT_INSTRUCTIONS.md`. +- PASS: Read `docs_build/dev/PROJECT_MULTI_PC.txt`. +- PASS: Verified Game Journey is Team Alpha owned. +- PASS: Started from synced `main` before creating `team/ALPHA/game-journey`. +- PASS: Scope stayed within Game Journey metrics persistence, Local API async pass-through, affected tests, and required reports. +- PASS: Removed active Game Journey `node:sqlite` / `DatabaseSync` persistence. +- PASS: Preserved data by blocking silent legacy SQLite replacement. +- PASS: Used targeted validation only. +- PASS: Did not run samples. +- PASS: Required shared reports are generated under `docs_build/dev/reports/`. +- PASS: Manual validation notes are present. +- PASS: Repo-structured ZIP is required under `tmp/`. diff --git a/docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-manual-validation-notes.md b/docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-manual-validation-notes.md new file mode 100644 index 000000000..85901ad75 --- /dev/null +++ b/docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-manual-validation-notes.md @@ -0,0 +1,20 @@ +# PR_26171_ALPHA_023 Manual Validation Notes + +TEAM ownership: ALPHA. + +Manual validation performed: +- Confirmed Game Journey completion metrics dashboard renders through the Local API with a Postgres client stub. +- Confirmed `/api/game-journey/completion-metrics` returns `databaseEngine: "Postgres"` and the existing 14 completion metric records. +- Confirmed updating `001-idea` persists through the Postgres stub and returns `updatedMetric`. +- Confirmed missing Postgres configuration reports `GAMEFOUNDRY_DATABASE_URL`. +- Confirmed legacy SQLite data guard blocks startup without deleting the legacy file. + +Expected outcome: +- Game Journey completion metrics preserve user-visible behavior and response shapes. +- Active metrics persistence no longer depends on `node:sqlite` or `DatabaseSync`. +- Legacy SQLite data cannot be silently dropped during cutover. + +Out of scope: +- Full samples smoke. +- Broad Game Journey editor/detail regression tests. +- Live Postgres connectivity against an operator database. diff --git a/docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration.md b/docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration.md new file mode 100644 index 000000000..f5f970627 --- /dev/null +++ b/docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration.md @@ -0,0 +1,45 @@ +# PR_26171_ALPHA_023-game-journey-postgres-metrics-migration + +## Summary + +TEAM ownership: ALPHA. + +Branch: `team/ALPHA/game-journey`. + +Scope completed: +- Migrated Game Journey completion metrics persistence from active SQLite usage to Postgres. +- Replaced `node:sqlite` / `DatabaseSync` usage in `src/dev-runtime/persistence/game-journey-completion-metrics-store.mjs`. +- Reused the existing `createPostgresConnectionClient` dev-runtime pattern backed by `GAMEFOUNDRY_DATABASE_URL`. +- Preserved the existing Game Journey completion metrics API response shape, including summary counts, `records`, `updatedMetric`, and compatibility metadata fields. +- Updated Game Journey repository and Local API routes to await the Postgres-backed metrics store. +- Updated affected Game Journey Playwright tests to use an injected Postgres client stub. + +## Validation + +Passed: +- `git diff --check` +- `node --check src/dev-runtime/persistence/game-journey-completion-metrics-store.mjs` +- `node --check src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js` +- `node --check src/dev-runtime/server/local-api-router.mjs` +- `node --check tests/playwright/tools/GameJourneyTool.spec.mjs` +- `node --check tests/helpers/playwrightRepoServer.mjs` +- `node --check tests/helpers/gameJourneyCompletionMetricsPostgresClientStub.mjs` +- `npx playwright test tests/playwright/tools/GameJourneyTool.spec.mjs --config=codex_playwright_system_chrome.config.cjs --project=playwright -g "Game Journey progress dashboard|Game Journey mock data keeps system guidance template-owned|Game Journey Local API persists completion metrics to Postgres|Game Journey completion metrics fail visibly|Game Journey completion metrics protect legacy SQLite"` + +Targeted checks: +- Verified changed Game Journey metrics paths no longer import `node:sqlite` or `DatabaseSync`. +- Verified missing Postgres configuration fails with an actionable `GAMEFOUNDRY_DATABASE_URL` diagnostic. +- Verified legacy SQLite metrics files are not deleted or silently ignored. +- Verified no secret values are emitted by the metrics store. + +Skipped: +- Full samples smoke: out of scope for this Game Journey metrics persistence migration. +- Broad Game Journey editor/detail Playwright cases: out of scope for completion metrics persistence. A broader exploratory run surfaced editor/detail assertions outside the metrics path, so the completion gate used the affected metrics/API lane only. + +## Data Preservation + +The Postgres metrics store does not delete, overwrite, or fall back to legacy SQLite data. + +If a legacy Game Journey completion metrics SQLite file exists at the configured legacy path, the store fails visibly before seeding Postgres and reports that the operator must export or migrate the legacy data before moving the file. This prevents silent data loss while removing SQLite as the active persistence path. + +Tests inject `gameJourneyCompletionMetricsLegacyDbPath: null` with a Postgres client stub so the active Postgres path can be validated without touching ignored local runtime files. diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt index c5f96be84..9f07c7577 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt @@ -1,36 +1,30 @@ ## git status --short -M docs_build/database/dml/DML_INDEX.md -M docs_build/database/dml/messages.sql -M docs_build/database/seed/messages.json -A docs_build/dev/reports/PR_26171_BETA_022-messages-postgres-service-cutover-instruction-compliance-checklist.md -A docs_build/dev/reports/PR_26171_BETA_022-messages-postgres-service-cutover-manual-validation-notes.md -A docs_build/dev/reports/PR_26171_BETA_022-messages-postgres-service-cutover.md +A docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-data-preservation-notes.md +A docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-instruction-compliance-checklist.md +A docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-manual-validation-notes.md +A docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration.md M docs_build/dev/reports/coverage_changed_js_guardrail.txt M docs_build/dev/reports/playwright_v8_coverage_report.txt -A src/dev-runtime/messages/messages-postgres-service.mjs -D src/dev-runtime/messages/messages-sqlite-service.mjs +M src/dev-runtime/persistence/game-journey-completion-metrics-store.mjs +M src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js M src/dev-runtime/server/local-api-router.mjs -M tests/dev-runtime/DbSeedIntegrity.test.mjs -A tests/helpers/messagesPostgresClientStub.mjs +A tests/helpers/gameJourneyCompletionMetricsPostgresClientStub.mjs M tests/helpers/playwrightRepoServer.mjs -M tests/playwright/tools/MessagesTool.spec.mjs +M tests/playwright/tools/GameJourneyTool.spec.mjs ## git diff --cached --stat - docs_build/database/dml/DML_INDEX.md | 2 +- - docs_build/database/dml/messages.sql | 2 +- - docs_build/database/seed/messages.json | 2 +- - ...ice-cutover-instruction-compliance-checklist.md | 16 + - ...gres-service-cutover-manual-validation-notes.md | 21 + - ...1_BETA_022-messages-postgres-service-cutover.md | 39 + - .../dev/reports/coverage_changed_js_guardrail.txt | 32 +- - .../dev/reports/playwright_v8_coverage_report.txt | 91 +- - .../messages/messages-postgres-service.mjs | 1117 +++++++++++++++++++ - .../messages/messages-sqlite-service.mjs | 1118 -------------------- - src/dev-runtime/server/local-api-router.mjs | 16 +- - tests/dev-runtime/DbSeedIntegrity.test.mjs | 61 +- - tests/helpers/messagesPostgresClientStub.mjs | 83 ++ - tests/helpers/playwrightRepoServer.mjs | 6 +- - tests/playwright/tools/MessagesTool.spec.mjs | 39 +- - 15 files changed, 1377 insertions(+), 1268 deletions(-) + ...es-metrics-migration-data-preservation-notes.md | 19 ++ + ...s-migration-instruction-compliance-checklist.md | 16 + + ...es-metrics-migration-manual-validation-notes.md | 20 ++ + ..._023-game-journey-postgres-metrics-migration.md | 45 +++ + .../dev/reports/coverage_changed_js_guardrail.txt | 30 +- + .../dev/reports/playwright_v8_coverage_report.txt | 85 +---- + .../game-journey-completion-metrics-store.mjs | 369 ++++++++++++--------- + .../game-journey-mock-repository.js | 6 +- + src/dev-runtime/server/local-api-router.mjs | 41 ++- + ...eJourneyCompletionMetricsPostgresClientStub.mjs | 87 +++++ + tests/helpers/playwrightRepoServer.mjs | 11 +- + tests/playwright/tools/GameJourneyTool.spec.mjs | 108 ++++-- + 12 files changed, 518 insertions(+), 319 deletions(-) ## git diff --stat diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff index 613dbd9ff..07d6bb81f 100644 Binary files a/docs_build/dev/reports/codex_review.diff and b/docs_build/dev/reports/codex_review.diff differ diff --git a/docs_build/dev/reports/coverage_changed_js_guardrail.txt b/docs_build/dev/reports/coverage_changed_js_guardrail.txt index 604c78f93..aaedf0c8c 100644 --- a/docs_build/dev/reports/coverage_changed_js_guardrail.txt +++ b/docs_build/dev/reports/coverage_changed_js_guardrail.txt @@ -6,9 +6,11 @@ Missing changed runtime JS files are WARN, not FAIL. Source: Playwright/Chromium built-in V8 coverage from the active Playwright run. Changed runtime JS files considered: -(0%) src/dev-runtime/messages/messages-postgres-service.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) src/dev-runtime/persistence/game-journey-completion-metrics-store.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only (0%) src/dev-runtime/server/local-api-router.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only Guardrail warnings: -(0%) src/dev-runtime/messages/messages-postgres-service.mjs - WARNING: changed runtime JS file missing from coverage; advisory only +(0%) src/dev-runtime/persistence/game-journey-completion-metrics-store.mjs - WARNING: changed runtime JS file missing from coverage; advisory only +(0%) src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js - WARNING: changed runtime JS file missing from coverage; advisory only (0%) src/dev-runtime/server/local-api-router.mjs - WARNING: changed runtime JS file missing from coverage; advisory only diff --git a/docs_build/dev/reports/playwright_v8_coverage_report.txt b/docs_build/dev/reports/playwright_v8_coverage_report.txt index 38245048a..750d7c7da 100644 --- a/docs_build/dev/reports/playwright_v8_coverage_report.txt +++ b/docs_build/dev/reports/playwright_v8_coverage_report.txt @@ -12,34 +12,34 @@ Note: entry percentages use function coverage when available, otherwise line cov Note: coverage entries are aggregated across every page/tool where coverageReporter.start(page) and coverageReporter.stop(page) ran. Exercised tool entry points detected: -(87%) Toolbox Index - exercised 4 runtime JS files +(74%) Toolbox Index - exercised 3 runtime JS files (0%) Tool Template V2 - not exercised by this Playwright run -(56%) Theme V2 Shared JS - exercised 2 runtime JS files +(63%) Theme V2 Shared JS - exercised 2 runtime JS files Changed runtime JS files covered: -(0%) src/dev-runtime/messages/messages-postgres-service.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) src/dev-runtime/persistence/game-journey-completion-metrics-store.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(0%) src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only (0%) src/dev-runtime/server/local-api-router.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only Files with executed line/function counts where available: -(36%) src/api/server-api-client.js - executed lines 167/167; executed functions 5/14 -(36%) src/engine/audio/TextToSpeechEngine.js - executed lines 412/412; executed functions 16/44 -(38%) src/api/public-config-client.js - executed lines 209/209; executed functions 10/26 -(54%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 46/85 -(58%) toolbox/messages/messages-api-client.js - executed lines 64/64; executed functions 11/19 +(53%) src/api/server-api-client.js - executed lines 167/167; executed functions 10/19 +(63%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 54/86 (64%) assets/theme-v2/js/tool-display-mode.js - executed lines 204/204; executed functions 9/14 +(65%) src/api/public-config-client.js - executed lines 209/209; executed functions 17/26 +(67%) src/api/game-journey-completion-api-client.js - executed lines 15/15; executed functions 2/3 +(74%) toolbox/game-journey/game-journey.js - executed lines 1652/1652; executed functions 110/149 (76%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 22/29 -(95%) toolbox/messages/messages.js - executed lines 1019/1019; executed functions 98/103 -(100%) src/engine/audio/TextToSpeechDefaults.js - executed lines 108/108; executed functions 1/1 -(100%) toolbox/messages/message-tts-service-registry.js - executed lines 45/45; executed functions 7/7 +(100%) toolbox/game-journey/game-journey-api-client.js - executed lines 19/19; executed functions 2/2 Uncovered or low-coverage changed JS files: -(0%) src/dev-runtime/messages/messages-postgres-service.mjs - WARNING: uncovered changed runtime JS file; advisory only +(0%) src/dev-runtime/persistence/game-journey-completion-metrics-store.mjs - WARNING: uncovered changed runtime JS file; advisory only +(0%) src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js - WARNING: uncovered changed runtime JS file; advisory only (0%) src/dev-runtime/server/local-api-router.mjs - WARNING: uncovered changed runtime JS file; advisory only Changed JS files considered: -(0%) src/dev-runtime/messages/messages-postgres-service.mjs - changed JS file not collected as browser runtime coverage +(0%) src/dev-runtime/persistence/game-journey-completion-metrics-store.mjs - changed JS file not collected as browser runtime coverage +(0%) src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js - changed JS file not collected as browser runtime coverage (0%) src/dev-runtime/server/local-api-router.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/dev-runtime/DbSeedIntegrity.test.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/helpers/messagesPostgresClientStub.mjs - changed JS file not collected as browser runtime coverage +(0%) tests/helpers/gameJourneyCompletionMetricsPostgresClientStub.mjs - changed JS file not collected as browser runtime coverage (0%) tests/helpers/playwrightRepoServer.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/playwright/tools/MessagesTool.spec.mjs - changed JS file not collected as browser runtime coverage +(0%) tests/playwright/tools/GameJourneyTool.spec.mjs - changed JS file not collected as browser runtime coverage diff --git a/src/dev-runtime/persistence/game-journey-completion-metrics-store.mjs b/src/dev-runtime/persistence/game-journey-completion-metrics-store.mjs index 12d741388..c23ab78e0 100644 --- a/src/dev-runtime/persistence/game-journey-completion-metrics-store.mjs +++ b/src/dev-runtime/persistence/game-journey-completion-metrics-store.mjs @@ -1,11 +1,36 @@ -import { mkdirSync } from "node:fs"; +import { existsSync } from "node:fs"; import path from "node:path"; import process from "node:process"; -import { DatabaseSync } from "node:sqlite"; +import { createPostgresConnectionClient } from "./postgres-connection-client.mjs"; import { SEED_DB_KEYS, makeSeedUlid } from "../seed/seed-db-keys.mjs"; export const GAME_JOURNEY_COMPLETION_METRICS_TABLE = "game_journey_completion_metrics"; +const GAME_JOURNEY_COMPLETION_METRICS_SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS game_journey_completion_metrics ( + key text PRIMARY KEY, + "bucketKey" text NOT NULL UNIQUE, + "bucketOrder" integer NOT NULL DEFAULT 0, + "bucketName" text NOT NULL, + "friendlyDescription" text NOT NULL, + "requiredForMvp" boolean NOT NULL DEFAULT false, + "canSkip" boolean NOT NULL DEFAULT false, + "plannedCount" integer NOT NULL DEFAULT 0, + "completedCount" integer NOT NULL DEFAULT 0, + "active" boolean NOT NULL DEFAULT true, + "status" text NOT NULL DEFAULT 'active', + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now(), + "createdBy" text NOT NULL REFERENCES users(key), + "updatedBy" text NOT NULL REFERENCES users(key) +); + +CREATE INDEX IF NOT EXISTS idx_game_journey_completion_metrics_active ON game_journey_completion_metrics ("active"); +CREATE INDEX IF NOT EXISTS idx_game_journey_completion_metrics_status ON game_journey_completion_metrics ("status"); +CREATE INDEX IF NOT EXISTS idx_game_journey_completion_metrics_createdby ON game_journey_completion_metrics ("createdBy"); +CREATE INDEX IF NOT EXISTS idx_game_journey_completion_metrics_updatedby ON game_journey_completion_metrics ("updatedBy"); +`; + function makeCompletionBucket({ order, ...bucket }) { return Object.freeze({ key: makeSeedUlid(6000 + order), @@ -35,14 +60,34 @@ function clone(value) { return JSON.parse(JSON.stringify(value)); } -function defaultDatabasePath() { - const configured = String(process.env.GAMEFOUNDRY_GAME_JOURNEY_METRICS_DB_PATH || "").trim(); +function defaultLegacySqlitePath(env = process.env) { + const configured = String(env.GAMEFOUNDRY_GAME_JOURNEY_METRICS_DB_PATH || "").trim(); if (configured) { return path.resolve(configured); } return path.join(process.cwd(), "tmp", "local-api", "game-journey-completion-metrics.sqlite"); } +function resolveLegacySqlitePath({ dbPath, env, legacyDbPath }) { + if (legacyDbPath === null) { + return ""; + } + if (legacyDbPath !== undefined) { + return path.resolve(legacyDbPath); + } + if (dbPath) { + return path.resolve(dbPath); + } + return defaultLegacySqlitePath(env); +} + +function assertNoUnmigratedLegacySqlite(legacyDbPath) { + if (!legacyDbPath || !existsSync(legacyDbPath)) { + return; + } + throw new Error(`Legacy Game Journey completion metrics SQLite data exists at ${legacyDbPath}. No data was removed or overwritten. Export or migrate that data into Postgres, then move the legacy file before using the Postgres metrics store.`); +} + function normalizeCount(value, fallback = 0) { const parsed = Number(value); if (!Number.isFinite(parsed)) { @@ -52,10 +97,10 @@ function normalizeCount(value, fallback = 0) { } function normalizeActive(value, fallback = true) { - if (value === true || value === 1 || value === "1" || value === "active") { + if (value === true || value === 1 || value === "1" || value === "true" || value === "active") { return true; } - if (value === false || value === 0 || value === "0" || value === "inactive") { + if (value === false || value === 0 || value === "0" || value === "false" || value === "inactive") { return false; } return fallback; @@ -92,192 +137,184 @@ function normalizeMetric(row, fallback = {}) { }; } -function addMissingColumn(database, columns, name, ddl) { - if (!columns.has(name)) { - database.exec(`ALTER TABLE ${GAME_JOURNEY_COMPLETION_METRICS_TABLE} ADD COLUMN ${ddl}`); - } +function queryForBucketKey(bucketKey) { + return `select=*&bucketKey=eq.${encodeURIComponent(bucketKey)}`; } -function ensureMetricColumns(database) { - const columns = new Set( - database.prepare(`PRAGMA table_info(${GAME_JOURNEY_COMPLETION_METRICS_TABLE})`) - .all() - .map((column) => column.name), - ); - addMissingColumn(database, columns, "status", "\"status\" TEXT NOT NULL DEFAULT 'active'"); - addMissingColumn(database, columns, "createdBy", `"createdBy" TEXT NOT NULL DEFAULT '${SEED_DB_KEYS.users.forgeBot}'`); - addMissingColumn(database, columns, "updatedBy", `"updatedBy" TEXT NOT NULL DEFAULT '${SEED_DB_KEYS.users.forgeBot}'`); - addMissingColumn(database, columns, "key", `"key" TEXT NOT NULL DEFAULT '${makeSeedUlid(6000)}'`); +function sortByBucketOrder(left, right) { + return Number(left.bucketOrder) - Number(right.bucketOrder) + || String(left.bucketKey).localeCompare(String(right.bucketKey)); } -function openDatabase(dbPath) { - mkdirSync(path.dirname(dbPath), { recursive: true }); - const database = new DatabaseSync(dbPath); - database.exec(` - CREATE TABLE IF NOT EXISTS ${GAME_JOURNEY_COMPLETION_METRICS_TABLE} ( - "key" TEXT PRIMARY KEY, - "bucketKey" TEXT NOT NULL UNIQUE, - "bucketOrder" INTEGER NOT NULL, - "bucketName" TEXT NOT NULL, - "friendlyDescription" TEXT NOT NULL, - "requiredForMvp" INTEGER NOT NULL DEFAULT 0, - "canSkip" INTEGER NOT NULL DEFAULT 0, - "plannedCount" INTEGER NOT NULL DEFAULT 0, - "completedCount" INTEGER NOT NULL DEFAULT 0, - "active" INTEGER NOT NULL DEFAULT 1, - "status" TEXT NOT NULL DEFAULT 'active', - "createdAt" TEXT NOT NULL, - "updatedAt" TEXT NOT NULL, - "createdBy" TEXT NOT NULL, - "updatedBy" TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_game_journey_completion_metrics_active - ON ${GAME_JOURNEY_COMPLETION_METRICS_TABLE} ("active"); - `); - ensureMetricColumns(database); - return database; -} - -function seedDefaultBuckets(database, buckets) { - const now = new Date().toISOString(); - const statement = database.prepare(` - INSERT INTO ${GAME_JOURNEY_COMPLETION_METRICS_TABLE} ( - "bucketKey", - "key", - "bucketOrder", - "bucketName", - "friendlyDescription", - "requiredForMvp", - "canSkip", - "plannedCount", - "completedCount", - "active", - "status", - "createdAt", - "updatedAt", - "createdBy", - "updatedBy" - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT("bucketKey") DO UPDATE SET - "bucketOrder" = excluded."bucketOrder", - "bucketName" = excluded."bucketName", - "friendlyDescription" = excluded."friendlyDescription", - "requiredForMvp" = excluded."requiredForMvp", - "canSkip" = excluded."canSkip" - `); - buckets.forEach((bucket) => { - statement.run( - bucket.bucketKey, - bucket.key, - bucket.order, - bucket.bucketName, - bucket.friendlyDescription, - bucket.requiredForMvp ? 1 : 0, - bucket.canSkip ? 1 : 0, - bucket.plannedCount, - bucket.completedCount, - bucket.active ? 1 : 0, - bucket.active ? "active" : "inactive", - now, - now, - SEED_DB_KEYS.users.forgeBot, - SEED_DB_KEYS.users.forgeBot, - ); - }); -} - -function readMetrics(database, buckets) { - seedDefaultBuckets(database, buckets); - const rows = database.prepare(` - SELECT - "bucketKey", - "key", - "bucketOrder", - "bucketName", - "friendlyDescription", - "requiredForMvp", - "canSkip", - "plannedCount", - "completedCount", - "active", - "status", - "createdAt", - "updatedAt", - "createdBy", - "updatedBy" - FROM ${GAME_JOURNEY_COMPLETION_METRICS_TABLE} - ORDER BY "bucketOrder" ASC - `).all(); - const fallbackByKey = new Map(buckets.map((bucket) => [bucket.bucketKey, bucket])); - return rows.map((row) => normalizeMetric(row, fallbackByKey.get(row.bucketKey) || {})); +function bucketSeedRow(bucket, now) { + return { + active: bucket.active, + bucketKey: bucket.bucketKey, + bucketName: bucket.bucketName, + bucketOrder: bucket.order, + canSkip: bucket.canSkip, + completedCount: bucket.completedCount, + createdAt: now, + createdBy: SEED_DB_KEYS.users.forgeBot, + friendlyDescription: bucket.friendlyDescription, + key: bucket.key, + plannedCount: bucket.plannedCount, + requiredForMvp: bucket.requiredForMvp, + status: bucket.active ? "active" : "inactive", + updatedAt: now, + updatedBy: SEED_DB_KEYS.users.forgeBot, + }; } export function createGameJourneyCompletionMetricsStore(options = {}) { - const dbPath = path.resolve(options.dbPath || defaultDatabasePath()); + const env = options.env || process.env; const bucketSeeds = Object.freeze((options.buckets || GAME_JOURNEY_COMPLETION_BUCKETS).map(clone)); + const legacyDbPath = resolveLegacySqlitePath({ + dbPath: options.dbPath, + env, + legacyDbPath: options.legacyDbPath, + }); + let postgresClient = options.postgresClient || null; + let readyPromise = null; - function withDatabase(callback) { - const database = openDatabase(dbPath); + function client() { + if (postgresClient) { + return postgresClient; + } try { - return callback(database); - } finally { - database.close(); + postgresClient = createPostgresConnectionClient({ env }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error || "Unknown Postgres configuration error."); + throw new Error(`Game Journey completion metrics Postgres storage is not configured. ${message}`); } + return postgresClient; } - function listMetrics() { - return withDatabase((database) => readMetrics(database, bucketSeeds)); + async function tableRows() { + const rows = await client().requestTable(GAME_JOURNEY_COMPLETION_METRICS_TABLE, { + method: "GET", + query: "select=*", + }); + return Array.isArray(rows) ? clone(rows) : []; } - function updateMetric(bucketKey, updates = {}) { + async function rowByBucketKey(bucketKey) { + const rows = await client().requestTable(GAME_JOURNEY_COMPLETION_METRICS_TABLE, { + method: "GET", + query: queryForBucketKey(bucketKey), + }); + return clone(Array.isArray(rows) ? rows[0] || null : null); + } + + async function upsertRow(row) { + const rows = await client().requestTable(GAME_JOURNEY_COMPLETION_METRICS_TABLE, { + body: row, + method: "POST", + }); + return clone(Array.isArray(rows) ? rows[0] || row : row); + } + + async function patchRow(bucketKey, row) { + const rows = await client().requestTable(GAME_JOURNEY_COMPLETION_METRICS_TABLE, { + body: row, + method: "PATCH", + query: queryForBucketKey(bucketKey), + }); + return clone(Array.isArray(rows) ? rows[0] || null : null); + } + + async function seedDefaultBuckets() { + const now = new Date().toISOString(); + const existingByBucketKey = new Map((await tableRows()).map((row) => [row.bucketKey, row])); + for (const bucket of bucketSeeds) { + const existing = existingByBucketKey.get(bucket.bucketKey); + if (!existing) { + await upsertRow(bucketSeedRow(bucket, now)); + continue; + } + await patchRow(bucket.bucketKey, { + bucketName: bucket.bucketName, + bucketOrder: bucket.order, + canSkip: bucket.canSkip, + friendlyDescription: bucket.friendlyDescription, + requiredForMvp: bucket.requiredForMvp, + }); + } + } + + async function ensureReady() { + if (!readyPromise) { + readyPromise = (async () => { + assertNoUnmigratedLegacySqlite(legacyDbPath); + await client().query(GAME_JOURNEY_COMPLETION_METRICS_SCHEMA_SQL); + await seedDefaultBuckets(); + })(); + } + return readyPromise; + } + + async function listMetrics() { + await ensureReady(); + const fallbackByKey = new Map(bucketSeeds.map((bucket) => [bucket.bucketKey, bucket])); + return (await tableRows()) + .map((row) => normalizeMetric(row, fallbackByKey.get(row.bucketKey) || {})) + .sort(sortByBucketOrder); + } + + async function updateMetric(bucketKey, updates = {}) { + await ensureReady(); const key = String(bucketKey || updates.bucketKey || "").trim(); if (!key) { throw new Error("Game Journey completion metric update requires a bucketKey."); } - return withDatabase((database) => { - const current = readMetrics(database, bucketSeeds).find((metric) => metric.bucketKey === key); - if (!current) { - throw new Error(`Unknown Game Journey completion metric bucket: ${key}.`); - } - const plannedCount = updates.plannedCount === undefined - ? current.plannedCount - : normalizeCount(updates.plannedCount, current.plannedCount); - const completedCount = Math.min( - updates.completedCount === undefined - ? current.completedCount - : normalizeCount(updates.completedCount, current.completedCount), - plannedCount, - ); - const active = updates.active === undefined && updates.status === undefined - ? current.active - : normalizeActive(updates.active ?? updates.status, current.active); - const updatedAt = new Date().toISOString(); - database.prepare(` - UPDATE ${GAME_JOURNEY_COMPLETION_METRICS_TABLE} - SET - "plannedCount" = ?, - "completedCount" = ?, - "active" = ?, - "status" = ?, - "updatedAt" = ? - WHERE "bucketKey" = ? - `).run(plannedCount, completedCount, active ? 1 : 0, active ? "active" : "inactive", updatedAt, key); - return readMetrics(database, bucketSeeds).find((metric) => metric.bucketKey === key); + const current = (await listMetrics()).find((metric) => metric.bucketKey === key); + if (!current) { + throw new Error(`Unknown Game Journey completion metric bucket: ${key}.`); + } + const plannedCount = updates.plannedCount === undefined + ? current.plannedCount + : normalizeCount(updates.plannedCount, current.plannedCount); + const completedCount = Math.min( + updates.completedCount === undefined + ? current.completedCount + : normalizeCount(updates.completedCount, current.completedCount), + plannedCount, + ); + const active = updates.active === undefined && updates.status === undefined + ? current.active + : normalizeActive(updates.active ?? updates.status, current.active); + const updatedAt = new Date().toISOString(); + const row = await patchRow(key, { + active, + completedCount, + plannedCount, + status: active ? "active" : "inactive", + updatedAt, + updatedBy: current.updatedBy || SEED_DB_KEYS.users.forgeBot, }); + return normalizeMetric(row || { + ...current, + active, + completedCount, + plannedCount, + status: active ? "active" : "inactive", + updatedAt, + }, current); } - function snapshot() { - const metrics = listMetrics(); + async function snapshot() { + const metrics = await listMetrics(); const activeCount = metrics.filter((metric) => metric.active).length; const plannedCount = metrics.reduce((total, metric) => total + metric.plannedCount, 0); const completedCount = metrics.reduce((total, metric) => total + metric.completedCount, 0); return { api: "Local API", - database: "Local DB", - databaseEngine: "SQLite", - databasePath: dbPath, - serviceContract: "Web UI -> Local API/Service Contract -> Local DB", + database: "Postgres", + databaseConfigKey: "GAMEFOUNDRY_DATABASE_URL", + databaseEngine: "Postgres", + databasePath: "GAMEFOUNDRY_DATABASE_URL", + legacySqlitePath: legacyDbPath, + serviceContract: "Web UI -> Local API/Service Contract -> Postgres", source: GAME_JOURNEY_COMPLETION_METRICS_TABLE, tableName: GAME_JOURNEY_COMPLETION_METRICS_TABLE, activeCount, @@ -290,7 +327,7 @@ export function createGameJourneyCompletionMetricsStore(options = {}) { } return { - dbPath, + legacyDbPath, listMetrics, snapshot, updateMetric, diff --git a/src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js b/src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js index 1bbbc2527..905b1dba6 100644 --- a/src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js +++ b/src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js @@ -604,6 +604,8 @@ export function createGameJourneyMockRepository(options = {}) { const completionMetricsStore = options.completionMetricsStore || createGameJourneyCompletionMetricsStore({ dbPath: options.completionMetricsDbPath, + legacyDbPath: options.completionMetricsLegacyDbPath, + postgresClient: options.completionMetricsPostgresClient, }); const tables = loadMockDbTables(GAME_JOURNEY_DB_OWNER, getSeedTables(), options).tables; let selectedNoteKey = GAME_JOURNEY_KEYS.notes.designPass; @@ -1504,8 +1506,8 @@ export function createGameJourneyMockRepository(options = {}) { } return { - getTables: () => clone({ - game_journey_completion_metrics: completionMetricsStore.listMetrics(), + getTables: async () => clone({ + game_journey_completion_metrics: await completionMetricsStore.listMetrics(), ...tables, }), getCompletionMetricsSnapshot: () => completionMetricsStore.snapshot(), diff --git a/src/dev-runtime/server/local-api-router.mjs b/src/dev-runtime/server/local-api-router.mjs index 6e843911d..0a4c82fca 100644 --- a/src/dev-runtime/server/local-api-router.mjs +++ b/src/dev-runtime/server/local-api-router.mjs @@ -2212,8 +2212,8 @@ function controlsTables(repository) { return normalizeOwnedTables("controls", repository.getTables()); } -function gameJourneyTables(repository) { - return normalizeOwnedTables("game-journey", repository.getTables()); +async function gameJourneyTables(repository) { + return normalizeOwnedTables("game-journey", await repository.getTables()); } function paletteTables(repository) { @@ -2318,11 +2318,15 @@ function productTablesFromSnapshot(snapshot) { class ApiRuntimeDataSource { constructor({ + gameJourneyCompletionMetricsLegacyDbPath = undefined, + gameJourneyCompletionMetricsPostgresClient = null, messagesPostgresClient = null, messagesService = null, repoRoot = process.cwd(), } = {}) { this.messagesService = messagesService || createMessagesPostgresService({ postgresClient: messagesPostgresClient }); + this.gameJourneyCompletionMetricsLegacyDbPath = gameJourneyCompletionMetricsLegacyDbPath; + this.gameJourneyCompletionMetricsPostgresClient = gameJourneyCompletionMetricsPostgresClient; this.repositoryCounter = 1; this.repositoryById = new Map(); this.sessionModeId = FIXED_ACCOUNT_SESSION_MODE.id; @@ -2423,7 +2427,8 @@ class ApiRuntimeDataSource { } async persistSupabaseProductSnapshot(action) { - return this.upsertSupabaseProductTables(this.snapshot().tables, action); + const snapshot = await this.snapshot(); + return this.upsertSupabaseProductTables(snapshot.tables, action); } async persistSupabaseGameWorkspaceSnapshot(action) { @@ -2537,7 +2542,7 @@ class ApiRuntimeDataSource { return this.currentSessionForRoute(); } - currentStateSnapshot() { + async currentStateSnapshot() { return this.snapshot(); } @@ -2565,6 +2570,8 @@ class ApiRuntimeDataSource { this.standaloneTables.invitations = []; } this.sharedOptions = { + completionMetricsLegacyDbPath: this.gameJourneyCompletionMetricsLegacyDbPath, + completionMetricsPostgresClient: this.gameJourneyCompletionMetricsPostgresClient, memoryDbTables: this.standaloneTables, sessionMode: this.sessionModeId, sessionUserKey: this.sessionUserKey, @@ -3662,14 +3669,14 @@ LIMIT 1; }; } - gameJourneyCompletionMetricsForRoute() { + async gameJourneyCompletionMetricsForRoute() { return this.gameJourneyRepository.getCompletionMetricsSnapshot(); } - updateGameJourneyCompletionMetricForRoute(bucketKey, updates = {}) { - const metric = this.gameJourneyRepository.updateCompletionMetric(bucketKey, updates); + async updateGameJourneyCompletionMetricForRoute(bucketKey, updates = {}) { + const metric = await this.gameJourneyRepository.updateCompletionMetric(bucketKey, updates); return { - ...this.gameJourneyCompletionMetricsForRoute(), + ...(await this.gameJourneyCompletionMetricsForRoute()), updatedMetric: metric, }; } @@ -5313,7 +5320,7 @@ LIMIT 1; return result; } - snapshot() { + async snapshot() { const schemas = getMockDbTableSchemas(); const toolGroups = getMockDbToolGroups(); const owners = { @@ -5363,7 +5370,7 @@ LIMIT 1; ...gameConfigurationTables(this.gameConfigurationRepository), ...objectsTables(this.objectsRepository), ...controlsTables(this.inputMappingRepository), - ...gameJourneyTables(this.gameJourneyRepository), + ...(await gameJourneyTables(this.gameJourneyRepository)), ...paletteTables(this.paletteRepository), ...tagsTables(this.tagsRepository), ...assetTables(this.assetRepository), @@ -5412,7 +5419,7 @@ LIMIT 1; async snapshotForRoute() { const adapter = this.supabaseDatabaseAdapter("Reading Supabase product database state"); const providerSnapshot = await adapter.getDbViewerSnapshot(); - const baseline = this.snapshot(); + const baseline = await this.snapshot(); const schemas = getMockDbTableSchemas(); const tableDiagnostics = Array.isArray(providerSnapshot.tableDiagnostics) ? providerSnapshot.tableDiagnostics @@ -5448,11 +5455,19 @@ LIMIT 1; * The router itself serves the configured server API contract. */ export function createLocalApiRouter({ + gameJourneyCompletionMetricsLegacyDbPath = undefined, + gameJourneyCompletionMetricsPostgresClient = null, messagesPostgresClient = null, messagesService = null, repoRoot = process.cwd(), } = {}) { - const dataSource = new ApiRuntimeDataSource({ messagesPostgresClient, messagesService, repoRoot }); + const dataSource = new ApiRuntimeDataSource({ + gameJourneyCompletionMetricsLegacyDbPath, + gameJourneyCompletionMetricsPostgresClient, + messagesPostgresClient, + messagesService, + repoRoot, + }); async function handleApiRuntimeRequest(request, response, requestUrl) { if (!requestUrl.pathname.startsWith("/api/")) { @@ -5547,12 +5562,12 @@ export function createLocalApiRouter({ if (parts[1] === "game-journey" && parts[2] === "completion-metrics") { if (request.method === "GET") { - ok(response, dataSource.gameJourneyCompletionMetricsForRoute()); + ok(response, await dataSource.gameJourneyCompletionMetricsForRoute()); return true; } if ((request.method === "POST" || request.method === "PATCH") && parts[3]) { const body = await readRequestJson(request); - ok(response, dataSource.updateGameJourneyCompletionMetricForRoute(parts[3], body)); + ok(response, await dataSource.updateGameJourneyCompletionMetricForRoute(parts[3], body)); return true; } } diff --git a/tests/helpers/gameJourneyCompletionMetricsPostgresClientStub.mjs b/tests/helpers/gameJourneyCompletionMetricsPostgresClientStub.mjs new file mode 100644 index 000000000..e1c879932 --- /dev/null +++ b/tests/helpers/gameJourneyCompletionMetricsPostgresClientStub.mjs @@ -0,0 +1,87 @@ +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function filterFromQuery(query = "") { + const params = new URLSearchParams(query); + for (const [key, value] of params.entries()) { + if (key === "select" || key === "on_conflict") { + continue; + } + if (!value.startsWith("eq.")) { + throw new Error(`Unsupported Game Journey metrics Postgres test filter for ${key}.`); + } + return { + key, + value: decodeURIComponent(value.slice(3)), + }; + } + return null; +} + +export function createGameJourneyCompletionMetricsPostgresClientStub() { + const tables = new Map(); + + function table(name) { + if (!tables.has(name)) { + tables.set(name, []); + } + return tables.get(name); + } + + return { + dumpTable(tableName) { + return clone(table(tableName)); + }, + + async query(sql) { + if (!String(sql || "").trim()) { + return []; + } + return []; + }, + + async requestTable(tableName, { body = null, method = "GET", query = "select=*" } = {}) { + const rows = table(tableName); + const normalizedMethod = String(method || "GET").toUpperCase(); + const filter = filterFromQuery(query); + + if (normalizedMethod === "GET") { + const selected = filter ? rows.filter((row) => String(row[filter.key]) === filter.value) : rows; + return clone(selected); + } + + if (normalizedMethod === "POST") { + const incomingRows = Array.isArray(body) ? body : [body]; + const written = incomingRows.map((incoming) => { + const row = clone(incoming || {}); + const index = rows.findIndex((existing) => existing.key === row.key); + if (index === -1) { + rows.push(row); + } else { + rows[index] = { ...rows[index], ...row }; + } + return row; + }); + return clone(written); + } + + if (normalizedMethod === "PATCH") { + if (!filter) { + throw new Error(`PATCH ${tableName} requires an equality filter.`); + } + const patched = []; + rows.forEach((row, index) => { + if (String(row[filter.key]) !== filter.value) { + return; + } + rows[index] = { ...row, ...clone(body || {}) }; + patched.push(rows[index]); + }); + return clone(patched); + } + + throw new Error(`Unsupported Game Journey metrics Postgres test method: ${normalizedMethod}.`); + }, + }; +} diff --git a/tests/helpers/playwrightRepoServer.mjs b/tests/helpers/playwrightRepoServer.mjs index b10698d21..9bc8e8f07 100644 --- a/tests/helpers/playwrightRepoServer.mjs +++ b/tests/helpers/playwrightRepoServer.mjs @@ -91,10 +91,17 @@ function resolveBrowserRoutePath(decodedPath) { } export async function startRepoServer({ + gameJourneyCompletionMetricsLegacyDbPath = undefined, + gameJourneyCompletionMetricsPostgresClient = null, messagesPostgresClient = null, } = {}) { await loadRuntimeEnv(); - const handleLocalApiRequest = createLocalApiRouter({ messagesPostgresClient, repoRoot }); + const handleLocalApiRequest = createLocalApiRouter({ + gameJourneyCompletionMetricsLegacyDbPath, + gameJourneyCompletionMetricsPostgresClient, + messagesPostgresClient, + repoRoot, + }); const server = http.createServer(async (request, response) => { try { const requestUrl = new URL(request.url || "/", "http://127.0.0.1"); diff --git a/tests/playwright/tools/GameJourneyTool.spec.mjs b/tests/playwright/tools/GameJourneyTool.spec.mjs index 429f6f381..2e3896550 100644 --- a/tests/playwright/tools/GameJourneyTool.spec.mjs +++ b/tests/playwright/tools/GameJourneyTool.spec.mjs @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs"; +import { createGameJourneyCompletionMetricsPostgresClientStub } from "../../helpers/gameJourneyCompletionMetricsPostgresClientStub.mjs"; import { clearPlaywrightStorage, installPlaywrightStorageIsolation } from "../../helpers/playwrightStorageIsolation.mjs"; import { workspaceV2CoverageReporter } from "../../helpers/workspaceV2CoverageReporter.mjs"; import { @@ -13,6 +14,7 @@ import { GAME_JOURNEY_TOOL_OWNERSHIP_AREAS, createGameJourneyMockRepository, } from "../../../src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js"; +import { createGameJourneyCompletionMetricsStore } from "../../../src/dev-runtime/persistence/game-journey-completion-metrics-store.mjs"; import { MOCK_DB_KEYS, getStandaloneMockDbSeedTables } from "../../../src/dev-runtime/persistence/mock-db-store.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -36,7 +38,11 @@ test.afterAll(async () => { }); async function openRepoPage(page, pathName, options = {}) { - const server = await startRepoServer(); + const gameJourneyCompletionMetricsPostgresClient = createGameJourneyCompletionMetricsPostgresClientStub(); + const server = await startRepoServer({ + gameJourneyCompletionMetricsLegacyDbPath: null, + gameJourneyCompletionMetricsPostgresClient, + }); const previousApiUrl = process.env.GAMEFOUNDRY_API_URL; const previousSiteUrl = process.env.GAMEFOUNDRY_SITE_URL; process.env.GAMEFOUNDRY_API_URL = `${server.baseUrl}/api`; @@ -78,7 +84,15 @@ async function openRepoPage(page, pathName, options = {}) { method: "POST", }); await page.goto(`${server.baseUrl}${pathName}`, { waitUntil: "networkidle" }); - return { consoleErrors, failedRequests, pageErrors, previousApiUrl, previousSiteUrl, server }; + return { + consoleErrors, + failedRequests, + gameJourneyCompletionMetricsPostgresClient, + pageErrors, + previousApiUrl, + previousSiteUrl, + server, + }; } async function fetchApiData(server, pathName, options = {}) { @@ -207,7 +221,11 @@ test("Game Journey exposes static tool ownership areas without automatic counts" "Audio", ]); - const server = await startRepoServer(); + const gameJourneyCompletionMetricsPostgresClient = createGameJourneyCompletionMetricsPostgresClientStub(); + const server = await startRepoServer({ + gameJourneyCompletionMetricsLegacyDbPath: null, + gameJourneyCompletionMetricsPostgresClient, + }); try { const constants = await fetchApiData(server, "/api/toolbox/game-journey/constants"); expectStaticToolOwnershipAreas(constants.GAME_JOURNEY_TOOL_OWNERSHIP_AREAS); @@ -226,7 +244,11 @@ test("Game Journey progress dashboard summarizes completion metrics", async ({ p const previousLocalDbPath = process.env.GAMEFOUNDRY_LOCAL_DB_PATH; const localDbPath = path.join(process.cwd(), "tmp", "local-db", `game-journey-targets-${process.pid}-${Date.now()}.sqlite`); process.env.GAMEFOUNDRY_LOCAL_DB_PATH = localDbPath; - const server = await startRepoServer(); + const gameJourneyCompletionMetricsPostgresClient = createGameJourneyCompletionMetricsPostgresClientStub(); + const server = await startRepoServer({ + gameJourneyCompletionMetricsLegacyDbPath: null, + gameJourneyCompletionMetricsPostgresClient, + }); const previousApiUrl = process.env.GAMEFOUNDRY_API_URL; const previousSiteUrl = process.env.GAMEFOUNDRY_SITE_URL; process.env.GAMEFOUNDRY_API_URL = `${server.baseUrl}/api`; @@ -1199,15 +1221,17 @@ test("Game Journey displays system template diagnostics", async ({ page }) => { } }); -test("Game Journey mock data keeps system guidance template-owned", () => { +test("Game Journey mock data keeps system guidance template-owned", async () => { const repository = createGameJourneyMockRepository({ + completionMetricsLegacyDbPath: null, + completionMetricsPostgresClient: createGameJourneyCompletionMetricsPostgresClientStub(), memoryDbTables: standaloneSeedTables, persist: false, sessionUserKey: MOCK_DB_KEYS.users.user1, }); repository.openGame("demo-game"); - const tables = repository.getTables(); + const tables = await repository.getTables(); expect(tables.game_journey_items).toBeTruthy(); expect(tables.game_journey_templates).toBeTruthy(); expect(tables.game_journey_entries).toBeUndefined(); @@ -1363,14 +1387,16 @@ test("Game Journey mock data keeps system guidance template-owned", () => { expect(firstAddedItem.title).toBe("First editable user item"); }); -test("Game Journey Local API persists completion metrics to SQLite", async () => { - const previousMetricsPath = process.env.GAMEFOUNDRY_GAME_JOURNEY_METRICS_DB_PATH; - const metricsPath = path.join(process.cwd(), "tmp", "local-api", `game-journey-metrics-${process.pid}-${Date.now()}.sqlite`); - process.env.GAMEFOUNDRY_GAME_JOURNEY_METRICS_DB_PATH = metricsPath; - const server = await startRepoServer(); +test("Game Journey Local API persists completion metrics to Postgres", async () => { + const gameJourneyCompletionMetricsPostgresClient = createGameJourneyCompletionMetricsPostgresClientStub(); + const server = await startRepoServer({ + gameJourneyCompletionMetricsLegacyDbPath: null, + gameJourneyCompletionMetricsPostgresClient, + }); try { const initial = await fetchApiData(server, "/api/game-journey/completion-metrics"); - expect(initial.databaseEngine).toBe("SQLite"); + expect(initial.databaseEngine).toBe("Postgres"); + expect(initial.databaseConfigKey).toBe("GAMEFOUNDRY_DATABASE_URL"); expect(initial.records).toHaveLength(14); expect(initial.records.find((metric) => metric.bucketKey === "001-idea")).toMatchObject({ active: false, @@ -1406,31 +1432,43 @@ test("Game Journey Local API persists completion metrics to SQLite", async () => status: "active", }); - const { DatabaseSync } = await import("node:sqlite"); - const database = new DatabaseSync(metricsPath); - try { - const row = database.prepare(` - SELECT "plannedCount", "completedCount", "active", "status" - FROM game_journey_completion_metrics - WHERE "bucketKey" = ? - `).get("001-idea"); - expect(row).toMatchObject({ - active: 1, - completedCount: 2, - plannedCount: 4, - status: "active", - }); - } finally { - database.close(); - } + const row = gameJourneyCompletionMetricsPostgresClient + .dumpTable("game_journey_completion_metrics") + .find((metric) => metric.bucketKey === "001-idea"); + expect(row).toMatchObject({ + active: true, + completedCount: 2, + plannedCount: 4, + status: "active", + }); } finally { await server.close(); - await fs.rm(metricsPath, { force: true }); - if (previousMetricsPath) { - process.env.GAMEFOUNDRY_GAME_JOURNEY_METRICS_DB_PATH = previousMetricsPath; - } else { - delete process.env.GAMEFOUNDRY_GAME_JOURNEY_METRICS_DB_PATH; - } + } +}); + +test("Game Journey completion metrics fail visibly when Postgres is not configured", async () => { + const store = createGameJourneyCompletionMetricsStore({ + env: {}, + legacyDbPath: null, + }); + + await expect(store.listMetrics()).rejects.toThrow(/GAMEFOUNDRY_DATABASE_URL/); +}); + +test("Game Journey completion metrics protect legacy SQLite data from silent drop", async () => { + const legacyDbPath = path.join(process.cwd(), "tmp", "local-api", `game-journey-legacy-guard-${process.pid}-${Date.now()}.sqlite`); + await fs.mkdir(path.dirname(legacyDbPath), { recursive: true }); + await fs.writeFile(legacyDbPath, "legacy metrics placeholder"); + + const store = createGameJourneyCompletionMetricsStore({ + legacyDbPath, + postgresClient: createGameJourneyCompletionMetricsPostgresClientStub(), + }); + + try { + await expect(store.listMetrics()).rejects.toThrow(/Legacy Game Journey completion metrics SQLite data exists/); + } finally { + await fs.rm(legacyDbPath, { force: true }); } });