From cb17f227ae60f7194de88dc33cb7403955f5d67b Mon Sep 17 00:00:00 2001 From: "Mr. Q" <97194984+ToolboxAid@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:19:45 -0400 Subject: [PATCH] PR_26171_ALPHA_023 game journey postgres metrics migration --- ...trics-migration-data-preservation-notes.md | 19 + ...ration-instruction-compliance-checklist.md | 16 + ...trics-migration-manual-validation-notes.md | 20 + ...game-journey-postgres-metrics-migration.md | 45 +++ .../dev/reports/codex_changed_files.txt | 95 ++--- docs_build/dev/reports/codex_review.diff | Bin 172924 -> 135982 bytes .../reports/coverage_changed_js_guardrail.txt | 30 +- .../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 +- ...neyCompletionMetricsPostgresClientStub.mjs | 87 +++++ tests/helpers/playwrightRepoServer.mjs | 11 +- .../playwright/tools/GameJourneyTool.spec.mjs | 108 +++-- 14 files changed, 546 insertions(+), 386 deletions(-) create mode 100644 docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-data-preservation-notes.md create mode 100644 docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-instruction-compliance-checklist.md create mode 100644 docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration-manual-validation-notes.md create mode 100644 docs_build/dev/reports/PR_26171_ALPHA_023-game-journey-postgres-metrics-migration.md create mode 100644 tests/helpers/gameJourneyCompletionMetricsPostgresClientStub.mjs 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 1a893c6ef..9f07c7577 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt @@ -1,69 +1,30 @@ -# PR_26171_GAMMA_019-admin-workstream-mergeability-recovery changed files +## git status --short +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 +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 +A tests/helpers/gameJourneyCompletionMetricsPostgresClientStub.mjs +M tests/helpers/playwrightRepoServer.mjs +M tests/playwright/tools/GameJourneyTool.spec.mjs -Base: origin/main 35b04c02ea54da8b13c10354126f1ee8ddd14a89 -Head before recovery merge commit: 1806adbf5df787f7072c6579d23b99bb4257466b +## git diff --cached --stat + ...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(-) -## Changed files against origin/main -M admin/system-health.html -M assets/theme-v2/js/admin-system-health.js -A docs_build/dev/reports/PR_26171_GAMMA_011-admin-system-health-foundation-instruction-compliance-checklist.md -A docs_build/dev/reports/PR_26171_GAMMA_011-admin-system-health-foundation-manual-validation-notes.md -A docs_build/dev/reports/PR_26171_GAMMA_011-admin-system-health-foundation.md -A docs_build/dev/reports/PR_26171_GAMMA_012-admin-system-health-status-reason-cleanup-instruction-compliance-checklist.md -A docs_build/dev/reports/PR_26171_GAMMA_012-admin-system-health-status-reason-cleanup-manual-validation-notes.md -A docs_build/dev/reports/PR_26171_GAMMA_012-admin-system-health-status-reason-cleanup.md -A docs_build/dev/reports/PR_26171_GAMMA_013-admin-system-health-diagnostics-plan-instruction-compliance-checklist.md -A docs_build/dev/reports/PR_26171_GAMMA_013-admin-system-health-diagnostics-plan-manual-validation-notes.md -A docs_build/dev/reports/PR_26171_GAMMA_013-admin-system-health-diagnostics-plan.md -A docs_build/dev/reports/PR_26171_GAMMA_014-admin-postgres-diagnostics-runtime-instruction-compliance-checklist.md -A docs_build/dev/reports/PR_26171_GAMMA_014-admin-postgres-diagnostics-runtime-manual-validation-notes.md -A docs_build/dev/reports/PR_26171_GAMMA_014-admin-postgres-diagnostics-runtime.md -A docs_build/dev/reports/PR_26171_GAMMA_015-admin-r2-diagnostics-runtime-instruction-compliance-checklist.md -A docs_build/dev/reports/PR_26171_GAMMA_015-admin-r2-diagnostics-runtime-manual-validation-notes.md -A docs_build/dev/reports/PR_26171_GAMMA_015-admin-r2-diagnostics-runtime.md -A docs_build/dev/reports/PR_26171_GAMMA_016-admin-runtime-environment-runtime-instruction-compliance-checklist.md -A docs_build/dev/reports/PR_26171_GAMMA_016-admin-runtime-environment-runtime-manual-validation-notes.md -A docs_build/dev/reports/PR_26171_GAMMA_016-admin-runtime-environment-runtime.md -A docs_build/dev/reports/PR_26171_GAMMA_019-admin-workstream-mergeability-recovery.md -M docs_build/dev/reports/codex_changed_files.txt -M docs_build/dev/reports/codex_review.diff -M docs_build/dev/reports/coverage_changed_js_guardrail.txt -M docs_build/dev/reports/playwright_v8_coverage_report.txt -M src/dev-runtime/server/local-api-router.mjs -M tests/playwright/tools/AdminHealthOperationsPage.spec.mjs - -## Diff stat against origin/main - admin/system-health.html | 366 +-- - assets/theme-v2/js/admin-system-health.js | 505 ++-- - ...-foundation-instruction-compliance-checklist.md | 32 + - ...em-health-foundation-manual-validation-notes.md | 34 + - ...171_GAMMA_011-admin-system-health-foundation.md | 65 + - ...son-cleanup-instruction-compliance-checklist.md | 57 + - ...tatus-reason-cleanup-manual-validation-notes.md | 26 + - ...12-admin-system-health-status-reason-cleanup.md | 70 + - ...ostics-plan-instruction-compliance-checklist.md | 64 + - ...lth-diagnostics-plan-manual-validation-notes.md | 27 + - ...MMA_013-admin-system-health-diagnostics-plan.md | 82 + - ...ics-runtime-instruction-compliance-checklist.md | 64 + - ...-diagnostics-runtime-manual-validation-notes.md | 27 + - ...GAMMA_014-admin-postgres-diagnostics-runtime.md | 93 + - ...ics-runtime-instruction-compliance-checklist.md | 62 + - ...-diagnostics-runtime-manual-validation-notes.md | 26 + - ...26171_GAMMA_015-admin-r2-diagnostics-runtime.md | 95 + - ...ent-runtime-instruction-compliance-checklist.md | 66 + - ...-environment-runtime-manual-validation-notes.md | 28 + - ..._GAMMA_016-admin-runtime-environment-runtime.md | 102 + - ...A_019-admin-workstream-mergeability-recovery.md | 50 + - docs_build/dev/reports/codex_changed_files.txt | 79 +- - docs_build/dev/reports/codex_review.diff | 2696 +++++++++++++++++--- - .../dev/reports/coverage_changed_js_guardrail.txt | 28 + - .../dev/reports/playwright_v8_coverage_report.txt | 94 +- - src/dev-runtime/server/local-api-router.mjs | 46 + - .../tools/AdminHealthOperationsPage.spec.mjs | 151 +- - 27 files changed, 4034 insertions(+), 1001 deletions(-) - -## Validation evidence -- git diff --check: PASS -- git diff --cached --check: PASS -- npx playwright test tests/playwright/tools/AdminHealthOperationsPage.spec.mjs --config=codex_playwright_system_chrome.config.cjs --project=playwright: PASS (3 passed) -- samples smoke: SKIPPED by request +## git diff --stat diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff index 1c29de68d3bbbd51c55616c4b094594871452173..07d6bb81f19b025fed09c6b05672000ea49a1dd7 100644 GIT binary patch literal 135982 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 J|I``({{bvrptb-2 literal 172924 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 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 4eed49c54..efd9a0362 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,9 +2318,13 @@ function productTablesFromSnapshot(snapshot) { class ApiRuntimeDataSource { constructor({ + gameJourneyCompletionMetricsLegacyDbPath = undefined, + gameJourneyCompletionMetricsPostgresClient = null, repoRoot = process.cwd(), } = {}) { this.messagesService = createMessagesSqliteService({ repoRoot }); + this.gameJourneyCompletionMetricsLegacyDbPath = gameJourneyCompletionMetricsLegacyDbPath; + this.gameJourneyCompletionMetricsPostgresClient = gameJourneyCompletionMetricsPostgresClient; this.repositoryCounter = 1; this.repositoryById = new Map(); this.sessionModeId = FIXED_ACCOUNT_SESSION_MODE.id; @@ -2421,7 +2425,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) { @@ -2535,7 +2540,7 @@ class ApiRuntimeDataSource { return this.currentSessionForRoute(); } - currentStateSnapshot() { + async currentStateSnapshot() { return this.snapshot(); } @@ -2563,6 +2568,8 @@ class ApiRuntimeDataSource { this.standaloneTables.invitations = []; } this.sharedOptions = { + completionMetricsLegacyDbPath: this.gameJourneyCompletionMetricsLegacyDbPath, + completionMetricsPostgresClient: this.gameJourneyCompletionMetricsPostgresClient, memoryDbTables: this.standaloneTables, sessionMode: this.sessionModeId, sessionUserKey: this.sessionUserKey, @@ -3660,14 +3667,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, }; } @@ -5311,7 +5318,7 @@ LIMIT 1; return result; } - snapshot() { + async snapshot() { const schemas = getMockDbTableSchemas(); const toolGroups = getMockDbToolGroups(); const owners = { @@ -5361,7 +5368,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), @@ -5410,7 +5417,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 @@ -5446,9 +5453,15 @@ LIMIT 1; * The router itself serves the configured server API contract. */ export function createLocalApiRouter({ + gameJourneyCompletionMetricsLegacyDbPath = undefined, + gameJourneyCompletionMetricsPostgresClient = null, repoRoot = process.cwd(), } = {}) { - const dataSource = new ApiRuntimeDataSource({ repoRoot }); + const dataSource = new ApiRuntimeDataSource({ + gameJourneyCompletionMetricsLegacyDbPath, + gameJourneyCompletionMetricsPostgresClient, + repoRoot, + }); async function handleApiRuntimeRequest(request, response, requestUrl) { if (!requestUrl.pathname.startsWith("/api/")) { @@ -5543,12 +5556,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 13bb7c069..b0c6a0b49 100644 --- a/tests/helpers/playwrightRepoServer.mjs +++ b/tests/helpers/playwrightRepoServer.mjs @@ -90,9 +90,16 @@ function resolveBrowserRoutePath(decodedPath) { return normalizedPath; } -export async function startRepoServer() { +export async function startRepoServer({ + gameJourneyCompletionMetricsLegacyDbPath = undefined, + gameJourneyCompletionMetricsPostgresClient = null, +} = {}) { await loadRuntimeEnv(); - const handleLocalApiRequest = createLocalApiRouter({ repoRoot }); + const handleLocalApiRequest = createLocalApiRouter({ + gameJourneyCompletionMetricsLegacyDbPath, + gameJourneyCompletionMetricsPostgresClient, + 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 }); } });