@marinoscar/kvox-eval 0.0.0-bootstrap → 0.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -0
  4. package/dist/ask/index.d.ts +6 -0
  5. package/dist/ask/index.js +28 -0
  6. package/dist/ask/question-schema.d.ts +121 -0
  7. package/dist/ask/question-schema.js +167 -0
  8. package/dist/ask/report.d.ts +88 -0
  9. package/dist/ask/report.js +170 -0
  10. package/dist/ask/scorer.d.ts +104 -0
  11. package/dist/ask/scorer.js +164 -0
  12. package/dist/ask-run/ask-command.d.ts +62 -0
  13. package/dist/ask-run/ask-command.js +254 -0
  14. package/dist/ask-run/host/app.d.ts +65 -0
  15. package/dist/ask-run/host/app.js +146 -0
  16. package/dist/ask-run/host/db.d.ts +38 -0
  17. package/dist/ask-run/host/db.js +126 -0
  18. package/dist/ask-run/host/ports.d.ts +35 -0
  19. package/dist/ask-run/host/ports.js +113 -0
  20. package/dist/ask-run/index.d.ts +13 -0
  21. package/dist/ask-run/index.js +35 -0
  22. package/dist/ask-run/replay.d.ts +128 -0
  23. package/dist/ask-run/replay.js +212 -0
  24. package/dist/ask-run/run-ask-eval.d.ts +125 -0
  25. package/dist/ask-run/run-ask-eval.js +193 -0
  26. package/dist/ask-run/seed-golden-graph.d.ts +64 -0
  27. package/dist/ask-run/seed-golden-graph.js +495 -0
  28. package/dist/cli/index.d.ts +3 -0
  29. package/dist/cli/index.js +12 -0
  30. package/dist/cli/kg-command.d.ts +66 -0
  31. package/dist/cli/kg-command.js +238 -0
  32. package/dist/cli/main.d.ts +18 -0
  33. package/dist/cli/main.js +69 -0
  34. package/dist/fixtures/domains.d.ts +16 -0
  35. package/dist/fixtures/domains.js +33 -0
  36. package/dist/fixtures/fixture-schema.d.ts +323 -0
  37. package/dist/fixtures/fixture-schema.js +199 -0
  38. package/dist/fixtures/index.d.ts +16 -0
  39. package/dist/fixtures/index.js +40 -0
  40. package/dist/fixtures/load.d.ts +73 -0
  41. package/dist/fixtures/load.js +137 -0
  42. package/dist/fixtures/questions.d.ts +15 -0
  43. package/dist/fixtures/questions.js +26 -0
  44. package/dist/index.d.ts +20 -0
  45. package/dist/index.js +36 -0
  46. package/dist/kg/gold-runner.d.ts +13 -0
  47. package/dist/kg/gold-runner.js +57 -0
  48. package/dist/kg/index.d.ts +17 -0
  49. package/dist/kg/index.js +65 -0
  50. package/dist/kg/internals.d.ts +7 -0
  51. package/dist/kg/internals.js +17 -0
  52. package/dist/kg/prediction-schema.d.ts +245 -0
  53. package/dist/kg/prediction-schema.js +102 -0
  54. package/dist/kg/predictions.d.ts +11 -0
  55. package/dist/kg/predictions.js +49 -0
  56. package/dist/kg/report.d.ts +84 -0
  57. package/dist/kg/report.js +125 -0
  58. package/dist/kg/run-kg-eval.d.ts +77 -0
  59. package/dist/kg/run-kg-eval.js +137 -0
  60. package/dist/kg/runner.d.ts +72 -0
  61. package/dist/kg/runner.js +51 -0
  62. package/dist/kg/runners/extract-resolve-runner.d.ts +40 -0
  63. package/dist/kg/runners/extract-resolve-runner.js +199 -0
  64. package/dist/kg/runners/extract-runner.d.ts +65 -0
  65. package/dist/kg/runners/extract-runner.js +243 -0
  66. package/dist/kg/scorer.d.ts +226 -0
  67. package/dist/kg/scorer.js +463 -0
  68. package/dist/kg/targets.d.ts +17 -0
  69. package/dist/kg/targets.js +16 -0
  70. package/dist/kg/text.d.ts +45 -0
  71. package/dist/kg/text.js +100 -0
  72. package/dist/live/adapters.d.ts +16 -0
  73. package/dist/live/adapters.js +58 -0
  74. package/dist/live/env.d.ts +39 -0
  75. package/dist/live/env.js +56 -0
  76. package/dist/live/index.d.ts +6 -0
  77. package/dist/live/index.js +16 -0
  78. package/dist/live/live-ai.d.ts +104 -0
  79. package/dist/live/live-ai.js +157 -0
  80. package/dist/parity/constants.d.ts +79 -0
  81. package/dist/parity/constants.js +104 -0
  82. package/dist/parity/index.d.ts +20 -0
  83. package/dist/parity/index.js +38 -0
  84. package/dist/parity/internals.d.ts +8 -0
  85. package/dist/parity/internals.js +16 -0
  86. package/dist/parity/scenario.d.ts +45 -0
  87. package/dist/parity/scenario.js +245 -0
  88. package/dist/parity/script.d.ts +44 -0
  89. package/dist/parity/script.js +136 -0
  90. package/dist/parity/snapshot.d.ts +43 -0
  91. package/dist/parity/snapshot.js +71 -0
  92. package/dist/real-data/index.d.ts +2 -0
  93. package/dist/real-data/index.js +12 -0
  94. package/dist/real-data/real-data.d.ts +76 -0
  95. package/dist/real-data/real-data.js +156 -0
  96. package/fixtures/kg-golden/README.md +136 -0
  97. package/fixtures/kg-golden/ask-questions.json +458 -0
  98. package/fixtures/kg-golden/meetings/m01-northwind-pilot-kickoff.json +792 -0
  99. package/fixtures/kg-golden/meetings/m02-halden-migration-planning.json +814 -0
  100. package/fixtures/kg-golden/meetings/m03-larkspur-weekly-sync.json +601 -0
  101. package/fixtures/kg-golden/meetings/m04-corvid-intake-discovery.json +690 -0
  102. package/fixtures/kg-golden/meetings/m05-hosting-vendor-selection.json +631 -0
  103. package/fixtures/kg-golden/meetings/m06-northwind-floor-data-review.json +649 -0
  104. package/fixtures/kg-golden/meetings/m07-gullwing-intro-notes.json +428 -0
  105. package/fixtures/kg-golden/meetings/m08-halden-migration-checkpoint.json +672 -0
  106. package/fixtures/kg-golden/meetings/m09-northwind-platform-org-update.json +711 -0
  107. package/fixtures/kg-golden/meetings/m10-corvid-prototype-review.json +623 -0
  108. package/fixtures/kg-golden/meetings/m11-solvane-grid-dashboard-scoping.json +689 -0
  109. package/fixtures/kg-golden/meetings/m12-northwind-pilot-replan.json +681 -0
  110. package/fixtures/kg-golden/meetings/m13-halden-cutover-go-no-go.json +721 -0
  111. package/fixtures/kg-golden/meetings/m14-gullwing-discovery-readout.json +719 -0
  112. package/fixtures/kg-golden/meetings/m15-halden-handover-ravi-leaving.json +716 -0
  113. package/fixtures/kg-golden/meetings/m16-varden-claims-portal-intro.json +704 -0
  114. package/fixtures/kg-golden/meetings/m17-corvid-usability-debrief.json +572 -0
  115. package/fixtures/kg-golden/meetings/m18-hosting-decision-revisited.json +655 -0
  116. package/fixtures/kg-golden/meetings/m19-solvane-integration-kickoff.json +726 -0
  117. package/fixtures/kg-golden/meetings/m20-northwind-go-live-retro.json +673 -0
  118. package/fixtures/kg-golden/meetings/m21-quarterly-account-review.json +449 -0
  119. package/fixtures/kg-golden/meetings/m22-halden-intro-call-2020.json +558 -0
  120. package/fixtures/kg-golden/meetings/m23-gullwing-model-review.json +702 -0
  121. package/fixtures/kg-golden/meetings/m24-varden-discovery-midpoint.json +643 -0
  122. package/fixtures/kg-golden/meetings/m25-corvid-rollout-planning.json +642 -0
  123. package/fixtures/kg-golden/meetings/m26-larkspur-engineering-prep.json +695 -0
  124. package/fixtures/kg-golden/meetings/m27-solvane-phase-one-demo.json +649 -0
  125. package/fixtures/kg-golden/meetings/m28-northwind-building-5-scoping.json +715 -0
  126. package/fixtures/kg-golden/meetings/m29-halden-southern-cutover-review.json +752 -0
  127. package/fixtures/kg-golden/meetings/m30-varden-portal-build-kickoff.json +697 -0
  128. package/fixtures/kg-golden/meetings/m31-gullwing-rollout-renewal.json +681 -0
  129. package/fixtures/kg-golden/meetings/m32-brambleway-weekly-sync.json +472 -0
  130. package/fixtures/kg-golden/meetings/m33-nina-oliver-one-on-one.json +342 -0
  131. package/fixtures/kg-golden/meetings/m34-coffee-with-dana.json +249 -0
  132. package/fixtures/kg-golden/meetings/m35-brambleway-june-sync.json +553 -0
  133. package/fixtures/kg-golden/meetings/m36-copenhagen-conference-planning.json +296 -0
  134. package/host/generated/client.d.ts +1 -0
  135. package/host/generated/client.js +5 -0
  136. package/host/generated/default.d.ts +1 -0
  137. package/host/generated/default.js +5 -0
  138. package/host/generated/edge.d.ts +1 -0
  139. package/host/generated/edge.js +1658 -0
  140. package/host/generated/index-browser.js +1684 -0
  141. package/host/generated/index.d.ts +192335 -0
  142. package/host/generated/index.js +1658 -0
  143. package/host/generated/package.json +139 -0
  144. package/host/generated/query_compiler_fast_bg.js +2 -0
  145. package/host/generated/query_compiler_fast_bg.wasm +0 -0
  146. package/host/generated/query_compiler_fast_bg.wasm-base64.js +2 -0
  147. package/host/generated/runtime/client.d.ts +3433 -0
  148. package/host/generated/runtime/client.js +110 -0
  149. package/host/generated/runtime/index-browser.d.ts +90 -0
  150. package/host/generated/runtime/index-browser.js +6 -0
  151. package/host/generated/runtime/wasm-compiler-edge.js +100 -0
  152. package/host/generated/schema.prisma +6312 -0
  153. package/host/generated/wasm-edge-light-loader.mjs +5 -0
  154. package/host/generated/wasm-worker-loader.mjs +5 -0
  155. package/host/prisma/fragments/kvox.ask.prisma +126 -0
  156. package/host/prisma/fragments/kvox.graph.prisma +1073 -0
  157. package/host/prisma/fragments/kvox.notes.prisma +787 -0
  158. package/host/prisma/fragments/kvox.search.prisma +211 -0
  159. package/host/prisma/fragments/kvox.transcripts.prisma +790 -0
  160. package/host/prisma/kvox.lock +101 -0
  161. package/host/prisma/migrations/20240101000000_initial/migration.sql +297 -0
  162. package/host/prisma/migrations/20240101000001_add_personal_access_tokens/migration.sql +31 -0
  163. package/host/prisma/migrations/20240101000002_add_credentials/migration.sql +20 -0
  164. package/host/prisma/migrations/20240101000003_add_notification_deliveries/migration.sql +27 -0
  165. package/host/prisma/migrations/20240101000004_drop_stale_uuid_defaults/migration.sql +35 -0
  166. package/host/prisma/migrations/20240101000005_add_notifications/migration.sql +22 -0
  167. package/host/prisma/migrations/20240101000006_add_push_subscriptions/migration.sql +25 -0
  168. package/host/prisma/migrations/20240101000007_add_jobs/migration.sql +110 -0
  169. package/host/prisma/migrations/20240101000008_add_worker_nodes/migration.sql +90 -0
  170. package/host/prisma/migrations/20240101000009_add_database_backup_runs/migration.sql +104 -0
  171. package/host/prisma/migrations/20240101000010_add_notification_broadcasts/migration.sql +57 -0
  172. package/host/prisma/migrations/20240101000011_add_backup_run_job_link/migration.sql +87 -0
  173. package/host/prisma/migrations/20240101000012_add_job_node_secrets/migration.sql +61 -0
  174. package/host/prisma/migrations/20240101000013_add_backup_run_pg_dump_version/migration.sql +26 -0
  175. package/host/prisma/migrations/20240101000014_add_job_claim_token/migration.sql +92 -0
  176. package/host/prisma/migrations/20240101000015_add_ai_platform/migration.sql +108 -0
  177. package/host/prisma/migrations/20240101000016_revoke_viewer_ai_use/migration.sql +32 -0
  178. package/host/prisma/migrations/20240101000017_add_user_credentials/migration.sql +29 -0
  179. package/host/prisma/migrations/20240101000018_add_device_session_credential_link/migration.sql +52 -0
  180. package/host/prisma/migrations/20240101000019_add_worker_node_vitals/migration.sql +3 -0
  181. package/host/prisma/migrations/20240101000020_add_job_trace_context/migration.sql +6 -0
  182. package/host/prisma/migrations/20240101000021_add_retention_created_at_indexes/migration.sql +13 -0
  183. package/host/prisma/migrations/20240101000022_add_organizations/migration.sql +142 -0
  184. package/host/prisma/migrations/20240101000023_split_system_org_roles/migration.sql +131 -0
  185. package/host/prisma/migrations/20240101000024_org_scoped_rls/migration.sql +215 -0
  186. package/host/prisma/migrations/20240101000025_add_groups/migration.sql +165 -0
  187. package/host/prisma/migrations/20240101000026_add_grants/migration.sql +117 -0
  188. package/host/prisma/migrations/20240101000027_add_org_settings/migration.sql +49 -0
  189. package/host/prisma/migrations/20240101000028_add_org_credentials/migration.sql +54 -0
  190. package/host/prisma/migrations/20240101000029_add_jobs_org_id/migration.sql +18 -0
  191. package/host/prisma/migrations/20240101000030_add_jobs_org_id_status_index/migration.sql +6 -0
  192. package/host/prisma/migrations/20240101000031_add_broadcast_target_org/migration.sql +21 -0
  193. package/host/prisma/migrations/20240101000032_add_android_app/migration.sql +60 -0
  194. package/host/prisma/migrations/20240101000033_open_job_reason/migration.sql +16 -0
  195. package/host/prisma/migrations/20240101000034_ai_usage_feature/migration.sql +37 -0
  196. package/host/prisma/migrations/20240101000035_add_app_metadata/migration.sql +49 -0
  197. package/host/prisma/migrations/20240101000036_open_sharing_vocabularies/migration.sql +97 -0
  198. package/host/prisma/migrations/20260914130000_kvox_add_transcripts/migration.sql +322 -0
  199. package/host/prisma/migrations/20260914140000_kvox_add_notes/migration.sql +311 -0
  200. package/host/prisma/migrations/20260915120000_kvox_add_search_vectors/migration.sql +150 -0
  201. package/host/prisma/migrations/20260915130000_kvox_add_search_embeddings/migration.sql +362 -0
  202. package/host/prisma/migrations/20260915210000_kvox_add_note_title_source/migration.sql +30 -0
  203. package/host/prisma/migrations/20260923120000_kvox_add_note_generation_context_snapshot/migration.sql +24 -0
  204. package/host/prisma/migrations/20260923130000_kvox_add_user_hidden_note_templates/migration.sql +35 -0
  205. package/host/prisma/migrations/20260923140000_kvox_add_transcript_speaker_identities/migration.sql +6 -0
  206. package/host/prisma/migrations/20260923150000_kvox_transcript_name_checks/migration.sql +95 -0
  207. package/host/prisma/migrations/20260923160000_kvox_add_note_body_format/migration.sql +27 -0
  208. package/host/prisma/migrations/20260923170000_kvox_add_note_version_body_format/migration.sql +23 -0
  209. package/host/prisma/migrations/20260926012548_kvox_add_knowledge_graph/migration.sql +680 -0
  210. package/host/prisma/migrations/20260926030000_kvox_fix_kg_relations_valid_precision_chk/migration.sql +29 -0
  211. package/host/prisma/migrations/20260926030100_kvox_add_transcript_recorded_at/migration.sql +9 -0
  212. package/host/prisma/migrations/20260926030200_kvox_kg_evidence_invariant/migration.sql +60 -0
  213. package/host/prisma/migrations/20260926040000_kvox_add_kg_graph_layouts/migration.sql +30 -0
  214. package/host/prisma/migrations/20260926050000_kvox_add_ask_conversations/migration.sql +99 -0
  215. package/host/prisma/migrations/20260926231512_kvox_add_kg_exports/migration.sql +52 -0
  216. package/host/prisma/migrations/20261016120000_kvox_transcripts_org_id/migration.sql +49 -0
  217. package/host/prisma/platform.lock +319 -0
  218. package/host/prisma/schema/app.kvox.ask.prisma +110 -0
  219. package/host/prisma/schema/app.kvox.graph.prisma +1030 -0
  220. package/host/prisma/schema/app.kvox.notes.prisma +746 -0
  221. package/host/prisma/schema/app.kvox.search.prisma +208 -0
  222. package/host/prisma/schema/app.kvox.transcripts.prisma +726 -0
  223. package/host/prisma/schema/platform.ai.prisma +419 -0
  224. package/host/prisma/schema/platform.android-app.prisma +46 -0
  225. package/host/prisma/schema/platform.base.prisma +15 -0
  226. package/host/prisma/schema/platform.credentials.prisma +156 -0
  227. package/host/prisma/schema/platform.db-backup.prisma +359 -0
  228. package/host/prisma/schema/platform.identity.prisma +638 -0
  229. package/host/prisma/schema/platform.jobs.prisma +824 -0
  230. package/host/prisma/schema/platform.notifications.prisma +641 -0
  231. package/host/prisma/schema/platform.settings.prisma +53 -0
  232. package/host/prisma/schema/platform.sharing.prisma +208 -0
  233. package/host/prisma/schema/platform.storage.prisma +118 -0
  234. package/package.json +112 -1
  235. package/parity/requests.ask.json +2681 -0
  236. package/parity/requests.m01.json +1554 -0
  237. package/parity/requests.m02.json +1554 -0
  238. package/parity/requests.m03.json +1554 -0
  239. package/parity/requests.m04.json +1554 -0
  240. package/parity/requests.m05.json +1554 -0
  241. package/parity/requests.m06.json +1554 -0
  242. package/parity/requests.m07.json +1554 -0
  243. package/parity/requests.m08.json +1554 -0
  244. package/parity/requests.m09.json +1554 -0
  245. package/parity/requests.m10.json +1554 -0
@@ -0,0 +1,90 @@
1
+ -- =============================================================================
2
+ -- Worker Nodes (issue #267, epic #254)
3
+ -- =============================================================================
4
+ -- Adds `worker_nodes` and `node_credentials`, and wires the FK on
5
+ -- `jobs.claimed_by_node_id` that was deliberately left unconstrained by
6
+ -- 20260906120000_add_jobs (see that migration's own header comment, and the
7
+ -- block comment above the `Job` model and the `WorkerNode` model in
8
+ -- prisma/schema.prisma for the full reasoning behind every choice below —
9
+ -- this file intentionally does not restate it at length).
10
+ -- =============================================================================
11
+
12
+ -- CreateEnum
13
+ CREATE TYPE "NodeStatus" AS ENUM ('online', 'draining', 'offline', 'disabled');
14
+
15
+ -- CreateTable
16
+ CREATE TABLE "worker_nodes" (
17
+ "id" UUID NOT NULL,
18
+ "name" TEXT NOT NULL,
19
+ "hostname" TEXT NOT NULL,
20
+ "platform" TEXT NOT NULL,
21
+ "cli_version" TEXT NOT NULL,
22
+ "eligible_types" TEXT[],
23
+ "concurrency" INTEGER NOT NULL,
24
+ "status" "NodeStatus" NOT NULL DEFAULT 'offline',
25
+ "capabilities" JSONB,
26
+ "registered_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
27
+ "last_heartbeat_at" TIMESTAMPTZ,
28
+ "created_by_id" UUID NOT NULL,
29
+
30
+ CONSTRAINT "worker_nodes_pkey" PRIMARY KEY ("id")
31
+ );
32
+
33
+ -- CreateTable
34
+ CREATE TABLE "node_credentials" (
35
+ "id" UUID NOT NULL,
36
+ "user_id" UUID NOT NULL,
37
+ "name" TEXT NOT NULL,
38
+ "token_hash" TEXT NOT NULL,
39
+ "token_prefix" TEXT NOT NULL,
40
+ "expires_at" TIMESTAMPTZ,
41
+ "last_used_at" TIMESTAMPTZ,
42
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
43
+ "revoked_at" TIMESTAMPTZ,
44
+
45
+ CONSTRAINT "node_credentials_pkey" PRIMARY KEY ("id")
46
+ );
47
+
48
+ -- CreateIndex
49
+ -- The register-or-reattach anchor (#268's own words: "existing exactly this
50
+ -- way") — see the block comment above the `WorkerNode` model.
51
+ CREATE UNIQUE INDEX "worker_nodes_created_by_id_name_key" ON "worker_nodes"("created_by_id", "name");
52
+
53
+ -- CreateIndex
54
+ -- "Every online/draining/offline/disabled node" — the fleet page's primary
55
+ -- filter, and what #270's lifecycle cron scans.
56
+ CREATE INDEX "worker_nodes_status_idx" ON "worker_nodes"("status");
57
+
58
+ -- CreateIndex
59
+ -- "This user's own nodes" — backs `GET /nodes` and `assertOwnership` (#268).
60
+ CREATE INDEX "worker_nodes_created_by_id_idx" ON "worker_nodes"("created_by_id");
61
+
62
+ -- CreateIndex
63
+ CREATE UNIQUE INDEX "node_credentials_token_hash_key" ON "node_credentials"("token_hash");
64
+
65
+ -- CreateIndex
66
+ -- "This user's own credentials" — backs `GET /api/node-credentials`, the
67
+ -- same query shape as `personal_access_tokens_user_id_idx`.
68
+ CREATE INDEX "node_credentials_user_id_idx" ON "node_credentials"("user_id");
69
+
70
+ -- AddForeignKey
71
+ -- onDelete: Cascade — a WorkerNode is a live registration with no standalone
72
+ -- value once its owner is gone (its NodeCredential rows are already gone via
73
+ -- their own Cascade below, so an orphaned node could never authenticate
74
+ -- again). See the block comment above `WorkerNode.createdBy` in
75
+ -- prisma/schema.prisma for the full reasoning, including why this is NOT the
76
+ -- SetNull audit-trail pattern used elsewhere in this schema (AuditEvent,
77
+ -- Credential, NotificationDelivery).
78
+ ALTER TABLE "worker_nodes" ADD CONSTRAINT "worker_nodes_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
79
+
80
+ -- AddForeignKey
81
+ -- onDelete: Cascade — mirrors `personal_access_tokens_user_id_fkey` exactly
82
+ -- (per issue #267): a node credential is a live authentication artifact with
83
+ -- no standalone value once its owning account is deleted.
84
+ ALTER TABLE "node_credentials" ADD CONSTRAINT "node_credentials_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
85
+
86
+ -- AddForeignKey
87
+ -- The FK deferred by 20260906120000_add_jobs. onDelete: SetNull — deleting a
88
+ -- WorkerNode must release the jobs it was holding, never delete job history.
89
+ -- See the "claimedByNodeId" block comment above the `Job` model.
90
+ ALTER TABLE "jobs" ADD CONSTRAINT "jobs_claimed_by_node_id_fkey" FOREIGN KEY ("claimed_by_node_id") REFERENCES "worker_nodes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,104 @@
1
+ -- CreateEnum
2
+ CREATE TYPE "DatabaseBackupStatus" AS ENUM ('pending', 'running', 'completed', 'failed', 'stale');
3
+
4
+ -- CreateEnum
5
+ CREATE TYPE "DatabaseBackupTrigger" AS ENUM ('manual', 'scheduled', 'pre_restore');
6
+
7
+ -- CreateTable
8
+ CREATE TABLE "database_backup_runs" (
9
+ "id" UUID NOT NULL,
10
+ "status" "DatabaseBackupStatus" NOT NULL DEFAULT 'pending',
11
+ "trigger" "DatabaseBackupTrigger" NOT NULL,
12
+ "started_at" TIMESTAMPTZ,
13
+ "finished_at" TIMESTAMPTZ,
14
+ "last_heartbeat_at" TIMESTAMPTZ,
15
+ "bytes_written" BIGINT NOT NULL DEFAULT 0,
16
+ "size_bytes" BIGINT NOT NULL DEFAULT 0,
17
+ "storage_provider" TEXT NOT NULL,
18
+ "storage_key" TEXT NOT NULL,
19
+ "bucket" TEXT NOT NULL,
20
+ "format" TEXT NOT NULL,
21
+ "checksum_sha256" TEXT,
22
+ "db_version" TEXT,
23
+ "app_version" TEXT,
24
+ "migration_name" TEXT,
25
+ "verified_at" TIMESTAMPTZ,
26
+ "last_error" TEXT,
27
+ "created_by_id" UUID,
28
+ "restore_status" TEXT,
29
+ "restore_error" TEXT,
30
+ "restored_at" TIMESTAMPTZ,
31
+ "restored_by_id" UUID,
32
+ "restore_scratch_db" TEXT,
33
+ "restore_old_db" TEXT,
34
+ "swapped_at" TIMESTAMPTZ,
35
+ "pre_restore_backup_id" UUID,
36
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
37
+ "updated_at" TIMESTAMPTZ NOT NULL,
38
+
39
+ CONSTRAINT "database_backup_runs_pkey" PRIMARY KEY ("id")
40
+ );
41
+
42
+ -- CreateIndex
43
+ CREATE INDEX "database_backup_runs_created_at_idx" ON "database_backup_runs"("created_at" DESC);
44
+
45
+ -- CreateIndex
46
+ CREATE INDEX "database_backup_runs_started_at_idx" ON "database_backup_runs"("started_at" DESC);
47
+
48
+ -- CreateIndex
49
+ CREATE INDEX "database_backup_runs_status_idx" ON "database_backup_runs"("status");
50
+
51
+ -- CreateIndex
52
+ CREATE INDEX "database_backup_runs_status_created_at_idx" ON "database_backup_runs"("status", "created_at" DESC);
53
+
54
+ -- AddForeignKey
55
+ ALTER TABLE "database_backup_runs" ADD CONSTRAINT "database_backup_runs_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
56
+
57
+ -- AddForeignKey
58
+ ALTER TABLE "database_backup_runs" ADD CONSTRAINT "database_backup_runs_restored_by_id_fkey" FOREIGN KEY ("restored_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
59
+
60
+ -- AddForeignKey
61
+ ALTER TABLE "database_backup_runs" ADD CONSTRAINT "database_backup_runs_pre_restore_backup_id_fkey" FOREIGN KEY ("pre_restore_backup_id") REFERENCES "database_backup_runs"("id") ON DELETE SET NULL ON UPDATE CASCADE;
62
+
63
+ -- =============================================================================
64
+ -- HAND-WRITTEN INDEX — INTENTIONAL SCHEMA DRIFT (issue #281, epic #254)
65
+ -- =============================================================================
66
+ -- The index below is NOT emitted by `prisma migrate dev`/`diff` and never will
67
+ -- be: it needs a partial-index `WHERE` clause, which the Prisma schema language
68
+ -- has no syntax for. It is written here by hand, and the block comment above
69
+ -- the `DatabaseBackupRun` model in prisma/schema.prisma records the same fact
70
+ -- for anyone reading the schema without this file open.
71
+ --
72
+ -- ⚠ `prisma migrate dev` WILL WANT TO DROP IT on the next diff, exactly as it
73
+ -- would for the three partial indexes in 20260906120000_add_jobs. Do not
74
+ -- "reconcile" this migration by accepting that drop, and do not expect
75
+ -- `prisma migrate diff` against a live database to come back clean. This drift
76
+ -- is deliberate and permanent for as long as Prisma's DSL lacks partial
77
+ -- indexes. If a future `migrate dev` run emits a `DROP INDEX
78
+ -- "database_backup_runs_active_uniq_idx"`, delete that line from the generated
79
+ -- migration before committing it.
80
+ -- =============================================================================
81
+
82
+ -- THE single-active-run guard. It is what makes "at most one backup at a time"
83
+ -- true ACROSS REPLICAS, rather than true only within one process: a scheduled
84
+ -- tick on one container and an administrator's click on another cannot both
85
+ -- insert, so two `pg_dump` processes can never stream into the derived storage
86
+ -- key at once. `DatabaseBackupRunnerService.startBackup` inserts optimistically
87
+ -- and turns the loser's P2002 into a typed
88
+ -- `DatabaseBackupAlreadyRunningError` carrying the winner's id; there is
89
+ -- deliberately no `findFirst` pre-check anywhere in that path, because
90
+ -- check-then-act is racy exactly when this constraint matters.
91
+ --
92
+ -- No cast is needed on the literals: Postgres coerces an unadorned string
93
+ -- literal to the column's enum type in an `IN` list, the same way
94
+ -- "jobs_active_dedup_uniq_idx" does for "JobStatus".
95
+ --
96
+ -- ⚠ WHAT THIS ADMITS, PRECISELY: a UNIQUE index on ("status") filtered to two
97
+ -- values permits at most one 'pending' row AND at most one 'running' row. The
98
+ -- runner only ever inserts 'running', so today the ceiling is exactly one
99
+ -- active run; 'pending' is covered so that a future path which does insert one
100
+ -- is still arbitrated here rather than in application code. A future design
101
+ -- that genuinely needs both states populated at once must TIGHTEN this index
102
+ -- (to a constant expression) — never relax the guard into a service.
103
+ CREATE UNIQUE INDEX "database_backup_runs_active_uniq_idx"
104
+ ON "database_backup_runs" ("status") WHERE "status" IN ('pending','running');
@@ -0,0 +1,57 @@
1
+ -- =============================================================================
2
+ -- Notification Broadcasts (issue #320, epic #319)
3
+ -- =============================================================================
4
+ -- Adds `notification_broadcasts`. See the block comment above the
5
+ -- `NotificationBroadcast` model in prisma/schema.prisma for the full
6
+ -- reasoning behind every choice below (why a table at all rather than
7
+ -- `Job.payload`, why `channels` is a plain string array, why `status` carries
8
+ -- a currently-unreachable `draft` member, why `cursor_user_id` pairs with
9
+ -- `audience_cutoff` to freeze and replay the fan-out's audience, why
10
+ -- `created_by_id` is SetNull, and the rejected per-recipient join table) —
11
+ -- this file intentionally does not restate it at length.
12
+ -- =============================================================================
13
+
14
+ -- CreateEnum
15
+ CREATE TYPE "NotificationBroadcastStatus" AS ENUM ('draft', 'scheduled', 'sending', 'sent', 'canceled', 'failed');
16
+
17
+ -- CreateTable
18
+ CREATE TABLE "notification_broadcasts" (
19
+ "id" UUID NOT NULL,
20
+ "title" TEXT NOT NULL,
21
+ "body" TEXT NOT NULL,
22
+ "link" TEXT,
23
+ "cta_label" TEXT,
24
+ "event_key" TEXT NOT NULL,
25
+ "channels" TEXT[],
26
+ "status" "NotificationBroadcastStatus" NOT NULL DEFAULT 'scheduled',
27
+ "scheduled_for" TIMESTAMPTZ,
28
+ "started_at" TIMESTAMPTZ,
29
+ "finished_at" TIMESTAMPTZ,
30
+ "canceled_at" TIMESTAMPTZ,
31
+ "audience_cutoff" TIMESTAMPTZ,
32
+ "cursor_user_id" UUID,
33
+ "recipients_targeted" INTEGER,
34
+ "recipients_dispatched" INTEGER NOT NULL DEFAULT 0,
35
+ "last_error" TEXT,
36
+ "created_by_id" UUID,
37
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
38
+ "updated_at" TIMESTAMPTZ NOT NULL,
39
+
40
+ CONSTRAINT "notification_broadcasts_pkey" PRIMARY KEY ("id")
41
+ );
42
+
43
+ -- CreateIndex
44
+ -- "What is pending" — the scheduler's own query.
45
+ CREATE INDEX "notification_broadcasts_status_scheduled_for_idx" ON "notification_broadcasts"("status", "scheduled_for");
46
+
47
+ -- CreateIndex
48
+ -- The admin list page's default order, "most recently composed first".
49
+ CREATE INDEX "notification_broadcasts_created_at_idx" ON "notification_broadcasts"("created_at" DESC);
50
+
51
+ -- AddForeignKey
52
+ -- onDelete: SetNull — same audit-trail reasoning as
53
+ -- `audit_events_actor_user_id_fkey`: deleting the admin who composed a
54
+ -- broadcast must not delete the record of what they announced. Takes a
55
+ -- brief lock on `users` while the constraint is validated, as any new FK to
56
+ -- that table does.
57
+ ALTER TABLE "notification_broadcasts" ADD CONSTRAINT "notification_broadcasts_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,87 @@
1
+ -- =============================================================================
2
+ -- Backup Run <-> Job link, and tightening the single-active-run guard
3
+ -- (issue #351, epic #345)
4
+ -- =============================================================================
5
+ -- Two independent changes land together because the second is only safe once
6
+ -- the first is possible to reason about (see the second block below for why
7
+ -- they must not be split across two migrations).
8
+ --
9
+ -- 1. `database_backup_runs.job_id`: the FK from a backup run back to the
10
+ -- queue job that drove it, once the dump becomes a job (`db.backup.run`).
11
+ -- Nullable, no default, no backfill — see the block comment above
12
+ -- `DatabaseBackupRun` in prisma/schema.prisma ("jobId") for the full
13
+ -- reasoning behind `@unique` / nullable / `SetNull`, which this migration
14
+ -- intentionally does not restate.
15
+ --
16
+ -- 2. Tightening `database_backup_runs_active_uniq_idx` from a UNIQUE index on
17
+ -- the `status` COLUMN to a UNIQUE index on the CONSTANT EXPRESSION `(true)`
18
+ -- — both filtered to `status IN ('pending','running')`. See the block
19
+ -- comment above `DatabaseBackupRun` in prisma/schema.prisma
20
+ -- ("THE SINGLE-ACTIVE-RUN GUARD...") for the full story; the short version:
21
+ -- the old, column-keyed index admits one `pending` row AND one `running`
22
+ -- row AT THE SAME TIME (two active runs), which was harmless only because
23
+ -- nothing ever wrote `pending`. #351's service half makes
24
+ -- `POST /api/admin/db-backup/runs` enqueue the job and insert the run row
25
+ -- as `pending` before any worker claims it, so `pending` rows become real.
26
+ -- Keying on `(true)` instead makes every active row — whichever status it
27
+ -- holds — collide on the same key, so at most one can exist across both
28
+ -- statuses combined.
29
+ -- =============================================================================
30
+
31
+ -- AlterTable
32
+ ALTER TABLE "database_backup_runs" ADD COLUMN "job_id" UUID;
33
+
34
+ -- CreateIndex
35
+ CREATE UNIQUE INDEX "database_backup_runs_job_id_key" ON "database_backup_runs"("job_id");
36
+
37
+ -- AddForeignKey
38
+ -- onDelete: SetNull, NOT Cascade — `job.history.purge` deletes `jobs` rows on
39
+ -- a retention schedule independent of the archive's own retention. Cascade
40
+ -- would let a routine job-history purge delete the record of a still-restorable
41
+ -- backup. See the block comment above `DatabaseBackupRun.jobId` in
42
+ -- prisma/schema.prisma.
43
+ ALTER TABLE "database_backup_runs" ADD CONSTRAINT "database_backup_runs_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "jobs"("id") ON DELETE SET NULL ON UPDATE CASCADE;
44
+
45
+ -- =============================================================================
46
+ -- HAND-WRITTEN INDEX CHANGE — INTENTIONAL SCHEMA DRIFT (issue #351, epic #345)
47
+ -- =============================================================================
48
+ -- `database_backup_runs_active_uniq_idx` is NOT emitted by `prisma migrate
49
+ -- dev`/`diff` and never will be: Prisma's schema language has no syntax for a
50
+ -- partial index OR an expression index, so both the original (partial, on
51
+ -- `status`) and this tightened form (partial, on the expression `(true)`) are
52
+ -- written here by hand — exactly the drift `20260907120000_add_database_backup
53
+ -- _runs/migration.sql` already carries, now one step further. The block
54
+ -- comment above the `DatabaseBackupRun` model in prisma/schema.prisma records
55
+ -- the same fact for anyone reading the schema without this file open.
56
+ --
57
+ -- ⚠ `prisma migrate dev` WILL WANT TO DROP THIS INDEX on the next diff, the
58
+ -- same as its predecessor. Do not "reconcile" this migration by accepting
59
+ -- that drop, and do not expect `prisma migrate diff` against a live database
60
+ -- to come back clean. This drift is deliberate and permanent for as long as
61
+ -- Prisma's DSL lacks expression indexes.
62
+ -- =============================================================================
63
+
64
+ -- The ORIGINAL guard admitted at most one 'pending' row AND at most one
65
+ -- 'running' row AT THE SAME TIME — two active runs, not one — because the
66
+ -- index was keyed on the `status` column itself: a 'pending' row and a
67
+ -- 'running' row have different key values, so both could satisfy the same
68
+ -- unique index simultaneously. That was harmless only because nothing ever
69
+ -- wrote 'pending'. #351 makes the dump a queue job, and the run row is now
70
+ -- created 'pending' before any worker has claimed it, so 'pending' rows are
71
+ -- no longer hypothetical.
72
+ DROP INDEX "database_backup_runs_active_uniq_idx";
73
+
74
+ -- THE tightened single-active-run guard. Keying on the constant expression
75
+ -- `(true)` — rather than on `status` — makes every row matching the WHERE
76
+ -- predicate (any 'pending' or 'running' row) index to the exact same key, so
77
+ -- Postgres now enforces "at most one active row, full stop" across BOTH
78
+ -- statuses combined, not "at most one of each". A 'completed', 'failed' or
79
+ -- 'stale' row does not match the predicate at all and is never constrained by
80
+ -- this index, so any number of them may coexist.
81
+ --
82
+ -- `DatabaseBackupRunnerService.startBackup` still inserts optimistically and
83
+ -- turns the loser's P2002 into a typed `DatabaseBackupAlreadyRunningError`
84
+ -- carrying the winner's id; there is deliberately no `findFirst` pre-check,
85
+ -- because check-then-act is racy exactly when this constraint matters.
86
+ CREATE UNIQUE INDEX "database_backup_runs_active_uniq_idx"
87
+ ON "database_backup_runs" ((true)) WHERE "status" IN ('pending','running');
@@ -0,0 +1,61 @@
1
+ -- =============================================================================
2
+ -- Per-job secret broker: the handle ledger (issue #349, epic #345)
3
+ -- =============================================================================
4
+ -- One table, and the interesting thing about it is a column that is NOT here.
5
+ --
6
+ -- A worker node holds no credentials by design (docs/specs/worker-nodes.md §8).
7
+ -- Epic #345 needs it to run work that genuinely requires one, so the server
8
+ -- mints a credential per job, hands it over exactly once, and destroys it when
9
+ -- the job settles. `job_node_secrets` records THAT A GRANT EXISTED — which node,
10
+ -- of what kind, from when until when, revoked or not — so "did a node ever hold
11
+ -- a credential to this database, and when" is answerable.
12
+ --
13
+ -- ⚠ THERE IS NO COLUMN CAPABLE OF HOLDING SECRET MATERIAL, AND ADDING ONE IS
14
+ -- THE ONE CHANGE THIS TABLE MUST NEVER RECEIVE. Not `password`, not
15
+ -- `encrypted_material`, not "just a base64 blob for debugging". `handle` is an
16
+ -- identifier for the grant (a role name) and is what `JobSecretBroker.revoke`
17
+ -- takes; the material is serialised into one HTTP response and dropped.
18
+ -- Reusing SECRETS_ENCRYPTION_KEY here was considered and rejected: a credential
19
+ -- that can be re-read is a credential that can be stolen twice, and nothing
20
+ -- needs to re-read this one. Storing it — even encrypted — would put a live
21
+ -- database password into this table, into every pg_dump of it, and into every
22
+ -- copy of that dump. See the block comment above `JobNodeSecret` in
23
+ -- prisma/schema.prisma for the full argument.
24
+ --
25
+ -- NO FOREIGN KEYS, deliberately. `job_id` and `node_id` are plain UUIDs. A
26
+ -- `jobs` row is deleted on a retention schedule (`job.history.purge`) that has
27
+ -- nothing to do with a grant's lifetime, and a cascade from that purge would
28
+ -- erase the audit record this table exists to keep; a grant must also stay
29
+ -- revocable after its job row is gone, which is why the sweeper resolves a
30
+ -- broker by `kind` rather than by the job's `type`.
31
+ -- =============================================================================
32
+
33
+ -- CreateTable
34
+ CREATE TABLE "job_node_secrets" (
35
+ "id" UUID NOT NULL,
36
+ "job_id" UUID NOT NULL,
37
+ "node_id" UUID NOT NULL,
38
+ "kind" TEXT NOT NULL,
39
+ -- The grant's identifier. NEVER the password — see the header.
40
+ "handle" TEXT NOT NULL,
41
+ "issued_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
42
+ "expires_at" TIMESTAMPTZ NOT NULL,
43
+ "revoked_at" TIMESTAMPTZ,
44
+
45
+ CONSTRAINT "job_node_secrets_pkey" PRIMARY KEY ("id")
46
+ );
47
+
48
+ -- CreateIndex
49
+ -- ONE CREDENTIAL PER JOB, EVER. A node asks for its secret more than once as a
50
+ -- matter of course (a restarted process still holding the lease, a lost
51
+ -- response, a retry), and every one of those calls must extend the SAME grant.
52
+ -- The revocation paths know exactly one handle per (job, kind); a sibling grant
53
+ -- would be one nothing ever destroys. This index makes the second row
54
+ -- unrepresentable rather than leaving it to the service to remember.
55
+ CREATE UNIQUE INDEX "job_node_secrets_job_id_kind_key" ON "job_node_secrets"("job_id", "kind");
56
+
57
+ -- CreateIndex
58
+ -- The sweeper's scan: unrevoked grants whose own clock has lapsed. See
59
+ -- `NodeSecretBrokerService.sweep` for why the sweeper is not redundant with the
60
+ -- settle-event revoker — three real cases emit no event at all.
61
+ CREATE INDEX "job_node_secrets_expires_at_idx" ON "job_node_secrets"("expires_at");
@@ -0,0 +1,26 @@
1
+ -- =============================================================================
2
+ -- `database_backup_runs.pg_dump_version` (issue #352, epic #345)
3
+ -- =============================================================================
4
+ -- The fourth member of this table's audit block: WHICH `pg_dump` wrote the
5
+ -- archive.
6
+ --
7
+ -- It was worth nothing while the dump could only run in the API process — the
8
+ -- client is whatever `apps/api/Dockerfile` installed, the same for every run,
9
+ -- and the node-credential preflight already reports it. #352 makes the dump
10
+ -- claimable by a worker node, and on that path the binary lives on a machine
11
+ -- this deployment may not own and cannot inspect after the fact. Without this
12
+ -- column, nothing anywhere records which client produced the file, and
13
+ -- "`pg_restore` cannot read an archive written by a NEWER `pg_dump`" becomes a
14
+ -- fact an operator discovers during a restore rather than one they can check
15
+ -- before starting it (see docs/runbooks/postgres-client-version.md).
16
+ --
17
+ -- Nullable, no default, NO BACKFILL. Every existing row was written by a
18
+ -- client nobody recorded, and inventing this deployment's CURRENT client
19
+ -- version for them would be a lie that reads exactly like evidence. NULL means
20
+ -- "not recorded", which is the truth for every run taken before this migration
21
+ -- and for any run whose `pg_dump --version` could not be read — the same
22
+ -- "warn and proceed" rule the other three audit columns already follow.
23
+ -- =============================================================================
24
+
25
+ -- AlterTable
26
+ ALTER TABLE "database_backup_runs" ADD COLUMN "pg_dump_version" TEXT;
@@ -0,0 +1,92 @@
1
+ -- =============================================================================
2
+ -- jobs.claim_token — per-claim identity, closing the two-replica lease hole
3
+ -- (issue #361)
4
+ -- =============================================================================
5
+ -- `heldLeaseWhere` (`src/jobs/job-lease.service.ts`) answers "does the caller
6
+ -- still hold this row" as `{ id, status: 'running', leaseExpiresAt: { gt: now
7
+ -- }, claimedByNodeId }`. That predicate identifies a CLAIMANT KIND — "a node"
8
+ -- (a real `WorkerNode.id`) vs. "the server" (`claimedByNodeId: null`) — not a
9
+ -- particular claim. Every API replica claims with `claimedByNodeId: null`, so
10
+ -- two server replicas produce the exact same `heldLeaseWhere` predicate for
11
+ -- the same job. Concretely: replica A claims a job, then stalls long enough
12
+ -- for the lease reaper to reclaim it as abandoned; replica B claims the
13
+ -- now-pending job and starts running it; replica A, still alive and unaware
14
+ -- it lost the row, calls `renew` — which matches `heldLeaseWhere` (status is
15
+ -- `running`, `claimedByNodeId` is `null`, and the lease B just set is still
16
+ -- in the future) and happily extends B's lease. A never learns it lost the
17
+ -- job, and now believes it owns work it does not.
18
+ --
19
+ -- The fix is a column that identifies ONE CLAIM, not one KIND of claimant:
20
+ -- `claim_token`, minted fresh (`gen_random_uuid()`) by the claim statement
21
+ -- itself and folded into `heldLeaseWhere` alongside `claimedByNodeId` (wiring
22
+ -- lands in a later slice — this migration and its schema.prisma companion are
23
+ -- schema-only, per the issue's stated scope). Once every renew/settle/reap
24
+ -- statement also matches on `claim_token`, replica A's renewal after losing
25
+ -- the row targets a token that is no longer on the row (B's claim overwrote
26
+ -- it with a new one) and the `UPDATE` matches zero rows, exactly like a node
27
+ -- losing a lease already does today.
28
+ --
29
+ -- -----------------------------------------------------------------------------
30
+ -- Why NULLABLE, with no DEFAULT
31
+ -- -----------------------------------------------------------------------------
32
+ -- The invariant this codebase will maintain from here on is `claim_token IS
33
+ -- NOT NULL` IFF the row is currently held under a claim. A `pending` job has
34
+ -- no claimant at all, so writing it a token would assert a claim that does
35
+ -- not exist — a lie the column has no business telling. `DEFAULT
36
+ -- gen_random_uuid()` would do exactly that: every row, including ones freshly
37
+ -- enqueued and never claimed, would carry a token that means nothing,
38
+ -- indistinguishable from a real one to anybody reading the column.
39
+ --
40
+ -- No backfill, and none is wanted. Every row that exists at deploy time is
41
+ -- either not currently claimed (`pending`, `succeeded`, `failed` — `NULL` is
42
+ -- simply correct) or `running` under a claim taken by a REPLICA RUNNING THE
43
+ -- OLD CODE, which never wrote a token and has no token to reconcile against.
44
+ -- `NULL` is still correct there, but the reason an old replica keeps renewing
45
+ -- successfully has nothing to do with NULL comparison semantics — it is
46
+ -- simpler than that: an old replica is running the OLD `heldLeaseWhere`,
47
+ -- which has no `claim_token` clause at all, so it never passes a token,
48
+ -- remembered or otherwise, and this column is invisible to its predicate.
49
+ -- The later slice's `heldLeaseWhere(jobId, { nodeId?, claimToken? })` treats
50
+ -- `claimToken` the same three-valued way `nodeId` already works: a string
51
+ -- constrains the match to that one claim, `undefined` omits the clause
52
+ -- entirely (what the node control plane and this migration-era code both do),
53
+ -- and `null` is passed straight through to Prisma and renders as an actual
54
+ -- `claim_token IS NULL` check — matching only a row that carries no token.
55
+ --
56
+ -- That makes the rolling-deploy story a narrowing, not a full close, and only
57
+ -- in one direction: a NEW replica's claim writes a real token, so no other
58
+ -- NEW replica can renew it out from under the claimant — the hole
59
+ -- is closed between new replicas from the moment this ships. An OLD
60
+ -- replica's `renew` still ignores `claim_token` altogether, so an old
61
+ -- replica that lost a row to the reaper can still extend a NEW replica's
62
+ -- lease for as long as the old replica keeps ticking — the same failure mode
63
+ -- this column exists to fix, just now confined to "one side of the pair is
64
+ -- still running pre-#361 code" instead of "always". That window is bounded
65
+ -- and ordinary: it closes for good once the last old replica has rolled off,
66
+ -- the same way any predicate change behaves during a rolling deploy, and is
67
+ -- not a reason to change this design.
68
+ --
69
+ -- -----------------------------------------------------------------------------
70
+ -- Why no index
71
+ -- -----------------------------------------------------------------------------
72
+ -- `claim_token` is never queried on its own — every read of it is one more
73
+ -- predicate bolted onto a single-row lookup already keyed by `jobs`'s primary
74
+ -- key (`WHERE id = $1 AND claim_token = $2 AND ...`), the same shape
75
+ -- `heldLeaseWhere` already has for `claimedByNodeId` and `leaseExpiresAt`,
76
+ -- neither of which is indexed on its own either. An index earns its keep by
77
+ -- narrowing a scan that would otherwise be large; there is no query here that
78
+ -- scans `jobs` by `claim_token` and needs narrowing. Adding one would be pure
79
+ -- write overhead on the hottest path this table has — every claim, every
80
+ -- renewal, every settle — for a lookup nothing performs.
81
+ --
82
+ -- -----------------------------------------------------------------------------
83
+ -- Online-safety
84
+ -- -----------------------------------------------------------------------------
85
+ -- `ADD COLUMN` of a NULLABLE column with NO DEFAULT is a metadata-only change
86
+ -- on PostgreSQL 11+: no table rewrite, no full-table scan to populate a
87
+ -- default, just a brief ACCESS EXCLUSIVE lock to update the catalog. Safe to
88
+ -- run against a live, populated `jobs` table with no maintenance window.
89
+ -- =============================================================================
90
+
91
+ -- AlterTable
92
+ ALTER TABLE "jobs" ADD COLUMN "claim_token" UUID;
@@ -0,0 +1,108 @@
1
+ -- CreateTable
2
+ CREATE TABLE "ai_models" (
3
+ "id" UUID NOT NULL,
4
+ "provider" TEXT NOT NULL,
5
+ "model_id" TEXT NOT NULL,
6
+ "display_name" TEXT,
7
+ "capabilities" JSONB NOT NULL,
8
+ "capability_source" TEXT NOT NULL DEFAULT 'unclassified',
9
+ "enabled" BOOLEAN NOT NULL DEFAULT false,
10
+ "context_window" INTEGER,
11
+ "max_output_tokens" INTEGER,
12
+ "discovered_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
13
+ "last_seen_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
14
+ "deprecated_at" TIMESTAMPTZ,
15
+ "updated_by_user_id" UUID,
16
+ "updated_at" TIMESTAMPTZ NOT NULL,
17
+
18
+ CONSTRAINT "ai_models_pkey" PRIMARY KEY ("id")
19
+ );
20
+
21
+ -- CreateTable
22
+ CREATE TABLE "user_ai_keys" (
23
+ "id" UUID NOT NULL,
24
+ "user_id" UUID NOT NULL,
25
+ "provider" TEXT NOT NULL,
26
+ "secret" TEXT NOT NULL,
27
+ "hint" TEXT,
28
+ "verified_at" TIMESTAMPTZ,
29
+ "last_error_code" TEXT,
30
+ "reachable_model_ids" TEXT[] DEFAULT ARRAY[]::TEXT[],
31
+ "reachable_checked_at" TIMESTAMPTZ,
32
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
33
+ "updated_at" TIMESTAMPTZ NOT NULL,
34
+
35
+ CONSTRAINT "user_ai_keys_pkey" PRIMARY KEY ("id")
36
+ );
37
+
38
+ -- CreateTable
39
+ CREATE TABLE "ai_runs" (
40
+ "id" UUID NOT NULL,
41
+ "user_id" UUID,
42
+ "job_id" UUID,
43
+ "status" TEXT NOT NULL DEFAULT 'pending',
44
+ "provider" TEXT NOT NULL,
45
+ "model_id" TEXT NOT NULL,
46
+ "request" JSONB NOT NULL,
47
+ "output" JSONB,
48
+ "error_code" TEXT,
49
+ "error_message" TEXT,
50
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
51
+ "updated_at" TIMESTAMPTZ NOT NULL,
52
+ "completed_at" TIMESTAMPTZ,
53
+
54
+ CONSTRAINT "ai_runs_pkey" PRIMARY KEY ("id")
55
+ );
56
+
57
+ -- CreateTable
58
+ CREATE TABLE "ai_usage_events" (
59
+ "id" UUID NOT NULL,
60
+ "user_id" UUID,
61
+ "provider" TEXT NOT NULL,
62
+ "model_id" TEXT NOT NULL,
63
+ "operation" TEXT NOT NULL,
64
+ "key_source" TEXT NOT NULL,
65
+ "input_tokens" INTEGER,
66
+ "output_tokens" INTEGER,
67
+ "reasoning_tokens" INTEGER,
68
+ "cached_input_tokens" INTEGER,
69
+ "units" JSONB,
70
+ "latency_ms" INTEGER NOT NULL,
71
+ "status" TEXT NOT NULL,
72
+ "error_code" TEXT,
73
+ "provider_request_id" TEXT,
74
+ "job_id" UUID,
75
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
76
+
77
+ CONSTRAINT "ai_usage_events_pkey" PRIMARY KEY ("id")
78
+ );
79
+
80
+ -- CreateIndex
81
+ CREATE INDEX "ai_models_provider_enabled_idx" ON "ai_models"("provider", "enabled");
82
+
83
+ -- CreateIndex
84
+ CREATE UNIQUE INDEX "ai_models_provider_model_id_key" ON "ai_models"("provider", "model_id");
85
+
86
+ -- CreateIndex
87
+ CREATE UNIQUE INDEX "user_ai_keys_user_id_provider_key" ON "user_ai_keys"("user_id", "provider");
88
+
89
+ -- CreateIndex
90
+ CREATE INDEX "ai_runs_user_id_created_at_idx" ON "ai_runs"("user_id", "created_at");
91
+
92
+ -- CreateIndex
93
+ CREATE INDEX "ai_usage_events_user_id_created_at_idx" ON "ai_usage_events"("user_id", "created_at");
94
+
95
+ -- CreateIndex
96
+ CREATE INDEX "ai_usage_events_created_at_idx" ON "ai_usage_events"("created_at");
97
+
98
+ -- AddForeignKey
99
+ ALTER TABLE "ai_models" ADD CONSTRAINT "ai_models_updated_by_user_id_fkey" FOREIGN KEY ("updated_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
100
+
101
+ -- AddForeignKey
102
+ ALTER TABLE "user_ai_keys" ADD CONSTRAINT "user_ai_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
103
+
104
+ -- AddForeignKey
105
+ ALTER TABLE "ai_runs" ADD CONSTRAINT "ai_runs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
106
+
107
+ -- AddForeignKey
108
+ ALTER TABLE "ai_usage_events" ADD CONSTRAINT "ai_usage_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;