diff --git a/docs_build/database/dml/DML_INDEX.md b/docs_build/database/dml/DML_INDEX.md index 3d8ee81f8..bd378d63e 100644 --- a/docs_build/database/dml/DML_INDEX.md +++ b/docs_build/database/dml/DML_INDEX.md @@ -14,7 +14,7 @@ Direct SQL setup is intentionally narrow. Account DEV users now require server-s | Game Design | `game-design.sql` | Server-seed-owned | Server-side seed API | | Game Journey | `game-journey.sql` | Server-seed-owned | Server-side seed API | | Game Hub | `game-workspace.sql` | Server-seed-owned | Server-side seed API | -| Messages | `messages.sql` | Server-seed-owned | Messages Local API/server-side SQLite service | +| Messages | `messages.sql` | Server-seed-owned | Messages Local API/server-side Postgres service | | Objects | `objects.sql` | Server-seed-owned | Server-side seed API | | Palette | `palette.sql` | Server-seed-owned | Server-side seed API | | Support Tickets | `support-tickets.sql` | Server-seed-owned | Admin Site Setup/server-side seed API | diff --git a/docs_build/database/dml/messages.sql b/docs_build/database/dml/messages.sql index ad9b75bff..9a489af0d 100644 --- a/docs_build/database/dml/messages.sql +++ b/docs_build/database/dml/messages.sql @@ -6,7 +6,7 @@ -- Owned tables: messages_categories, messages_emotion_profiles, messages_tts_profiles, messages_records, messages_segments -- DML status: Server-seed-owned. --- Setup is performed through the Messages Local API / server-side SQLite service for this PR. +-- Setup is performed through the Messages Local API / server-side Postgres service for this PR. -- Browser pages must not seed authoritative records. -- The server/API layer generates all keys and audit fields. -- This SQL file intentionally has no direct INSERT statements because direct SQL would bypass key/audit ownership. diff --git a/docs_build/database/seed/messages.json b/docs_build/database/seed/messages.json index 97e10c574..4548a6361 100644 --- a/docs_build/database/seed/messages.json +++ b/docs_build/database/seed/messages.json @@ -31,5 +31,5 @@ "messages_records": [], "messages_segments": [] }, - "note": "Seed names are applied by the server-side Messages SQLite service. Browser pages must not seed authoritative records." + "note": "Seed names are applied by the server-side Messages Postgres service. Browser pages must not seed authoritative records." } diff --git a/docs_build/dev/reports/PR_26171_BETA_022-messages-postgres-service-cutover-instruction-compliance-checklist.md b/docs_build/dev/reports/PR_26171_BETA_022-messages-postgres-service-cutover-instruction-compliance-checklist.md new file mode 100644 index 000000000..883a491c7 --- /dev/null +++ b/docs_build/dev/reports/PR_26171_BETA_022-messages-postgres-service-cutover-instruction-compliance-checklist.md @@ -0,0 +1,16 @@ +# PR_26171_BETA_022 Instruction Compliance Checklist + +TEAM ownership: BETA. + +- PASS: Read `docs_build/dev/PROJECT_INSTRUCTIONS.md`. +- PASS: Read `docs_build/dev/PROJECT_MULTI_PC.txt`. +- PASS: Verified Messages belongs to Team Beta ownership. +- PASS: Started from latest `main` before creating `team/BETA/messages`. +- PASS: Scope stayed within Messages Local API/service/tests and directly affected active database docs. +- PASS: Removed active Messages SQLite runtime dependency without touching unrelated SQLite work. +- PASS: Used targeted validation only. +- PASS: Did not run samples. +- PASS: Did not merge. +- 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_BETA_022-messages-postgres-service-cutover-manual-validation-notes.md b/docs_build/dev/reports/PR_26171_BETA_022-messages-postgres-service-cutover-manual-validation-notes.md new file mode 100644 index 000000000..da6f1bf5a --- /dev/null +++ b/docs_build/dev/reports/PR_26171_BETA_022-messages-postgres-service-cutover-manual-validation-notes.md @@ -0,0 +1,21 @@ +# PR_26171_BETA_022 Manual Validation Notes + +TEAM ownership: BETA. + +Manual validation performed: +- Confirmed the Messages page loads through the repo Playwright server with the injected Postgres client stub. +- Confirmed seeded Messages categories, emotion profiles, and TTS profiles are available from `/api/messages/*`. +- Confirmed creating a message preserves `message`, `persistence`, `categoryName`, and `emotionProfileName` response shape fields. +- Confirmed creating a message segment preserves `segment`, `messageName`, and `emotionProfileName` response shape fields. +- Confirmed playback behavior remains covered by the Messages Playwright spec. +- Confirmed active Messages SQLite runtime dependency strings are absent from scoped active paths. + +Expected outcome: +- Messages Local API persists through the Postgres service contract. +- Messages tool behavior remains unchanged from the browser user's perspective. +- No `messages.sqlite` file is created or required by the Messages tool tests. + +Out of scope: +- Full samples smoke. +- Broad runtime regression suite. +- Production database connectivity against a live Postgres instance. diff --git a/docs_build/dev/reports/PR_26171_BETA_022-messages-postgres-service-cutover.md b/docs_build/dev/reports/PR_26171_BETA_022-messages-postgres-service-cutover.md new file mode 100644 index 000000000..a7e9d526e --- /dev/null +++ b/docs_build/dev/reports/PR_26171_BETA_022-messages-postgres-service-cutover.md @@ -0,0 +1,39 @@ +# PR_26171_BETA_022-messages-postgres-service-cutover + +## Summary + +TEAM ownership: BETA. + +Branch: `team/BETA/messages`. + +Scope completed: +- Replaced the active Messages Local API persistence service with a Postgres-backed implementation. +- Routed `/api/messages/*` through `createMessagesPostgresService`. +- Preserved the existing Local API response envelopes for Messages, categories, emotion profiles, TTS profiles, and segments. +- Removed the active Messages `node:sqlite`, `DatabaseSync`, `messages.sqlite`, and `GAMEFOUNDRY_MESSAGES_SQLITE_PATH` runtime path. +- Updated targeted Messages Playwright coverage to use an injected Postgres client stub. +- Updated active Messages DML/seed documentation from SQLite service wording to Postgres service wording. + +Compatibility wrapper: +- No wrapper was kept. Active imports were migrated to `messages-postgres-service.mjs`, and targeted text checks found no remaining active reference to `messages-sqlite-service`. + +Postgres authority: +- The runtime service uses the existing `createPostgresConnectionClient` path, which reads `GAMEFOUNDRY_DATABASE_URL` for the server-side database connection. +- Tests inject `createMessagesPostgresClientStub()` to preserve deterministic Local API and Playwright behavior without creating a local SQLite file. + +## Validation + +Passed: +- `node --test --test-name-pattern "Messages Local API" tests/dev-runtime/DbSeedIntegrity.test.mjs` +- `npx playwright test tests/playwright/tools/MessagesTool.spec.mjs --config=codex_playwright_system_chrome.config.cjs --project=playwright` +- `git diff --check` +- Targeted stale SQLite dependency check across Messages service, Local API router, affected tests, helper stubs, and active Messages docs. +- Targeted Postgres wiring check for `GAMEFOUNDRY_DATABASE_URL`, `createPostgresConnectionClient`, and Postgres service references. + +Skipped: +- Full samples smoke: out of scope for this Messages Local API cutover. +- Broad runtime test suite: out of scope; validation was limited to the affected Messages and Local API surfaces. + +## Notes + +Playwright V8 coverage reports were refreshed by the targeted Messages Playwright run. Server-side runtime files are listed as advisory warnings because browser V8 coverage does not collect Node-side Local API modules. diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt index 1a893c6ef..c5f96be84 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt @@ -1,69 +1,36 @@ -# PR_26171_GAMMA_019-admin-workstream-mergeability-recovery changed files +## 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 +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/server/local-api-router.mjs +M tests/dev-runtime/DbSeedIntegrity.test.mjs +A tests/helpers/messagesPostgresClientStub.mjs +M tests/helpers/playwrightRepoServer.mjs +M tests/playwright/tools/MessagesTool.spec.mjs -Base: origin/main 35b04c02ea54da8b13c10354126f1ee8ddd14a89 -Head before recovery merge commit: 1806adbf5df787f7072c6579d23b99bb4257466b +## 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(-) -## 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 1c29de68d..613dbd9ff 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 f5b74d4eb..604c78f93 100644 --- a/docs_build/dev/reports/coverage_changed_js_guardrail.txt +++ b/docs_build/dev/reports/coverage_changed_js_guardrail.txt @@ -6,37 +6,9 @@ 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%) assets/theme-v2/js/account-achievements.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) src/dev-runtime/messages/messages-sqlite-service.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) src/dev-runtime/persistence/mock-db-store.js - 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/seed/server-seed-loader.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(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/server/local-api-router.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) src/shared/toolbox/tool-metadata-inventory.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/controls/controls.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/game-hub/game-hub-api-client.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/game-hub/game-hub.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/game-journey/game-journey.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/idea-board/index.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/text-to-speech/text2speech.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/tools-page-accordions.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(74%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 67/90 -(90%) assets/theme-v2/js/admin-system-health.js - executed lines 227/227; executed functions 28/31 Guardrail warnings: -(0%) assets/theme-v2/js/account-achievements.js - WARNING: changed runtime JS file missing from coverage; advisory only -(0%) src/dev-runtime/messages/messages-sqlite-service.mjs - WARNING: changed runtime JS file missing from coverage; advisory only -(0%) src/dev-runtime/persistence/mock-db-store.js - 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/seed/server-seed-loader.mjs - WARNING: changed runtime JS file missing from coverage; advisory only +(0%) src/dev-runtime/messages/messages-postgres-service.mjs - 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 -(0%) src/shared/toolbox/tool-metadata-inventory.js - WARNING: changed runtime JS file missing from coverage; advisory only -(0%) toolbox/controls/controls.js - WARNING: changed runtime JS file missing from coverage; advisory only -(0%) toolbox/game-hub/game-hub-api-client.js - WARNING: changed runtime JS file missing from coverage; advisory only -(0%) toolbox/game-hub/game-hub.js - WARNING: changed runtime JS file missing from coverage; advisory only -(0%) toolbox/game-journey/game-journey.js - WARNING: changed runtime JS file missing from coverage; advisory only -(0%) toolbox/idea-board/index.js - WARNING: changed runtime JS file missing from coverage; advisory only -(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file missing from coverage; advisory only -(0%) toolbox/text-to-speech/text2speech.js - WARNING: changed runtime JS file missing from coverage; advisory only -(0%) toolbox/tools-page-accordions.js - 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 0d5722de2..38245048a 100644 --- a/docs_build/dev/reports/playwright_v8_coverage_report.txt +++ b/docs_build/dev/reports/playwright_v8_coverage_report.txt @@ -12,93 +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: -(46%) Toolbox Index - exercised 1 runtime JS files +(87%) Toolbox Index - exercised 4 runtime JS files (0%) Tool Template V2 - not exercised by this Playwright run -(78%) Theme V2 Shared JS - exercised 4 runtime JS files +(56%) Theme V2 Shared JS - exercised 2 runtime JS files Changed runtime JS files covered: -(0%) assets/theme-v2/js/account-achievements.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) src/dev-runtime/messages/messages-sqlite-service.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) src/dev-runtime/persistence/mock-db-store.js - 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/seed/server-seed-loader.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only +(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/server/local-api-router.mjs - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) src/shared/toolbox/tool-metadata-inventory.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/controls/controls.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/game-hub/game-hub-api-client.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/game-hub/game-hub.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/game-journey/game-journey.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/idea-board/index.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/messages/messages.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/text-to-speech/text2speech.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(0%) toolbox/tools-page-accordions.js - WARNING: changed runtime JS file was not collected by Playwright V8 coverage; advisory only -(74%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 67/90 -(90%) assets/theme-v2/js/admin-system-health.js - executed lines 227/227; executed functions 28/31 Files with executed line/function counts where available: (36%) src/api/server-api-client.js - executed lines 167/167; executed functions 5/14 -(46%) toolbox/tool-registry-api-client.js - executed lines 155/155; executed functions 12/26 +(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 (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 -(74%) assets/theme-v2/js/gamefoundry-partials.js - executed lines 977/977; executed functions 67/90 -(80%) src/api/admin-owner-navigation.js - executed lines 42/42; executed functions 4/5 -(90%) assets/theme-v2/js/admin-system-health.js - executed lines 227/227; executed functions 28/31 -(91%) assets/theme-v2/js/admin-owner-navigation.js - executed lines 58/58; executed functions 10/11 -(100%) src/api/admin-system-health-api-client.js - executed lines 19/19; executed functions 3/3 +(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 Uncovered or low-coverage changed JS files: -(0%) assets/theme-v2/js/account-achievements.js - WARNING: uncovered changed runtime JS file; advisory only -(0%) src/dev-runtime/messages/messages-sqlite-service.mjs - WARNING: uncovered changed runtime JS file; advisory only -(0%) src/dev-runtime/persistence/mock-db-store.js - 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/seed/server-seed-loader.mjs - WARNING: uncovered changed runtime JS file; advisory only +(0%) src/dev-runtime/messages/messages-postgres-service.mjs - 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 -(0%) src/shared/toolbox/tool-metadata-inventory.js - WARNING: uncovered changed runtime JS file; advisory only -(0%) toolbox/controls/controls.js - WARNING: uncovered changed runtime JS file; advisory only -(0%) toolbox/game-hub/game-hub-api-client.js - WARNING: uncovered changed runtime JS file; advisory only -(0%) toolbox/game-hub/game-hub.js - WARNING: uncovered changed runtime JS file; advisory only -(0%) toolbox/game-journey/game-journey.js - WARNING: uncovered changed runtime JS file; advisory only -(0%) toolbox/idea-board/index.js - WARNING: uncovered changed runtime JS file; advisory only -(0%) toolbox/messages/messages.js - WARNING: uncovered changed runtime JS file; advisory only -(0%) toolbox/text-to-speech/text2speech.js - WARNING: uncovered changed runtime JS file; advisory only -(0%) toolbox/tools-page-accordions.js - WARNING: uncovered changed runtime JS file; advisory only Changed JS files considered: -(0%) assets/theme-v2/js/account-achievements.js - changed JS file not collected as browser runtime coverage -(0%) scripts/run-targeted-test-lanes.mjs - changed JS file not collected as browser runtime coverage -(0%) scripts/start-local-api-server.mjs - changed JS file not collected as browser runtime coverage -(0%) scripts/validate-browser-env-agnostic.mjs - changed JS file not collected as browser runtime coverage -(0%) scripts/validate-local-postgres-runtime.mjs - changed JS file not collected as browser runtime coverage -(0%) src/dev-runtime/messages/messages-sqlite-service.mjs - changed JS file not collected as browser runtime coverage -(0%) src/dev-runtime/persistence/mock-db-store.js - 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/seed/server-seed-loader.mjs - changed JS file not collected as browser runtime coverage +(0%) src/dev-runtime/messages/messages-postgres-service.mjs - 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%) src/shared/toolbox/tool-metadata-inventory.js - changed JS file not collected as browser runtime coverage -(0%) tests/dev-runtime/DevRuntimeBoundary.test.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/dev-runtime/LocalApiStartupLogging.test.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/dev-runtime/ProductDataProviderContractHardening.test.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/dev-runtime/SupabaseProductDataCutover.test.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/playwright/account/AchievementsPage.spec.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/playwright/tools/AdminDbViewer.spec.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/playwright/tools/AdminHealthOperationsPage.spec.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/playwright/tools/GameHubMockRepository.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 -(0%) tests/playwright/tools/IdeaBoardTableNotes.spec.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/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/RootToolsFutureState.spec.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/playwright/tools/TextToSpeechFunctional.spec.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/playwright/tools/ToolboxRoutePages.spec.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/playwright/tools/ToolDisplayModeNavigation.spec.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/playwright/tools/ToolImageRegistry.spec.mjs - changed JS file not collected as browser runtime coverage -(0%) tests/playwright/tools/ToolNavigationPrevNext.spec.mjs - changed JS file not collected as browser runtime coverage -(0%) toolbox/controls/controls.js - changed JS file not collected as browser runtime coverage -(0%) toolbox/game-hub/game-hub-api-client.js - changed JS file not collected as browser runtime coverage -(0%) toolbox/game-hub/game-hub.js - changed JS file not collected as browser runtime coverage -(0%) toolbox/game-journey/game-journey.js - changed JS file not collected as browser runtime coverage -(0%) toolbox/idea-board/index.js - changed JS file not collected as browser runtime coverage -(0%) toolbox/messages/messages.js - changed JS file not collected as browser runtime coverage -(0%) toolbox/text-to-speech/text2speech.js - changed JS file not collected as browser runtime coverage -(0%) toolbox/tools-page-accordions.js - changed JS file not collected as browser runtime coverage -(74%) assets/theme-v2/js/gamefoundry-partials.js - changed JS file with browser V8 coverage -(90%) assets/theme-v2/js/admin-system-health.js - changed JS file with browser V8 coverage diff --git a/src/dev-runtime/messages/messages-postgres-service.mjs b/src/dev-runtime/messages/messages-postgres-service.mjs new file mode 100644 index 000000000..6db58ed91 --- /dev/null +++ b/src/dev-runtime/messages/messages-postgres-service.mjs @@ -0,0 +1,1117 @@ +import { randomBytes } from "node:crypto"; +import { createPostgresConnectionClient } from "../persistence/postgres-connection-client.mjs"; +import { SEED_DB_KEYS } from "../seed/seed-db-keys.mjs"; + +const ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +const SEED_CATEGORY_NAMES = Object.freeze([ + "Dialog", + "Narration", + "Quest", + "Tutorial", + "Combat", + "System", + "Achievement", + "Notification", +]); +const SEED_EMOTION_PROFILES = Object.freeze([ + Object.freeze({ description: "Neutral spoken delivery for general narration or dialog.", name: "Calm", pauseAfterMs: 150, pauseBeforeMs: 0, pitch: 1, rate: 1, volume: 1 }), + Object.freeze({ description: "Fast, alert delivery for warnings and immediate danger.", name: "Urgent", pauseAfterMs: 80, pauseBeforeMs: 0, pitch: 1.08, rate: 1.15, volume: 1 }), + Object.freeze({ description: "Quiet delivery for secret, stealth, or intimate lines.", name: "Whisper", pauseAfterMs: 180, pauseBeforeMs: 80, pitch: 0.95, rate: 0.9, volume: 0.55 }), + Object.freeze({ description: "Forceful delivery for conflict or frustration.", name: "Angry", pauseAfterMs: 90, pauseBeforeMs: 0, pitch: 0.98, rate: 1.1, volume: 1 }), + Object.freeze({ description: "Bright delivery for reveals, wins, and high-energy moments.", name: "Excited", pauseAfterMs: 100, pauseBeforeMs: 0, pitch: 1.12, rate: 1.12, volume: 1 }), + Object.freeze({ description: "Soft delivery for loss, regret, or reflective moments.", name: "Sad", pauseAfterMs: 220, pauseBeforeMs: 100, pitch: 0.9, rate: 0.85, volume: 0.8 }), + Object.freeze({ description: "Measured delivery for suspense, hidden lore, or strange events.", name: "Mysterious", pauseAfterMs: 260, pauseBeforeMs: 120, pitch: 0.92, rate: 0.88, volume: 0.85 }), +]); +const SEED_TTS_PROFILES = Object.freeze([ + Object.freeze({ + description: "Balanced local browser playback option until authored TTS profiles are available.", + language: "en-US", + name: "Default Balanced TTS Profile", + pitch: 1, + providerKey: "browser-speech", + rate: 1, + voiceName: "", + volume: 1, + }), + Object.freeze({ + description: "Narration-focused preview configuration for future spoken story text.", + language: "en-US", + name: "Narration Preview", + pitch: 0.95, + providerKey: "browser-speech", + rate: 0.9, + voiceName: "", + volume: 0.9, + }), +]); + +const MESSAGES_POSTGRES_SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS messages_categories ( + key text PRIMARY KEY, + "name" text NOT NULL UNIQUE, + "active" boolean NOT NULL DEFAULT true, + "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 TABLE IF NOT EXISTS messages_emotion_profiles ( + key text PRIMARY KEY, + "name" text NOT NULL UNIQUE, + "description" text NOT NULL DEFAULT '', + "volume" numeric NOT NULL DEFAULT 1, + "pitch" numeric NOT NULL DEFAULT 1, + "rate" numeric NOT NULL DEFAULT 1, + "pauseBeforeMs" integer NOT NULL DEFAULT 0, + "pauseAfterMs" integer NOT NULL DEFAULT 0, + "active" boolean NOT NULL DEFAULT true, + "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 TABLE IF NOT EXISTS messages_records ( + key text PRIMARY KEY, + "name" text NOT NULL, + "categoryKey" text NOT NULL REFERENCES messages_categories(key), + "emotionProfileKey" text NOT NULL REFERENCES messages_emotion_profiles(key), + "messageText" text NOT NULL, + "notes" text NOT NULL DEFAULT '', + "active" boolean NOT NULL DEFAULT true, + "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 TABLE IF NOT EXISTS messages_tts_profiles ( + key text PRIMARY KEY, + "name" text NOT NULL UNIQUE, + "description" text NOT NULL DEFAULT '', + "providerKey" text NOT NULL, + "voiceName" text NOT NULL DEFAULT '', + "language" text NOT NULL, + "volume" numeric NOT NULL DEFAULT 1, + "pitch" numeric NOT NULL DEFAULT 1, + "rate" numeric NOT NULL DEFAULT 1, + "active" boolean NOT NULL DEFAULT true, + "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 TABLE IF NOT EXISTS messages_segments ( + key text PRIMARY KEY, + "messageKey" text NOT NULL REFERENCES messages_records(key), + "emotionProfileKey" text NOT NULL REFERENCES messages_emotion_profiles(key), + "segmentText" text NOT NULL, + "displayOrder" integer NOT NULL, + "active" boolean NOT NULL DEFAULT true, + "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_messages_records_categorykey ON messages_records ("categoryKey"); +CREATE INDEX IF NOT EXISTS idx_messages_records_emotionprofilekey ON messages_records ("emotionProfileKey"); +CREATE INDEX IF NOT EXISTS idx_messages_records_createdby ON messages_records ("createdBy"); +CREATE INDEX IF NOT EXISTS idx_messages_records_updatedby ON messages_records ("updatedBy"); +CREATE INDEX IF NOT EXISTS idx_messages_segments_messagekey ON messages_segments ("messageKey"); +CREATE INDEX IF NOT EXISTS idx_messages_segments_emotionprofilekey ON messages_segments ("emotionProfileKey"); +CREATE INDEX IF NOT EXISTS idx_messages_segments_order ON messages_segments ("messageKey", "displayOrder"); +CREATE INDEX IF NOT EXISTS idx_messages_segments_createdby ON messages_segments ("createdBy"); +CREATE INDEX IF NOT EXISTS idx_messages_segments_updatedby ON messages_segments ("updatedBy"); +CREATE INDEX IF NOT EXISTS idx_messages_tts_profiles_providerkey ON messages_tts_profiles ("providerKey"); +CREATE INDEX IF NOT EXISTS idx_messages_tts_profiles_createdby ON messages_tts_profiles ("createdBy"); +CREATE INDEX IF NOT EXISTS idx_messages_tts_profiles_updatedby ON messages_tts_profiles ("updatedBy"); +`; + +function encodeUlidPart(value, length) { + let remaining = BigInt(value); + let encoded = ""; + for (let index = 0; index < length; index += 1) { + encoded = ULID_ALPHABET[Number(remaining % 32n)] + encoded; + remaining /= 32n; + } + return encoded; +} + +function createUlid() { + const timePart = encodeUlidPart(Date.now(), 10); + const randomPart = Array.from(randomBytes(16), (byte) => ULID_ALPHABET[byte % 32]).join(""); + return `${timePart}${randomPart}`; +} + +function timestamp() { + return new Date().toISOString(); +} + +function httpError(message, statusCode = 400) { + const error = new Error(message); + error.statusCode = statusCode; + return error; +} + +function normalizeText(value) { + return typeof value === "string" ? value : ""; +} + +function normalizeName(value, label) { + const normalized = normalizeText(value).trim(); + if (!normalized) { + throw httpError(`${label} is required.`); + } + return normalized; +} + +function normalizeActive(value, fallback = true) { + return value === undefined ? fallback : value !== false; +} + +function normalizeNumber(value, fallback) { + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : fallback; +} + +function emotionSettingKey(value) { + return normalizeText(value) + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "neutral"; +} + +function normalizeInteger(value, fallback) { + const numberValue = Number(value); + return Number.isInteger(numberValue) ? numberValue : fallback; +} + +function normalizeActorKey(actorKey) { + const normalized = normalizeText(actorKey).trim(); + return normalized || SEED_DB_KEYS.users.forgeBot; +} + +function activeFromDatabase(value) { + if (typeof value === "boolean") { + return value; + } + return Number(value) !== 0; +} + +function cloneRows(rows) { + return JSON.parse(JSON.stringify(Array.isArray(rows) ? rows : [])); +} + +function compareName(left, right) { + return normalizeText(left?.name).localeCompare(normalizeText(right?.name), undefined, { sensitivity: "base" }); +} + +function queryForKey(key) { + return `select=*&key=eq.${encodeURIComponent(key)}`; +} + +function messageRecordFromRow(row, { categoryName = "", emotionProfileName = "" } = {}) { + return { + active: activeFromDatabase(row.active), + categoryKey: row.categoryKey, + categoryName, + createdAt: row.createdAt, + createdBy: row.createdBy, + emotionProfileKey: row.emotionProfileKey, + emotionProfileName, + key: row.key, + messageText: row.messageText, + name: row.name, + notes: row.notes || "", + updatedAt: row.updatedAt, + updatedBy: row.updatedBy, + }; +} + +function categoryFromRow(row) { + return { + active: activeFromDatabase(row.active), + createdAt: row.createdAt, + createdBy: row.createdBy, + key: row.key, + name: row.name, + status: activeFromDatabase(row.active) ? "Active" : "Inactive", + updatedAt: row.updatedAt, + updatedBy: row.updatedBy, + }; +} + +function emotionProfileFromRow(row, usage = {}) { + const messageUsageCount = Number(usage.messageUsageCount || 0); + const segmentUsageCount = Number(usage.segmentUsageCount || 0); + return { + active: activeFromDatabase(row.active), + createdAt: row.createdAt, + createdBy: row.createdBy, + description: row.description || "", + key: row.key, + name: row.name, + pauseAfterMs: Number(row.pauseAfterMs), + pauseBeforeMs: Number(row.pauseBeforeMs), + pitch: Number(row.pitch), + rate: Number(row.rate), + references: Array.isArray(usage.references) ? usage.references : [], + messageUsageCount, + segmentUsageCount, + status: activeFromDatabase(row.active) ? "Active" : "Inactive", + updatedAt: row.updatedAt, + updatedBy: row.updatedBy, + usageCount: messageUsageCount + segmentUsageCount, + volume: Number(row.volume), + }; +} + +function ttsEmotionSettingFromEmotionProfile(profile) { + return { + active: profile.active !== false, + emotion: emotionSettingKey(profile.name), + emotionLabel: profile.name, + pitch: Number(profile.pitch), + rate: Number(profile.rate), + ssmlLikePreset: "normal", + volume: Number(profile.volume), + }; +} + +function ttsProfileFromRow(row, emotionSettings = []) { + return { + active: activeFromDatabase(row.active), + createdAt: row.createdAt, + createdBy: row.createdBy, + description: row.description || "", + emotionSettings, + key: row.key, + language: row.language, + name: row.name, + pitch: Number(row.pitch), + providerKey: row.providerKey, + rate: Number(row.rate), + status: activeFromDatabase(row.active) ? "Active" : "Inactive", + updatedAt: row.updatedAt, + updatedBy: row.updatedBy, + voiceName: row.voiceName || "", + volume: Number(row.volume), + }; +} + +function messageSegmentFromRow(row, { emotionProfileName = "", messageName = "" } = {}) { + return { + active: activeFromDatabase(row.active), + createdAt: row.createdAt, + createdBy: row.createdBy, + displayOrder: Number(row.displayOrder), + emotionProfileKey: row.emotionProfileKey, + emotionProfileName, + key: row.key, + messageKey: row.messageKey, + messageName, + segmentText: row.segmentText, + updatedAt: row.updatedAt, + updatedBy: row.updatedBy, + }; +} + +function normalizeRequiredInteger(value, label) { + if (value === undefined || value === null || String(value).trim() === "") { + throw httpError(`${label} is required.`); + } + const numberValue = Number(value); + if (!Number.isInteger(numberValue)) { + throw httpError(`${label} must be a whole number.`); + } + if (numberValue < 1) { + throw httpError(`${label} must be 1 or greater.`); + } + return numberValue; +} + +export class MessagesPostgresService { + constructor({ + env = process.env, + postgresClient = null, + } = {}) { + this.env = env; + this.postgresClient = postgresClient; + this.readyPromise = null; + } + + client() { + if (!this.postgresClient) { + this.postgresClient = createPostgresConnectionClient({ env: this.env }); + } + return this.postgresClient; + } + + async ensureReady() { + if (!this.readyPromise) { + this.readyPromise = this.initialize(); + } + return this.readyPromise; + } + + async initialize() { + await this.client().query(MESSAGES_POSTGRES_SCHEMA_SQL); + await this.seedDefaults(); + } + + close() { + this.postgresClient?.close?.(); + } + + async tableRows(tableName) { + return cloneRows(await this.client().requestTable(tableName, { method: "GET", query: "select=*" })); + } + + async upsertRow(tableName, row) { + const rows = await this.client().requestTable(tableName, { body: row, method: "POST" }); + return cloneRows(rows)[0] || row; + } + + async patchRow(tableName, key, row) { + const rows = await this.client().requestTable(tableName, { + body: row, + method: "PATCH", + query: queryForKey(key), + }); + return cloneRows(rows)[0] || null; + } + + async rowByKey(tableName, key) { + const rows = await this.client().requestTable(tableName, { method: "GET", query: queryForKey(key) }); + return cloneRows(rows)[0] || null; + } + + async seedDefaults() { + for (const name of SEED_CATEGORY_NAMES) { + const existing = await this.findCategoryByNameRaw(name); + if (!existing) { + await this.insertCategory({ + active: true, + actorKey: SEED_DB_KEYS.users.forgeBot, + name, + }, { skipEnsure: true }); + } + } + for (const profile of SEED_EMOTION_PROFILES) { + const existing = await this.findEmotionProfileByNameRaw(profile.name); + if (!existing) { + await this.insertEmotionProfile({ + ...profile, + active: true, + actorKey: SEED_DB_KEYS.users.forgeBot, + }, { skipEnsure: true }); + } + } + for (const profile of SEED_TTS_PROFILES) { + const existing = await this.findTtsProfileByNameRaw(profile.name); + if (!existing) { + await this.insertTtsProfile({ + ...profile, + active: true, + actorKey: SEED_DB_KEYS.users.forgeBot, + }, { skipEnsure: true }); + } + } + } + + persistenceSummary() { + return { + engine: "Postgres", + owner: "messages", + storage: "server-owned", + }; + } + + async listCategories() { + await this.ensureReady(); + return (await this.tableRows("messages_categories")).sort(compareName).map(categoryFromRow); + } + + async getCategory(key) { + await this.ensureReady(); + const row = await this.rowByKey("messages_categories", key); + if (!row) { + throw httpError("Message category was not found.", 404); + } + return categoryFromRow(row); + } + + async findCategoryByNameRaw(name) { + const normalized = normalizeText(name).trim().toLowerCase(); + if (!normalized) { + return null; + } + return (await this.tableRows("messages_categories")).find((row) => normalizeText(row.name).trim().toLowerCase() === normalized) || null; + } + + async findCategoryByName(name) { + await this.ensureReady(); + const row = await this.findCategoryByNameRaw(name); + return row ? categoryFromRow(row) : null; + } + + async insertCategory({ active = true, actorKey, name }, { skipEnsure = false } = {}) { + if (!skipEnsure) { + await this.ensureReady(); + } + const key = createUlid(); + const now = timestamp(); + const actor = normalizeActorKey(actorKey); + await this.upsertRow("messages_categories", { + active: normalizeActive(active, true), + createdAt: now, + createdBy: actor, + key, + name: normalizeName(name, "Category name"), + updatedAt: now, + updatedBy: actor, + }); + const row = await this.rowByKey("messages_categories", key); + return categoryFromRow(row); + } + + async createCategory(input = {}, actorKey = "") { + const name = normalizeName(input.name, "Category name"); + const existing = await this.findCategoryByName(name); + if (existing) { + throw httpError(`Category ${name} already exists.`); + } + return this.insertCategory({ + active: normalizeActive(input.active, true), + actorKey, + name, + }); + } + + async updateCategory(key, input = {}, actorKey = "") { + const existing = await this.getCategory(key); + const name = input.name === undefined ? existing.name : normalizeName(input.name, "Category name"); + const duplicate = await this.findCategoryByName(name); + if (duplicate && duplicate.key !== key) { + throw httpError(`Category ${name} already exists.`); + } + await this.patchRow("messages_categories", key, { + active: normalizeActive(input.active, existing.active), + name, + updatedAt: timestamp(), + updatedBy: normalizeActorKey(actorKey), + }); + return this.getCategory(key); + } + + async listEmotionProfiles() { + await this.ensureReady(); + const rows = (await this.tableRows("messages_emotion_profiles")).sort(compareName); + return Promise.all(rows.map(async (row) => emotionProfileFromRow(row, await this.emotionProfileUsage(row.key)))); + } + + async getEmotionProfile(key) { + await this.ensureReady(); + const row = await this.rowByKey("messages_emotion_profiles", key); + if (!row) { + throw httpError("Emotion profile was not found.", 404); + } + return emotionProfileFromRow(row, await this.emotionProfileUsage(row.key)); + } + + async findEmotionProfileByNameRaw(name) { + const normalized = normalizeText(name).trim().toLowerCase(); + if (!normalized) { + return null; + } + return (await this.tableRows("messages_emotion_profiles")).find((row) => normalizeText(row.name).trim().toLowerCase() === normalized) || null; + } + + async findEmotionProfileByName(name) { + await this.ensureReady(); + const row = await this.findEmotionProfileByNameRaw(name); + return row ? emotionProfileFromRow(row) : null; + } + + async emotionProfileUsage(key) { + const messages = await this.tableRows("messages_records"); + const segments = await this.tableRows("messages_segments"); + const messageNames = new Map(messages.map((message) => [message.key, message.name])); + const messageReferences = messages + .filter((message) => message.emotionProfileKey === key) + .sort(compareName) + .map((row) => ({ + key: row.key, + label: row.name, + type: "message", + })); + const segmentReferences = segments + .filter((segment) => segment.emotionProfileKey === key) + .sort((left, right) => { + const leftName = messageNames.get(left.messageKey) || ""; + const rightName = messageNames.get(right.messageKey) || ""; + return leftName.localeCompare(rightName, undefined, { sensitivity: "base" }) + || Number(left.displayOrder) - Number(right.displayOrder) + || String(left.key).localeCompare(String(right.key)); + }) + .map((row) => ({ + displayOrder: Number(row.displayOrder), + key: row.key, + label: `${messageNames.get(row.messageKey) || "Unknown Message"} segment ${row.displayOrder}`, + messageKey: row.messageKey, + preview: normalizeText(row.segmentText).slice(0, 80), + type: "segment", + })); + return { + messageUsageCount: messageReferences.length, + references: [ + ...messageReferences, + ...segmentReferences, + ], + segmentUsageCount: segmentReferences.length, + }; + } + + async insertEmotionProfile(input = {}, { skipEnsure = false } = {}) { + if (!skipEnsure) { + await this.ensureReady(); + } + const key = createUlid(); + const now = timestamp(); + const actor = normalizeActorKey(input.actorKey); + await this.upsertRow("messages_emotion_profiles", { + active: normalizeActive(input.active, true), + createdAt: now, + createdBy: actor, + description: normalizeText(input.description), + key, + name: normalizeName(input.name, "Emotion profile name"), + pauseAfterMs: normalizeInteger(input.pauseAfterMs, 0), + pauseBeforeMs: normalizeInteger(input.pauseBeforeMs, 0), + pitch: normalizeNumber(input.pitch, 1), + rate: normalizeNumber(input.rate, 1), + updatedAt: now, + updatedBy: actor, + volume: normalizeNumber(input.volume, 1), + }); + const row = await this.rowByKey("messages_emotion_profiles", key); + return emotionProfileFromRow(row, await this.emotionProfileUsage(key)); + } + + async createEmotionProfile(input = {}, actorKey = "") { + const name = normalizeName(input.name, "Emotion profile name"); + const existing = await this.findEmotionProfileByName(name); + if (existing) { + throw httpError(`Emotion profile ${name} already exists.`); + } + return this.insertEmotionProfile({ + ...input, + actorKey, + name, + }); + } + + async updateEmotionProfile(key, input = {}, actorKey = "") { + const existing = await this.getEmotionProfile(key); + const name = input.name === undefined ? existing.name : normalizeName(input.name, "Emotion profile name"); + const duplicate = await this.findEmotionProfileByName(name); + if (duplicate && duplicate.key !== key) { + throw httpError(`Emotion profile ${name} already exists.`); + } + const active = normalizeActive(input.active, existing.active); + if (existing.active && !active && existing.usageCount > 0) { + throw httpError("Emotion profile is referenced by messages or segments. Reassign those references before deactivating this emotion profile."); + } + await this.patchRow("messages_emotion_profiles", key, { + active, + description: input.description === undefined ? existing.description : normalizeText(input.description), + name, + pauseAfterMs: normalizeInteger(input.pauseAfterMs, existing.pauseAfterMs), + pauseBeforeMs: normalizeInteger(input.pauseBeforeMs, existing.pauseBeforeMs), + pitch: normalizeNumber(input.pitch, existing.pitch), + rate: normalizeNumber(input.rate, existing.rate), + updatedAt: timestamp(), + updatedBy: normalizeActorKey(actorKey), + volume: normalizeNumber(input.volume, existing.volume), + }); + return this.getEmotionProfile(key); + } + + async listTtsProfiles() { + await this.ensureReady(); + const emotionSettings = (await this.tableRows("messages_emotion_profiles")) + .map((profileRow) => emotionProfileFromRow(profileRow)) + .filter((profile) => profile.active !== false) + .map(ttsEmotionSettingFromEmotionProfile); + return (await this.tableRows("messages_tts_profiles")).sort(compareName).map((row) => ttsProfileFromRow(row, emotionSettings)); + } + + async getTtsProfile(key) { + await this.ensureReady(); + const row = await this.rowByKey("messages_tts_profiles", key); + if (!row) { + throw httpError("TTS profile was not found.", 404); + } + const emotionSettings = (await this.tableRows("messages_emotion_profiles")) + .map((profileRow) => emotionProfileFromRow(profileRow)) + .filter((profile) => profile.active !== false) + .map(ttsEmotionSettingFromEmotionProfile); + return ttsProfileFromRow(row, emotionSettings); + } + + async findTtsProfileByNameRaw(name) { + const normalized = normalizeText(name).trim().toLowerCase(); + if (!normalized) { + return null; + } + return (await this.tableRows("messages_tts_profiles")).find((row) => normalizeText(row.name).trim().toLowerCase() === normalized) || null; + } + + async findTtsProfileByName(name) { + await this.ensureReady(); + const row = await this.findTtsProfileByNameRaw(name); + return row ? ttsProfileFromRow(row) : null; + } + + normalizeTtsProfileInput(input = {}, existing = null) { + const name = input.name === undefined && existing ? existing.name : normalizeName(input.name, "TTS profile name"); + return { + active: normalizeActive(input.active, existing ? existing.active : true), + description: input.description === undefined && existing ? existing.description : normalizeText(input.description), + language: input.language === undefined && existing ? existing.language : normalizeName(input.language, "TTS profile language"), + name, + pitch: normalizeNumber(input.pitch, existing ? existing.pitch : 1), + providerKey: input.providerKey === undefined && existing ? existing.providerKey : normalizeName(input.providerKey, "TTS provider key"), + rate: normalizeNumber(input.rate, existing ? existing.rate : 1), + voiceName: input.voiceName === undefined && existing ? existing.voiceName : normalizeText(input.voiceName), + volume: normalizeNumber(input.volume, existing ? existing.volume : 1), + }; + } + + async insertTtsProfile(input = {}, { skipEnsure = false } = {}) { + if (!skipEnsure) { + await this.ensureReady(); + } + const values = this.normalizeTtsProfileInput(input); + const key = createUlid(); + const now = timestamp(); + const actor = normalizeActorKey(input.actorKey); + await this.upsertRow("messages_tts_profiles", { + active: values.active, + createdAt: now, + createdBy: actor, + description: values.description, + key, + language: values.language, + name: values.name, + pitch: values.pitch, + providerKey: values.providerKey, + rate: values.rate, + updatedAt: now, + updatedBy: actor, + voiceName: values.voiceName, + volume: values.volume, + }); + const row = await this.rowByKey("messages_tts_profiles", key); + const emotionSettings = (await this.tableRows("messages_emotion_profiles")) + .map((profileRow) => emotionProfileFromRow(profileRow)) + .filter((profile) => profile.active !== false) + .map(ttsEmotionSettingFromEmotionProfile); + return ttsProfileFromRow(row, emotionSettings); + } + + async createTtsProfile(input = {}, actorKey = "") { + const values = this.normalizeTtsProfileInput(input); + const existing = await this.findTtsProfileByName(values.name); + if (existing) { + throw httpError(`TTS profile ${values.name} already exists.`); + } + return this.insertTtsProfile({ + ...values, + actorKey, + }); + } + + async updateTtsProfile(key, input = {}, actorKey = "") { + const existing = await this.getTtsProfile(key); + const values = this.normalizeTtsProfileInput(input, existing); + const duplicate = await this.findTtsProfileByName(values.name); + if (duplicate && duplicate.key !== key) { + throw httpError(`TTS profile ${values.name} already exists.`); + } + await this.patchRow("messages_tts_profiles", key, { + active: values.active, + description: values.description, + language: values.language, + name: values.name, + pitch: values.pitch, + providerKey: values.providerKey, + rate: values.rate, + updatedAt: timestamp(), + updatedBy: normalizeActorKey(actorKey), + voiceName: values.voiceName, + volume: values.volume, + }); + return this.getTtsProfile(key); + } + + async listMessages() { + await this.ensureReady(); + const categories = new Map((await this.tableRows("messages_categories")).map((row) => [row.key, row.name])); + const emotions = new Map((await this.tableRows("messages_emotion_profiles")).map((row) => [row.key, row.name])); + return (await this.tableRows("messages_records")) + .sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)) || compareName(left, right)) + .map((row) => messageRecordFromRow(row, { + categoryName: categories.get(row.categoryKey) || "", + emotionProfileName: emotions.get(row.emotionProfileKey) || "", + })); + } + + async getMessage(key) { + await this.ensureReady(); + const row = await this.rowByKey("messages_records", key); + if (!row) { + throw httpError("Message was not found.", 404); + } + const category = await this.rowByKey("messages_categories", row.categoryKey); + const emotion = await this.rowByKey("messages_emotion_profiles", row.emotionProfileKey); + return messageRecordFromRow(row, { + categoryName: category?.name || "", + emotionProfileName: emotion?.name || "", + }); + } + + async assertActiveCategory(key) { + const category = await this.getCategory(key); + if (!category.active) { + throw httpError("Category is inactive. Choose an active category before saving a message."); + } + return category; + } + + async defaultMessageCategoryKey() { + const dialog = await this.findCategoryByName("Dialog"); + const fallback = dialog || (await this.listCategories())[0]; + if (!fallback) { + throw httpError("Message category seed is unavailable. Restart the Local API runtime."); + } + return fallback.key; + } + + async assertActiveEmotionProfile(key) { + const profile = await this.getEmotionProfile(key); + if (!profile.active) { + throw httpError("Emotion profile is inactive. Choose an active emotion profile before saving a message."); + } + return profile; + } + + async normalizeMessageInput(input = {}, existing = null) { + const name = input.name === undefined && existing ? existing.name : normalizeName(input.name, "Message name"); + const categoryKey = normalizeText(input.categoryKey === undefined && existing ? existing.categoryKey : input.categoryKey).trim() + || await this.defaultMessageCategoryKey(); + const emotionProfileKey = normalizeText(input.emotionProfileKey === undefined && existing ? existing.emotionProfileKey : input.emotionProfileKey).trim(); + const messageText = input.messageText === undefined && existing ? existing.messageText : normalizeText(input.messageText); + if (!emotionProfileKey) { + throw httpError("Emotion profile is required."); + } + if (!messageText.trim()) { + throw httpError("Message text is required."); + } + await this.assertActiveCategory(categoryKey); + await this.assertActiveEmotionProfile(emotionProfileKey); + return { + active: normalizeActive(input.active, existing ? existing.active : true), + categoryKey, + emotionProfileKey, + messageText, + name, + notes: input.notes === undefined && existing ? existing.notes : normalizeText(input.notes), + }; + } + + async createMessage(input = {}, actorKey = "") { + await this.ensureReady(); + const values = await this.normalizeMessageInput(input); + const key = createUlid(); + const now = timestamp(); + const actor = normalizeActorKey(actorKey); + await this.upsertRow("messages_records", { + active: values.active, + categoryKey: values.categoryKey, + createdAt: now, + createdBy: actor, + emotionProfileKey: values.emotionProfileKey, + key, + messageText: values.messageText, + name: values.name, + notes: values.notes, + updatedAt: now, + updatedBy: actor, + }); + return this.getMessage(key); + } + + async updateMessage(key, input = {}, actorKey = "") { + const existing = await this.getMessage(key); + const values = await this.normalizeMessageInput(input, existing); + await this.patchRow("messages_records", key, { + active: values.active, + categoryKey: values.categoryKey, + emotionProfileKey: values.emotionProfileKey, + messageText: values.messageText, + name: values.name, + notes: values.notes, + updatedAt: timestamp(), + updatedBy: normalizeActorKey(actorKey), + }); + return this.getMessage(key); + } + + async listMessageSegments() { + await this.ensureReady(); + const messages = new Map((await this.tableRows("messages_records")).map((row) => [row.key, row.name])); + const emotions = new Map((await this.tableRows("messages_emotion_profiles")).map((row) => [row.key, row.name])); + return (await this.tableRows("messages_segments")) + .sort((left, right) => String(left.messageKey).localeCompare(String(right.messageKey)) + || Number(left.displayOrder) - Number(right.displayOrder) + || String(left.createdAt).localeCompare(String(right.createdAt)) + || String(left.key).localeCompare(String(right.key))) + .map((row) => messageSegmentFromRow(row, { + emotionProfileName: emotions.get(row.emotionProfileKey) || "", + messageName: messages.get(row.messageKey) || "", + })); + } + + async getMessageSegment(key) { + await this.ensureReady(); + const row = await this.rowByKey("messages_segments", key); + if (!row) { + throw httpError("Message segment was not found.", 404); + } + const message = await this.rowByKey("messages_records", row.messageKey); + const emotion = await this.rowByKey("messages_emotion_profiles", row.emotionProfileKey); + return messageSegmentFromRow(row, { + emotionProfileName: emotion?.name || "", + messageName: message?.name || "", + }); + } + + async normalizeMessageSegmentInput(input = {}, existing = null) { + const messageKey = normalizeText(input.messageKey === undefined && existing ? existing.messageKey : input.messageKey).trim(); + const emotionProfileKey = normalizeText(input.emotionProfileKey === undefined && existing ? existing.emotionProfileKey : input.emotionProfileKey).trim(); + const segmentText = input.segmentText === undefined && existing ? existing.segmentText : normalizeText(input.segmentText); + const displayOrder = normalizeRequiredInteger( + input.displayOrder === undefined && existing ? existing.displayOrder : input.displayOrder, + "Display order", + ); + if (!messageKey) { + throw httpError("Message is required."); + } + if (!emotionProfileKey) { + throw httpError("Emotion profile is required."); + } + if (!segmentText.trim()) { + throw httpError("Segment text is required."); + } + await this.getMessage(messageKey); + await this.assertActiveEmotionProfile(emotionProfileKey); + return { + active: normalizeActive(input.active, existing ? existing.active : true), + displayOrder, + emotionProfileKey, + messageKey, + segmentText, + }; + } + + async createMessageSegment(input = {}, actorKey = "") { + await this.ensureReady(); + const values = await this.normalizeMessageSegmentInput(input); + const key = createUlid(); + const now = timestamp(); + const actor = normalizeActorKey(actorKey); + await this.upsertRow("messages_segments", { + active: values.active, + createdAt: now, + createdBy: actor, + displayOrder: values.displayOrder, + emotionProfileKey: values.emotionProfileKey, + key, + messageKey: values.messageKey, + segmentText: values.segmentText, + updatedAt: now, + updatedBy: actor, + }); + return this.getMessageSegment(key); + } + + async updateMessageSegment(key, input = {}, actorKey = "") { + const existing = await this.getMessageSegment(key); + const values = await this.normalizeMessageSegmentInput(input, existing); + await this.patchRow("messages_segments", key, { + active: values.active, + displayOrder: values.displayOrder, + emotionProfileKey: values.emotionProfileKey, + messageKey: values.messageKey, + segmentText: values.segmentText, + updatedAt: timestamp(), + updatedBy: normalizeActorKey(actorKey), + }); + return this.getMessageSegment(key); + } +} + +export function createMessagesPostgresService(options = {}) { + return new MessagesPostgresService(options); +} + +export async function handleMessagesApiContract({ + actorKey = "", + body = {}, + method = "GET", + parts = [], + service, +} = {}) { + if (!service) { + throw httpError("Messages Postgres service is not configured.", 500); + } + const normalizedMethod = String(method || "GET").toUpperCase(); + const resource = parts[0] || ""; + const key = parts[1] || ""; + + if (resource === "messages") { + if (normalizedMethod === "GET" && !key) { + return { + messages: await service.listMessages(), + persistence: service.persistenceSummary(), + }; + } + if (normalizedMethod === "GET" && key) { + return { + message: await service.getMessage(key), + persistence: service.persistenceSummary(), + }; + } + if (normalizedMethod === "POST" && !key) { + return { + message: await service.createMessage(body, actorKey), + persistence: service.persistenceSummary(), + }; + } + if (normalizedMethod === "POST" && key) { + return { + message: await service.updateMessage(key, body, actorKey), + persistence: service.persistenceSummary(), + }; + } + } + + if (resource === "emotion-profiles") { + if (normalizedMethod === "GET" && !key) { + return { + emotionProfiles: await service.listEmotionProfiles(), + persistence: service.persistenceSummary(), + }; + } + if (normalizedMethod === "GET" && key) { + return { + emotionProfile: await service.getEmotionProfile(key), + persistence: service.persistenceSummary(), + }; + } + if (normalizedMethod === "POST" && !key) { + return { + emotionProfile: await service.createEmotionProfile(body, actorKey), + persistence: service.persistenceSummary(), + }; + } + if (normalizedMethod === "POST" && key) { + return { + emotionProfile: await service.updateEmotionProfile(key, body, actorKey), + persistence: service.persistenceSummary(), + }; + } + } + + if (resource === "categories") { + if (normalizedMethod === "GET" && !key) { + return { + categories: await service.listCategories(), + persistence: service.persistenceSummary(), + }; + } + if (normalizedMethod === "POST" && !key) { + return { + category: await service.createCategory(body, actorKey), + persistence: service.persistenceSummary(), + }; + } + if (normalizedMethod === "POST" && key) { + return { + category: await service.updateCategory(key, body, actorKey), + persistence: service.persistenceSummary(), + }; + } + } + + if (resource === "tts-profiles") { + if (normalizedMethod === "GET" && !key) { + return { + persistence: service.persistenceSummary(), + ttsProfiles: await service.listTtsProfiles(), + }; + } + if (normalizedMethod === "GET" && key) { + return { + persistence: service.persistenceSummary(), + ttsProfile: await service.getTtsProfile(key), + }; + } + if (normalizedMethod === "POST" && !key) { + return { + persistence: service.persistenceSummary(), + ttsProfile: await service.createTtsProfile(body, actorKey), + }; + } + if (normalizedMethod === "POST" && key) { + return { + persistence: service.persistenceSummary(), + ttsProfile: await service.updateTtsProfile(key, body, actorKey), + }; + } + } + + if (resource === "segments") { + if (normalizedMethod === "GET" && !key) { + return { + persistence: service.persistenceSummary(), + segments: await service.listMessageSegments(), + }; + } + if (normalizedMethod === "GET" && key) { + return { + persistence: service.persistenceSummary(), + segment: await service.getMessageSegment(key), + }; + } + if (normalizedMethod === "POST" && !key) { + return { + persistence: service.persistenceSummary(), + segment: await service.createMessageSegment(body, actorKey), + }; + } + if (normalizedMethod === "POST" && key) { + return { + persistence: service.persistenceSummary(), + segment: await service.updateMessageSegment(key, body, actorKey), + }; + } + } + + throw httpError(`Unknown Messages API route: ${normalizedMethod} /api/messages/${parts.join("/")}.`, 404); +} diff --git a/src/dev-runtime/messages/messages-sqlite-service.mjs b/src/dev-runtime/messages/messages-sqlite-service.mjs deleted file mode 100644 index 6d3894a85..000000000 --- a/src/dev-runtime/messages/messages-sqlite-service.mjs +++ /dev/null @@ -1,1118 +0,0 @@ -import { randomBytes } from "node:crypto"; -import { mkdirSync } from "node:fs"; -import path from "node:path"; -import process from "node:process"; -import { DatabaseSync } from "node:sqlite"; -import { SEED_DB_KEYS } from "../seed/seed-db-keys.mjs"; - -const MESSAGES_SQLITE_PATH_ENV_KEY = "GAMEFOUNDRY_MESSAGES_SQLITE_PATH"; -const ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; -const SEED_CATEGORY_NAMES = Object.freeze([ - "Dialog", - "Narration", - "Quest", - "Tutorial", - "Combat", - "System", - "Achievement", - "Notification", -]); -const SEED_EMOTION_PROFILES = Object.freeze([ - Object.freeze({ description: "Neutral spoken delivery for general narration or dialog.", name: "Calm", pauseAfterMs: 150, pauseBeforeMs: 0, pitch: 1, rate: 1, volume: 1 }), - Object.freeze({ description: "Fast, alert delivery for warnings and immediate danger.", name: "Urgent", pauseAfterMs: 80, pauseBeforeMs: 0, pitch: 1.08, rate: 1.15, volume: 1 }), - Object.freeze({ description: "Quiet delivery for secret, stealth, or intimate lines.", name: "Whisper", pauseAfterMs: 180, pauseBeforeMs: 80, pitch: 0.95, rate: 0.9, volume: 0.55 }), - Object.freeze({ description: "Forceful delivery for conflict or frustration.", name: "Angry", pauseAfterMs: 90, pauseBeforeMs: 0, pitch: 0.98, rate: 1.1, volume: 1 }), - Object.freeze({ description: "Bright delivery for reveals, wins, and high-energy moments.", name: "Excited", pauseAfterMs: 100, pauseBeforeMs: 0, pitch: 1.12, rate: 1.12, volume: 1 }), - Object.freeze({ description: "Soft delivery for loss, regret, or reflective moments.", name: "Sad", pauseAfterMs: 220, pauseBeforeMs: 100, pitch: 0.9, rate: 0.85, volume: 0.8 }), - Object.freeze({ description: "Measured delivery for suspense, hidden lore, or strange events.", name: "Mysterious", pauseAfterMs: 260, pauseBeforeMs: 120, pitch: 0.92, rate: 0.88, volume: 0.85 }), -]); -const SEED_TTS_PROFILES = Object.freeze([ - Object.freeze({ - description: "Balanced local browser playback option until authored TTS profiles are available.", - language: "en-US", - name: "Default Balanced TTS Profile", - pitch: 1, - providerKey: "browser-speech", - rate: 1, - voiceName: "", - volume: 1, - }), - Object.freeze({ - description: "Narration-focused preview configuration for future spoken story text.", - language: "en-US", - name: "Narration Preview", - pitch: 0.95, - providerKey: "browser-speech", - rate: 0.9, - voiceName: "", - volume: 0.9, - }), -]); - -function encodeUlidPart(value, length) { - let remaining = BigInt(value); - let encoded = ""; - for (let index = 0; index < length; index += 1) { - encoded = ULID_ALPHABET[Number(remaining % 32n)] + encoded; - remaining /= 32n; - } - return encoded; -} - -function createUlid() { - const timePart = encodeUlidPart(Date.now(), 10); - const randomPart = Array.from(randomBytes(16), (byte) => ULID_ALPHABET[byte % 32]).join(""); - return `${timePart}${randomPart}`; -} - -function timestamp() { - return new Date().toISOString(); -} - -function httpError(message, statusCode = 400) { - const error = new Error(message); - error.statusCode = statusCode; - return error; -} - -function normalizeText(value) { - return typeof value === "string" ? value : ""; -} - -function normalizeName(value, label) { - const normalized = normalizeText(value).trim(); - if (!normalized) { - throw httpError(`${label} is required.`); - } - return normalized; -} - -function normalizeActive(value, fallback = true) { - return value === undefined ? fallback : value !== false; -} - -function normalizeNumber(value, fallback) { - const numberValue = Number(value); - return Number.isFinite(numberValue) ? numberValue : fallback; -} - -function emotionSettingKey(value) { - return normalizeText(value) - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "neutral"; -} - -function normalizeInteger(value, fallback) { - const numberValue = Number(value); - return Number.isInteger(numberValue) ? numberValue : fallback; -} - -function normalizeActorKey(actorKey) { - const normalized = normalizeText(actorKey).trim(); - return normalized || SEED_DB_KEYS.users.forgeBot; -} - -function activeToDatabase(value) { - return value ? 1 : 0; -} - -function activeFromDatabase(value) { - return Number(value) !== 0; -} - -function resolveMessagesDatabasePath(repoRoot, env = process.env) { - const configuredPath = normalizeText(env[MESSAGES_SQLITE_PATH_ENV_KEY]).trim(); - if (configuredPath) { - return path.resolve(repoRoot, configuredPath); - } - return path.join(repoRoot, "tmp", "messages", "messages.sqlite"); -} - -function ensureParentDirectory(filePath) { - mkdirSync(path.dirname(filePath), { recursive: true }); -} - -function messageRecordFromRow(row) { - return { - active: activeFromDatabase(row.active), - categoryKey: row.categoryKey, - categoryName: row.categoryName || "", - createdAt: row.createdAt, - createdBy: row.createdBy, - emotionProfileKey: row.emotionProfileKey, - emotionProfileName: row.emotionProfileName || "", - key: row.key, - messageText: row.messageText, - name: row.name, - notes: row.notes || "", - updatedAt: row.updatedAt, - updatedBy: row.updatedBy, - }; -} - -function categoryFromRow(row) { - return { - active: activeFromDatabase(row.active), - createdAt: row.createdAt, - createdBy: row.createdBy, - key: row.key, - name: row.name, - status: activeFromDatabase(row.active) ? "Active" : "Inactive", - updatedAt: row.updatedAt, - updatedBy: row.updatedBy, - }; -} - -function emotionProfileFromRow(row, usage = {}) { - const messageUsageCount = Number(usage.messageUsageCount || 0); - const segmentUsageCount = Number(usage.segmentUsageCount || 0); - return { - active: activeFromDatabase(row.active), - createdAt: row.createdAt, - createdBy: row.createdBy, - description: row.description || "", - key: row.key, - name: row.name, - pauseAfterMs: Number(row.pauseAfterMs), - pauseBeforeMs: Number(row.pauseBeforeMs), - pitch: Number(row.pitch), - rate: Number(row.rate), - references: Array.isArray(usage.references) ? usage.references : [], - messageUsageCount, - segmentUsageCount, - status: activeFromDatabase(row.active) ? "Active" : "Inactive", - updatedAt: row.updatedAt, - updatedBy: row.updatedBy, - usageCount: messageUsageCount + segmentUsageCount, - volume: Number(row.volume), - }; -} - -function ttsEmotionSettingFromEmotionProfile(profile) { - return { - active: profile.active !== false, - emotion: emotionSettingKey(profile.name), - emotionLabel: profile.name, - pitch: Number(profile.pitch), - rate: Number(profile.rate), - ssmlLikePreset: "normal", - volume: Number(profile.volume), - }; -} - -function ttsProfileFromRow(row, emotionSettings = []) { - return { - active: activeFromDatabase(row.active), - createdAt: row.createdAt, - createdBy: row.createdBy, - description: row.description || "", - emotionSettings, - key: row.key, - language: row.language, - name: row.name, - pitch: Number(row.pitch), - providerKey: row.providerKey, - rate: Number(row.rate), - status: activeFromDatabase(row.active) ? "Active" : "Inactive", - updatedAt: row.updatedAt, - updatedBy: row.updatedBy, - voiceName: row.voiceName || "", - volume: Number(row.volume), - }; -} - -function messageSegmentFromRow(row) { - return { - active: activeFromDatabase(row.active), - createdAt: row.createdAt, - createdBy: row.createdBy, - displayOrder: Number(row.displayOrder), - emotionProfileKey: row.emotionProfileKey, - emotionProfileName: row.emotionProfileName || "", - key: row.key, - messageKey: row.messageKey, - messageName: row.messageName || "", - segmentText: row.segmentText, - updatedAt: row.updatedAt, - updatedBy: row.updatedBy, - }; -} - -function normalizeRequiredInteger(value, label) { - if (value === undefined || value === null || String(value).trim() === "") { - throw httpError(`${label} is required.`); - } - const numberValue = Number(value); - if (!Number.isInteger(numberValue)) { - throw httpError(`${label} must be a whole number.`); - } - if (numberValue < 1) { - throw httpError(`${label} must be 1 or greater.`); - } - return numberValue; -} - -export class MessagesSqliteService { - constructor({ - env = process.env, - repoRoot = process.cwd(), - } = {}) { - this.databasePath = resolveMessagesDatabasePath(repoRoot, env); - this.database = null; - } - - db() { - if (!this.database) { - ensureParentDirectory(this.databasePath); - this.database = new DatabaseSync(this.databasePath); - this.database.exec("PRAGMA foreign_keys = ON;"); - this.initializeSchema(); - this.seedDefaults(); - } - return this.database; - } - - close() { - if (this.database) { - this.database.close(); - this.database = null; - } - } - - initializeSchema() { - this.database.exec(` - CREATE TABLE IF NOT EXISTS messages_categories ( - key TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - active INTEGER NOT NULL DEFAULT 1, - createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL, - createdBy TEXT NOT NULL, - updatedBy TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS messages_emotion_profiles ( - key TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - description TEXT NOT NULL DEFAULT '', - volume REAL NOT NULL DEFAULT 1, - pitch REAL NOT NULL DEFAULT 1, - rate REAL NOT NULL DEFAULT 1, - pauseBeforeMs INTEGER NOT NULL DEFAULT 0, - pauseAfterMs INTEGER NOT NULL DEFAULT 0, - active INTEGER NOT NULL DEFAULT 1, - createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL, - createdBy TEXT NOT NULL, - updatedBy TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS messages_records ( - key TEXT PRIMARY KEY, - name TEXT NOT NULL, - categoryKey TEXT NOT NULL REFERENCES messages_categories(key), - emotionProfileKey TEXT NOT NULL REFERENCES messages_emotion_profiles(key), - messageText TEXT NOT NULL, - notes TEXT NOT NULL DEFAULT '', - active INTEGER NOT NULL DEFAULT 1, - createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL, - createdBy TEXT NOT NULL, - updatedBy TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS messages_tts_profiles ( - key TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - description TEXT NOT NULL DEFAULT '', - providerKey TEXT NOT NULL, - voiceName TEXT NOT NULL DEFAULT '', - language TEXT NOT NULL, - volume REAL NOT NULL DEFAULT 1, - pitch REAL NOT NULL DEFAULT 1, - rate REAL NOT NULL DEFAULT 1, - active INTEGER NOT NULL DEFAULT 1, - createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL, - createdBy TEXT NOT NULL, - updatedBy TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS messages_segments ( - key TEXT PRIMARY KEY, - messageKey TEXT NOT NULL REFERENCES messages_records(key), - emotionProfileKey TEXT NOT NULL REFERENCES messages_emotion_profiles(key), - segmentText TEXT NOT NULL, - displayOrder INTEGER NOT NULL, - active INTEGER NOT NULL DEFAULT 1, - createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL, - createdBy TEXT NOT NULL, - updatedBy TEXT NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_messages_records_category ON messages_records (categoryKey); - CREATE INDEX IF NOT EXISTS idx_messages_records_emotion ON messages_records (emotionProfileKey); - CREATE INDEX IF NOT EXISTS idx_messages_records_createdby ON messages_records (createdBy); - CREATE INDEX IF NOT EXISTS idx_messages_records_updatedby ON messages_records (updatedBy); - CREATE INDEX IF NOT EXISTS idx_messages_segments_message ON messages_segments (messageKey); - CREATE INDEX IF NOT EXISTS idx_messages_segments_emotion ON messages_segments (emotionProfileKey); - CREATE INDEX IF NOT EXISTS idx_messages_segments_order ON messages_segments (messageKey, displayOrder); - CREATE INDEX IF NOT EXISTS idx_messages_segments_createdby ON messages_segments (createdBy); - CREATE INDEX IF NOT EXISTS idx_messages_segments_updatedby ON messages_segments (updatedBy); - CREATE INDEX IF NOT EXISTS idx_messages_tts_profiles_provider ON messages_tts_profiles (providerKey); - CREATE INDEX IF NOT EXISTS idx_messages_tts_profiles_createdby ON messages_tts_profiles (createdBy); - CREATE INDEX IF NOT EXISTS idx_messages_tts_profiles_updatedby ON messages_tts_profiles (updatedBy); - `); - } - - seedDefaults() { - SEED_CATEGORY_NAMES.forEach((name) => { - const existing = this.findCategoryByName(name); - if (!existing) { - this.insertCategory({ - active: true, - actorKey: SEED_DB_KEYS.users.forgeBot, - name, - }); - } - }); - - SEED_EMOTION_PROFILES.forEach((profile) => { - const existing = this.findEmotionProfileByName(profile.name); - if (!existing) { - this.insertEmotionProfile({ - ...profile, - active: true, - actorKey: SEED_DB_KEYS.users.forgeBot, - }); - } - }); - - SEED_TTS_PROFILES.forEach((profile) => { - const existing = this.findTtsProfileByName(profile.name); - if (!existing) { - this.insertTtsProfile({ - ...profile, - active: true, - actorKey: SEED_DB_KEYS.users.forgeBot, - }); - } - }); - } - - persistenceSummary() { - return { - engine: "SQLite", - owner: "messages", - storage: "server-owned", - }; - } - - listCategories() { - return this.db().prepare(` - SELECT * FROM messages_categories - ORDER BY name COLLATE NOCASE ASC - `).all().map(categoryFromRow); - } - - getCategory(key) { - const row = this.db().prepare("SELECT * FROM messages_categories WHERE key = ?").get(key); - if (!row) { - throw httpError("Message category was not found.", 404); - } - return categoryFromRow(row); - } - - findCategoryByName(name) { - const normalized = normalizeText(name).trim(); - if (!normalized) { - return null; - } - const row = this.db().prepare("SELECT * FROM messages_categories WHERE lower(name) = lower(?)").get(normalized); - return row ? categoryFromRow(row) : null; - } - - insertCategory({ active = true, actorKey, name }) { - const key = createUlid(); - const now = timestamp(); - const actor = normalizeActorKey(actorKey); - this.db().prepare(` - INSERT INTO messages_categories (key, name, active, createdAt, updatedAt, createdBy, updatedBy) - VALUES (?, ?, ?, ?, ?, ?, ?) - `).run(key, normalizeName(name, "Category name"), activeToDatabase(active), now, now, actor, actor); - return this.getCategory(key); - } - - createCategory(input = {}, actorKey = "") { - const name = normalizeName(input.name, "Category name"); - const existing = this.findCategoryByName(name); - if (existing) { - throw httpError(`Category ${name} already exists.`); - } - return this.insertCategory({ - active: normalizeActive(input.active, true), - actorKey, - name, - }); - } - - updateCategory(key, input = {}, actorKey = "") { - const existing = this.getCategory(key); - const name = input.name === undefined ? existing.name : normalizeName(input.name, "Category name"); - const duplicate = this.findCategoryByName(name); - if (duplicate && duplicate.key !== key) { - throw httpError(`Category ${name} already exists.`); - } - const now = timestamp(); - this.db().prepare(` - UPDATE messages_categories - SET name = ?, active = ?, updatedAt = ?, updatedBy = ? - WHERE key = ? - `).run(name, activeToDatabase(normalizeActive(input.active, existing.active)), now, normalizeActorKey(actorKey), key); - return this.getCategory(key); - } - - listEmotionProfiles() { - return this.db().prepare(` - SELECT * FROM messages_emotion_profiles - ORDER BY name COLLATE NOCASE ASC - `).all().map((row) => emotionProfileFromRow(row, this.emotionProfileUsage(row.key))); - } - - getEmotionProfile(key) { - const row = this.db().prepare("SELECT * FROM messages_emotion_profiles WHERE key = ?").get(key); - if (!row) { - throw httpError("Emotion profile was not found.", 404); - } - return emotionProfileFromRow(row, this.emotionProfileUsage(row.key)); - } - - findEmotionProfileByName(name) { - const normalized = normalizeText(name).trim(); - if (!normalized) { - return null; - } - const row = this.db().prepare("SELECT * FROM messages_emotion_profiles WHERE lower(name) = lower(?)").get(normalized); - return row ? emotionProfileFromRow(row) : null; - } - - emotionProfileUsage(key) { - const messageReferences = this.db().prepare(` - SELECT key, name - FROM messages_records - WHERE emotionProfileKey = ? - ORDER BY name COLLATE NOCASE ASC, key ASC - `).all(key).map((row) => ({ - key: row.key, - label: row.name, - type: "message", - })); - const segmentReferences = this.db().prepare(` - SELECT - messages_segments.key, - messages_segments.messageKey, - messages_segments.displayOrder, - messages_segments.segmentText, - messages_records.name AS messageName - FROM messages_segments - LEFT JOIN messages_records ON messages_records.key = messages_segments.messageKey - WHERE messages_segments.emotionProfileKey = ? - ORDER BY messages_records.name COLLATE NOCASE ASC, messages_segments.displayOrder ASC, messages_segments.key ASC - `).all(key).map((row) => ({ - displayOrder: Number(row.displayOrder), - key: row.key, - label: `${row.messageName || "Unknown Message"} segment ${row.displayOrder}`, - messageKey: row.messageKey, - preview: normalizeText(row.segmentText).slice(0, 80), - type: "segment", - })); - return { - messageUsageCount: messageReferences.length, - references: [ - ...messageReferences, - ...segmentReferences, - ], - segmentUsageCount: segmentReferences.length, - }; - } - - insertEmotionProfile(input = {}) { - const key = createUlid(); - const now = timestamp(); - const actor = normalizeActorKey(input.actorKey); - this.db().prepare(` - INSERT INTO messages_emotion_profiles ( - key, name, description, volume, pitch, rate, pauseBeforeMs, pauseAfterMs, - active, createdAt, updatedAt, createdBy, updatedBy - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - key, - normalizeName(input.name, "Emotion profile name"), - normalizeText(input.description), - normalizeNumber(input.volume, 1), - normalizeNumber(input.pitch, 1), - normalizeNumber(input.rate, 1), - normalizeInteger(input.pauseBeforeMs, 0), - normalizeInteger(input.pauseAfterMs, 0), - activeToDatabase(normalizeActive(input.active, true)), - now, - now, - actor, - actor, - ); - return this.getEmotionProfile(key); - } - - createEmotionProfile(input = {}, actorKey = "") { - const name = normalizeName(input.name, "Emotion profile name"); - const existing = this.findEmotionProfileByName(name); - if (existing) { - throw httpError(`Emotion profile ${name} already exists.`); - } - return this.insertEmotionProfile({ - ...input, - actorKey, - name, - }); - } - - updateEmotionProfile(key, input = {}, actorKey = "") { - const existing = this.getEmotionProfile(key); - const name = input.name === undefined ? existing.name : normalizeName(input.name, "Emotion profile name"); - const duplicate = this.findEmotionProfileByName(name); - if (duplicate && duplicate.key !== key) { - throw httpError(`Emotion profile ${name} already exists.`); - } - const active = normalizeActive(input.active, existing.active); - if (existing.active && !active && existing.usageCount > 0) { - throw httpError("Emotion profile is referenced by messages or segments. Reassign those references before deactivating this emotion profile."); - } - const now = timestamp(); - this.db().prepare(` - UPDATE messages_emotion_profiles - SET name = ?, description = ?, volume = ?, pitch = ?, rate = ?, pauseBeforeMs = ?, pauseAfterMs = ?, - active = ?, updatedAt = ?, updatedBy = ? - WHERE key = ? - `).run( - name, - input.description === undefined ? existing.description : normalizeText(input.description), - normalizeNumber(input.volume, existing.volume), - normalizeNumber(input.pitch, existing.pitch), - normalizeNumber(input.rate, existing.rate), - normalizeInteger(input.pauseBeforeMs, existing.pauseBeforeMs), - normalizeInteger(input.pauseAfterMs, existing.pauseAfterMs), - activeToDatabase(active), - now, - normalizeActorKey(actorKey), - key, - ); - return this.getEmotionProfile(key); - } - - listTtsProfiles() { - const emotionSettings = this.listEmotionProfiles() - .filter((profile) => profile.active !== false) - .map(ttsEmotionSettingFromEmotionProfile); - return this.db().prepare(` - SELECT * FROM messages_tts_profiles - ORDER BY name COLLATE NOCASE ASC - `).all().map((row) => ttsProfileFromRow(row, emotionSettings)); - } - - getTtsProfile(key) { - const row = this.db().prepare("SELECT * FROM messages_tts_profiles WHERE key = ?").get(key); - if (!row) { - throw httpError("TTS profile was not found.", 404); - } - const emotionSettings = this.listEmotionProfiles() - .filter((profile) => profile.active !== false) - .map(ttsEmotionSettingFromEmotionProfile); - return ttsProfileFromRow(row, emotionSettings); - } - - findTtsProfileByName(name) { - const normalized = normalizeText(name).trim(); - if (!normalized) { - return null; - } - const row = this.db().prepare("SELECT * FROM messages_tts_profiles WHERE lower(name) = lower(?)").get(normalized); - return row ? ttsProfileFromRow(row) : null; - } - - normalizeTtsProfileInput(input = {}, existing = null) { - const name = input.name === undefined && existing ? existing.name : normalizeName(input.name, "TTS profile name"); - return { - active: normalizeActive(input.active, existing ? existing.active : true), - description: input.description === undefined && existing ? existing.description : normalizeText(input.description), - language: input.language === undefined && existing ? existing.language : normalizeName(input.language, "TTS profile language"), - name, - pitch: normalizeNumber(input.pitch, existing ? existing.pitch : 1), - providerKey: input.providerKey === undefined && existing ? existing.providerKey : normalizeName(input.providerKey, "TTS provider key"), - rate: normalizeNumber(input.rate, existing ? existing.rate : 1), - voiceName: input.voiceName === undefined && existing ? existing.voiceName : normalizeText(input.voiceName), - volume: normalizeNumber(input.volume, existing ? existing.volume : 1), - }; - } - - insertTtsProfile(input = {}) { - const values = this.normalizeTtsProfileInput(input); - const key = createUlid(); - const now = timestamp(); - const actor = normalizeActorKey(input.actorKey); - this.db().prepare(` - INSERT INTO messages_tts_profiles ( - key, name, description, providerKey, voiceName, language, volume, pitch, rate, - active, createdAt, updatedAt, createdBy, updatedBy - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - key, - values.name, - values.description, - values.providerKey, - values.voiceName, - values.language, - values.volume, - values.pitch, - values.rate, - activeToDatabase(values.active), - now, - now, - actor, - actor, - ); - return this.getTtsProfile(key); - } - - createTtsProfile(input = {}, actorKey = "") { - const values = this.normalizeTtsProfileInput(input); - const existing = this.findTtsProfileByName(values.name); - if (existing) { - throw httpError(`TTS profile ${values.name} already exists.`); - } - return this.insertTtsProfile({ - ...values, - actorKey, - }); - } - - updateTtsProfile(key, input = {}, actorKey = "") { - const existing = this.getTtsProfile(key); - const values = this.normalizeTtsProfileInput(input, existing); - const duplicate = this.findTtsProfileByName(values.name); - if (duplicate && duplicate.key !== key) { - throw httpError(`TTS profile ${values.name} already exists.`); - } - const now = timestamp(); - this.db().prepare(` - UPDATE messages_tts_profiles - SET name = ?, description = ?, providerKey = ?, voiceName = ?, language = ?, - volume = ?, pitch = ?, rate = ?, active = ?, updatedAt = ?, updatedBy = ? - WHERE key = ? - `).run( - values.name, - values.description, - values.providerKey, - values.voiceName, - values.language, - values.volume, - values.pitch, - values.rate, - activeToDatabase(values.active), - now, - normalizeActorKey(actorKey), - key, - ); - return this.getTtsProfile(key); - } - - listMessages() { - return this.db().prepare(` - SELECT - messages_records.*, - messages_categories.name AS categoryName, - messages_emotion_profiles.name AS emotionProfileName - FROM messages_records - LEFT JOIN messages_categories ON messages_categories.key = messages_records.categoryKey - LEFT JOIN messages_emotion_profiles ON messages_emotion_profiles.key = messages_records.emotionProfileKey - ORDER BY messages_records.updatedAt DESC, messages_records.name COLLATE NOCASE ASC - `).all().map(messageRecordFromRow); - } - - getMessage(key) { - const row = this.db().prepare(` - SELECT - messages_records.*, - messages_categories.name AS categoryName, - messages_emotion_profiles.name AS emotionProfileName - FROM messages_records - LEFT JOIN messages_categories ON messages_categories.key = messages_records.categoryKey - LEFT JOIN messages_emotion_profiles ON messages_emotion_profiles.key = messages_records.emotionProfileKey - WHERE messages_records.key = ? - `).get(key); - if (!row) { - throw httpError("Message was not found.", 404); - } - return messageRecordFromRow(row); - } - - assertActiveCategory(key) { - const category = this.getCategory(key); - if (!category.active) { - throw httpError("Category is inactive. Choose an active category before saving a message."); - } - return category; - } - - defaultMessageCategoryKey() { - const dialog = this.findCategoryByName("Dialog"); - const fallback = dialog || this.listCategories()[0]; - if (!fallback) { - throw httpError("Legacy message category seed is unavailable. Restart the Local API runtime."); - } - return fallback.key; - } - - assertActiveEmotionProfile(key) { - const profile = this.getEmotionProfile(key); - if (!profile.active) { - throw httpError("Emotion profile is inactive. Choose an active emotion profile before saving a message."); - } - return profile; - } - - normalizeMessageInput(input = {}, existing = null) { - const name = input.name === undefined && existing ? existing.name : normalizeName(input.name, "Message name"); - const categoryKey = normalizeText(input.categoryKey === undefined && existing ? existing.categoryKey : input.categoryKey).trim() - || this.defaultMessageCategoryKey(); - const emotionProfileKey = normalizeText(input.emotionProfileKey === undefined && existing ? existing.emotionProfileKey : input.emotionProfileKey).trim(); - const messageText = input.messageText === undefined && existing ? existing.messageText : normalizeText(input.messageText); - if (!emotionProfileKey) { - throw httpError("Emotion profile is required."); - } - if (!messageText.trim()) { - throw httpError("Message text is required."); - } - this.assertActiveCategory(categoryKey); - this.assertActiveEmotionProfile(emotionProfileKey); - return { - active: normalizeActive(input.active, existing ? existing.active : true), - categoryKey, - emotionProfileKey, - messageText, - name, - notes: input.notes === undefined && existing ? existing.notes : normalizeText(input.notes), - }; - } - - createMessage(input = {}, actorKey = "") { - const values = this.normalizeMessageInput(input); - const key = createUlid(); - const now = timestamp(); - const actor = normalizeActorKey(actorKey); - this.db().prepare(` - INSERT INTO messages_records ( - key, name, categoryKey, emotionProfileKey, messageText, notes, active, - createdAt, updatedAt, createdBy, updatedBy - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - key, - values.name, - values.categoryKey, - values.emotionProfileKey, - values.messageText, - values.notes, - activeToDatabase(values.active), - now, - now, - actor, - actor, - ); - return this.getMessage(key); - } - - updateMessage(key, input = {}, actorKey = "") { - const existing = this.getMessage(key); - const values = this.normalizeMessageInput(input, existing); - const now = timestamp(); - this.db().prepare(` - UPDATE messages_records - SET name = ?, categoryKey = ?, emotionProfileKey = ?, messageText = ?, notes = ?, - active = ?, updatedAt = ?, updatedBy = ? - WHERE key = ? - `).run( - values.name, - values.categoryKey, - values.emotionProfileKey, - values.messageText, - values.notes, - activeToDatabase(values.active), - now, - normalizeActorKey(actorKey), - key, - ); - return this.getMessage(key); - } - - listMessageSegments() { - return this.db().prepare(` - SELECT - messages_segments.*, - messages_records.name AS messageName, - messages_emotion_profiles.name AS emotionProfileName - FROM messages_segments - LEFT JOIN messages_records ON messages_records.key = messages_segments.messageKey - LEFT JOIN messages_emotion_profiles ON messages_emotion_profiles.key = messages_segments.emotionProfileKey - ORDER BY messages_segments.messageKey ASC, messages_segments.displayOrder ASC, - messages_segments.createdAt ASC, messages_segments.key ASC - `).all().map(messageSegmentFromRow); - } - - getMessageSegment(key) { - const row = this.db().prepare(` - SELECT - messages_segments.*, - messages_records.name AS messageName, - messages_emotion_profiles.name AS emotionProfileName - FROM messages_segments - LEFT JOIN messages_records ON messages_records.key = messages_segments.messageKey - LEFT JOIN messages_emotion_profiles ON messages_emotion_profiles.key = messages_segments.emotionProfileKey - WHERE messages_segments.key = ? - `).get(key); - if (!row) { - throw httpError("Message segment was not found.", 404); - } - return messageSegmentFromRow(row); - } - - normalizeMessageSegmentInput(input = {}, existing = null) { - const messageKey = normalizeText(input.messageKey === undefined && existing ? existing.messageKey : input.messageKey).trim(); - const emotionProfileKey = normalizeText(input.emotionProfileKey === undefined && existing ? existing.emotionProfileKey : input.emotionProfileKey).trim(); - const segmentText = input.segmentText === undefined && existing ? existing.segmentText : normalizeText(input.segmentText); - const displayOrder = normalizeRequiredInteger( - input.displayOrder === undefined && existing ? existing.displayOrder : input.displayOrder, - "Display order", - ); - if (!messageKey) { - throw httpError("Message is required."); - } - if (!emotionProfileKey) { - throw httpError("Emotion profile is required."); - } - if (!segmentText.trim()) { - throw httpError("Segment text is required."); - } - this.getMessage(messageKey); - this.assertActiveEmotionProfile(emotionProfileKey); - return { - active: normalizeActive(input.active, existing ? existing.active : true), - displayOrder, - emotionProfileKey, - messageKey, - segmentText, - }; - } - - createMessageSegment(input = {}, actorKey = "") { - const values = this.normalizeMessageSegmentInput(input); - const key = createUlid(); - const now = timestamp(); - const actor = normalizeActorKey(actorKey); - this.db().prepare(` - INSERT INTO messages_segments ( - key, messageKey, emotionProfileKey, segmentText, displayOrder, active, - createdAt, updatedAt, createdBy, updatedBy - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - key, - values.messageKey, - values.emotionProfileKey, - values.segmentText, - values.displayOrder, - activeToDatabase(values.active), - now, - now, - actor, - actor, - ); - return this.getMessageSegment(key); - } - - updateMessageSegment(key, input = {}, actorKey = "") { - const existing = this.getMessageSegment(key); - const values = this.normalizeMessageSegmentInput(input, existing); - const now = timestamp(); - this.db().prepare(` - UPDATE messages_segments - SET messageKey = ?, emotionProfileKey = ?, segmentText = ?, displayOrder = ?, - active = ?, updatedAt = ?, updatedBy = ? - WHERE key = ? - `).run( - values.messageKey, - values.emotionProfileKey, - values.segmentText, - values.displayOrder, - activeToDatabase(values.active), - now, - normalizeActorKey(actorKey), - key, - ); - return this.getMessageSegment(key); - } -} - -export function createMessagesSqliteService(options = {}) { - return new MessagesSqliteService(options); -} - -export function handleMessagesApiContract({ - actorKey = "", - body = {}, - method = "GET", - parts = [], - service, -} = {}) { - if (!service) { - throw httpError("Messages SQLite service is not configured.", 500); - } - const normalizedMethod = String(method || "GET").toUpperCase(); - const resource = parts[0] || ""; - const key = parts[1] || ""; - - if (resource === "messages") { - if (normalizedMethod === "GET" && !key) { - return { - messages: service.listMessages(), - persistence: service.persistenceSummary(), - }; - } - if (normalizedMethod === "GET" && key) { - return { - message: service.getMessage(key), - persistence: service.persistenceSummary(), - }; - } - if (normalizedMethod === "POST" && !key) { - return { - message: service.createMessage(body, actorKey), - persistence: service.persistenceSummary(), - }; - } - if (normalizedMethod === "POST" && key) { - return { - message: service.updateMessage(key, body, actorKey), - persistence: service.persistenceSummary(), - }; - } - } - - if (resource === "emotion-profiles") { - if (normalizedMethod === "GET" && !key) { - return { - emotionProfiles: service.listEmotionProfiles(), - persistence: service.persistenceSummary(), - }; - } - if (normalizedMethod === "GET" && key) { - return { - emotionProfile: service.getEmotionProfile(key), - persistence: service.persistenceSummary(), - }; - } - if (normalizedMethod === "POST" && !key) { - return { - emotionProfile: service.createEmotionProfile(body, actorKey), - persistence: service.persistenceSummary(), - }; - } - if (normalizedMethod === "POST" && key) { - return { - emotionProfile: service.updateEmotionProfile(key, body, actorKey), - persistence: service.persistenceSummary(), - }; - } - } - - if (resource === "categories") { - if (normalizedMethod === "GET" && !key) { - return { - categories: service.listCategories(), - persistence: service.persistenceSummary(), - }; - } - if (normalizedMethod === "POST" && !key) { - return { - category: service.createCategory(body, actorKey), - persistence: service.persistenceSummary(), - }; - } - if (normalizedMethod === "POST" && key) { - return { - category: service.updateCategory(key, body, actorKey), - persistence: service.persistenceSummary(), - }; - } - } - - if (resource === "tts-profiles") { - if (normalizedMethod === "GET" && !key) { - return { - persistence: service.persistenceSummary(), - ttsProfiles: service.listTtsProfiles(), - }; - } - if (normalizedMethod === "GET" && key) { - return { - persistence: service.persistenceSummary(), - ttsProfile: service.getTtsProfile(key), - }; - } - if (normalizedMethod === "POST" && !key) { - return { - persistence: service.persistenceSummary(), - ttsProfile: service.createTtsProfile(body, actorKey), - }; - } - if (normalizedMethod === "POST" && key) { - return { - persistence: service.persistenceSummary(), - ttsProfile: service.updateTtsProfile(key, body, actorKey), - }; - } - } - - if (resource === "segments") { - if (normalizedMethod === "GET" && !key) { - return { - persistence: service.persistenceSummary(), - segments: service.listMessageSegments(), - }; - } - if (normalizedMethod === "GET" && key) { - return { - persistence: service.persistenceSummary(), - segment: service.getMessageSegment(key), - }; - } - if (normalizedMethod === "POST" && !key) { - return { - persistence: service.persistenceSummary(), - segment: service.createMessageSegment(body, actorKey), - }; - } - if (normalizedMethod === "POST" && key) { - return { - persistence: service.persistenceSummary(), - segment: service.updateMessageSegment(key, body, actorKey), - }; - } - } - - throw httpError(`Unknown Messages API route: ${normalizedMethod} /api/messages/${parts.join("/")}.`, 404); -} diff --git a/src/dev-runtime/server/local-api-router.mjs b/src/dev-runtime/server/local-api-router.mjs index 4eed49c54..6e843911d 100644 --- a/src/dev-runtime/server/local-api-router.mjs +++ b/src/dev-runtime/server/local-api-router.mjs @@ -121,9 +121,9 @@ import { } from "../marketplace/marketplace-revenue-service.mjs"; import { handleAdminNotesDirectoryApiRequest } from "../admin/admin-notes-directory.mjs"; import { - createMessagesSqliteService, + createMessagesPostgresService, handleMessagesApiContract, -} from "../messages/messages-sqlite-service.mjs"; +} from "../messages/messages-postgres-service.mjs"; import { LegalDocumentError, readPublishedLegalDocument, @@ -2318,9 +2318,11 @@ function productTablesFromSnapshot(snapshot) { class ApiRuntimeDataSource { constructor({ + messagesPostgresClient = null, + messagesService = null, repoRoot = process.cwd(), } = {}) { - this.messagesService = createMessagesSqliteService({ repoRoot }); + this.messagesService = messagesService || createMessagesPostgresService({ postgresClient: messagesPostgresClient }); this.repositoryCounter = 1; this.repositoryById = new Map(); this.sessionModeId = FIXED_ACCOUNT_SESSION_MODE.id; @@ -4846,7 +4848,7 @@ LIMIT 1; return this.sessionUserKey || SEED_DB_KEYS.users.forgeBot; } - messagesApiContract(method, parts, body) { + async messagesApiContract(method, parts, body) { return handleMessagesApiContract({ actorKey: this.messagesActorKey(), body, @@ -5446,9 +5448,11 @@ LIMIT 1; * The router itself serves the configured server API contract. */ export function createLocalApiRouter({ + messagesPostgresClient = null, + messagesService = null, repoRoot = process.cwd(), } = {}) { - const dataSource = new ApiRuntimeDataSource({ repoRoot }); + const dataSource = new ApiRuntimeDataSource({ messagesPostgresClient, messagesService, repoRoot }); async function handleApiRuntimeRequest(request, response, requestUrl) { if (!requestUrl.pathname.startsWith("/api/")) { @@ -5672,7 +5676,7 @@ export function createLocalApiRouter({ if (parts[1] === "messages") { const body = request.method === "POST" ? await readRequestJson(request) : {}; - ok(response, dataSource.messagesApiContract(request.method, parts.slice(2), body)); + ok(response, await dataSource.messagesApiContract(request.method, parts.slice(2), body)); return true; } diff --git a/tests/dev-runtime/DbSeedIntegrity.test.mjs b/tests/dev-runtime/DbSeedIntegrity.test.mjs index 21eee8fab..2f16458c8 100644 --- a/tests/dev-runtime/DbSeedIntegrity.test.mjs +++ b/tests/dev-runtime/DbSeedIntegrity.test.mjs @@ -7,14 +7,15 @@ import assert from "node:assert/strict"; import { createLocalApiRouter } from "../../src/dev-runtime/server/local-api-router.mjs"; import { MOCK_DB_KEYS } from "../../src/dev-runtime/persistence/mock-db-store.js"; import { getActiveToolRegistry } from "../../src/dev-runtime/guest-seeds/tool-metadata-inventory.js"; +import { createMessagesPostgresClientStub } from "../helpers/messagesPostgresClientStub.mjs"; const GUEST_SEED_GROUP_KEYS = getActiveToolRegistry() .filter((tool) => tool.visibleInToolsList !== false && tool.hidden !== true) .map((tool) => tool.id || tool.key || tool.slug || tool.name) .sort(); -function startApiServer() { - const handleRequest = createLocalApiRouter(); +function startApiServer(routerOptions = {}) { + const handleRequest = createLocalApiRouter(routerOptions); const server = http.createServer((request, response) => { const address = server.address(); const port = address && typeof address !== "string" ? address.port : 0; @@ -38,7 +39,10 @@ function startApiServer() { } resolve({ baseUrl: `http://127.0.0.1:${address.port}`, - close: () => new Promise((closeResolve) => server.close(closeResolve)), + close: async () => { + await handleRequest.close?.(); + await new Promise((closeResolve) => server.close(closeResolve)); + }, }); }); }); @@ -77,6 +81,57 @@ function sampleByKey(snapshot, sampleKey) { return sample; } +test("Messages Local API seeds through the Postgres service and preserves response shapes", async () => { + const server = await startApiServer({ messagesPostgresClient: createMessagesPostgresClientStub() }); + try { + const categories = await apiJson(server.baseUrl, "/api/messages/categories"); + assert.equal(categories.persistence.engine, "Postgres"); + assert.equal(categories.persistence.owner, "messages"); + assert.equal(categories.persistence.storage, "server-owned"); + assert.equal(categories.categories.some((category) => category.name === "Dialog"), true); + + const emotionProfiles = await apiJson(server.baseUrl, "/api/messages/emotion-profiles"); + const urgent = emotionProfiles.emotionProfiles.find((profile) => profile.name === "Urgent"); + assert.ok(urgent, "Messages emotion profiles should include Urgent"); + + const ttsProfiles = await apiJson(server.baseUrl, "/api/messages/tts-profiles"); + assert.equal(ttsProfiles.ttsProfiles.some((profile) => profile.name === "Default Balanced TTS Profile"), true); + assert.equal(ttsProfiles.ttsProfiles[0].emotionSettings.some((setting) => setting.emotionLabel === "Urgent"), true); + + const created = await apiJson(server.baseUrl, "/api/messages/messages", { + body: JSON.stringify({ + emotionProfileKey: urgent.key, + messageText: "Postgres-backed message text.", + name: "Postgres Cutover Message", + }), + method: "POST", + }); + assert.equal(created.persistence.engine, "Postgres"); + assert.equal(created.message.categoryName, "Dialog"); + assert.equal(created.message.emotionProfileName, "Urgent"); + assert.equal(created.message.messageText, "Postgres-backed message text."); + + const segment = await apiJson(server.baseUrl, "/api/messages/segments", { + body: JSON.stringify({ + displayOrder: 1, + emotionProfileKey: urgent.key, + messageKey: created.message.key, + segmentText: "Postgres-backed message part.", + }), + method: "POST", + }); + assert.equal(segment.segment.messageName, "Postgres Cutover Message"); + assert.equal(segment.segment.emotionProfileName, "Urgent"); + + const list = await apiJson(server.baseUrl, "/api/messages/messages"); + const listed = list.messages.find((message) => message.key === created.message.key); + assert.equal(listed.name, "Postgres Cutover Message"); + assert.equal(listed.categoryName, "Dialog"); + } finally { + await server.close(); + } +}); + async function replaceSampleLabel(baseUrl, sampleKey, sampleLabel) { const snapshot = await apiJson(baseUrl, "/api/local-db/snapshot"); const nextState = clone(snapshot); diff --git a/tests/helpers/messagesPostgresClientStub.mjs b/tests/helpers/messagesPostgresClientStub.mjs new file mode 100644 index 000000000..89eca61d7 --- /dev/null +++ b/tests/helpers/messagesPostgresClientStub.mjs @@ -0,0 +1,83 @@ +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") { + continue; + } + if (!value.startsWith("eq.")) { + throw new Error(`Unsupported Messages Postgres test filter for ${key}.`); + } + return { + key, + value: decodeURIComponent(value.slice(3)), + }; + } + return null; +} + +export function createMessagesPostgresClientStub() { + const tables = new Map(); + + function table(name) { + if (!tables.has(name)) { + tables.set(name, []); + } + return tables.get(name); + } + + return { + 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 Messages Postgres test method: ${normalizedMethod}.`); + }, + }; +} diff --git a/tests/helpers/playwrightRepoServer.mjs b/tests/helpers/playwrightRepoServer.mjs index 13bb7c069..b10698d21 100644 --- a/tests/helpers/playwrightRepoServer.mjs +++ b/tests/helpers/playwrightRepoServer.mjs @@ -90,9 +90,11 @@ function resolveBrowserRoutePath(decodedPath) { return normalizedPath; } -export async function startRepoServer() { +export async function startRepoServer({ + messagesPostgresClient = null, +} = {}) { await loadRuntimeEnv(); - const handleLocalApiRequest = createLocalApiRouter({ repoRoot }); + const handleLocalApiRequest = createLocalApiRouter({ 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/MessagesTool.spec.mjs b/tests/playwright/tools/MessagesTool.spec.mjs index 69068a2e2..dd2b371b8 100644 --- a/tests/playwright/tools/MessagesTool.spec.mjs +++ b/tests/playwright/tools/MessagesTool.spec.mjs @@ -1,17 +1,10 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { expect, test } from "@playwright/test"; import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs"; +import { createMessagesPostgresClientStub } from "../../helpers/messagesPostgresClientStub.mjs"; import { clearPlaywrightStorage, installPlaywrightStorageIsolation } from "../../helpers/playwrightStorageIsolation.mjs"; import { workspaceV2CoverageReporter } from "../../helpers/workspaceV2CoverageReporter.mjs"; const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/; -let messagesRunId = 0; - -function messagesDbPath() { - messagesRunId += 1; - return path.join(process.cwd(), "tmp", `messages-tool-${process.pid}-${messagesRunId}.sqlite`); -} async function jsonRequest(url, options = {}) { const response = await fetch(url, { @@ -43,17 +36,15 @@ test.afterAll(async () => { await workspaceV2CoverageReporter.writeReport(); }); -async function openMessagesPage(page, sqlitePath, options = {}) { - const previousMessagesSqlitePath = process.env.GAMEFOUNDRY_MESSAGES_SQLITE_PATH; - process.env.GAMEFOUNDRY_MESSAGES_SQLITE_PATH = sqlitePath; - const server = await startRepoServer(); +async function openMessagesPage(page, options = {}) { + const messagesPostgresClient = createMessagesPostgresClientStub(); + const server = await startRepoServer({ messagesPostgresClient }); const failures = { consoleErrors: [], failedRequests: [], + messagesPostgresClient, pageErrors: [], - previousMessagesSqlitePath, server, - sqlitePath, }; page.on("pageerror", (error) => { @@ -127,11 +118,6 @@ async function openMessagesPage(page, sqlitePath, options = {}) { async function closeMessagesRun(failures, page) { await workspaceV2CoverageReporter.stop(page); await failures.server.close(); - if (failures.previousMessagesSqlitePath === undefined) { - delete process.env.GAMEFOUNDRY_MESSAGES_SQLITE_PATH; - } else { - process.env.GAMEFOUNDRY_MESSAGES_SQLITE_PATH = failures.previousMessagesSqlitePath; - } } async function addMessage(page, values) { @@ -155,9 +141,7 @@ async function openMessageParts(page, messageName) { } test("Message Studio renders Messages with child Message Parts and plays ordered parts", async ({ page }) => { - const sqlitePath = messagesDbPath(); - await fs.rm(sqlitePath, { force: true }); - const failures = await openMessagesPage(page, sqlitePath); + const failures = await openMessagesPage(page); try { await expect(page.getByRole("heading", { level: 1, name: "Message Studio" })).toBeVisible(); @@ -352,14 +336,11 @@ test("Message Studio renders Messages with child Message Parts and plays ordered expect(failures.consoleErrors).toEqual([]); } finally { await closeMessagesRun(failures, page); - await fs.rm(sqlitePath, { force: true }); } }); test("Message Studio shows actionable playback error when audio engine is unavailable", async ({ page }) => { - const sqlitePath = messagesDbPath(); - await fs.rm(sqlitePath, { force: true }); - const failures = await openMessagesPage(page, sqlitePath, { speechAvailable: false }); + const failures = await openMessagesPage(page, { speechAvailable: false }); try { await addMessage(page, { @@ -386,13 +367,10 @@ test("Message Studio shows actionable playback error when audio engine is unavai expect(failures.consoleErrors).toEqual([]); } finally { await closeMessagesRun(failures, page); - await fs.rm(sqlitePath, { force: true }); } }); test("Message Studio shows actionable playback error when selected TTS profile lacks the selected emotion", async ({ page }) => { - const sqlitePath = messagesDbPath(); - await fs.rm(sqlitePath, { force: true }); await page.route("**/api/messages/tts-profiles", async (route) => { if (route.request().method() !== "GET") { await route.continue(); @@ -417,7 +395,7 @@ test("Message Studio shows actionable playback error when selected TTS profile l }), }); }); - const failures = await openMessagesPage(page, sqlitePath); + const failures = await openMessagesPage(page); try { await addMessage(page, { @@ -445,6 +423,5 @@ test("Message Studio shows actionable playback error when selected TTS profile l } finally { await page.unroute("**/api/messages/tts-profiles"); await closeMessagesRun(failures, page); - await fs.rm(sqlitePath, { force: true }); } });