@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,824 @@
1
+ // GENERATED by platform db compose — do not edit
2
+ // source: package:jobs.prisma
3
+
4
+ // =============================================================================
5
+ // Jobs (issue #255, epic #254)
6
+ // =============================================================================
7
+ //
8
+ // A generic background-job queue table: one row per unit of work a worker
9
+ // node claims, runs, and reports the outcome of. This issue adds only the
10
+ // schema — the enqueue path, the claim query, and the node plane that
11
+ // actually pulls jobs off this table all arrive in later issues of #254
12
+ // (the claim query's `RETURNING` aliases land in #260, the `WorkerNode` model
13
+ // that `claimedByNodeId` will finally point at lands with the node plane).
14
+ // What is here is deliberately complete on its own: every column and index a
15
+ // worker needs to claim, execute, retry, and rate-limit a job, so nothing
16
+ // downstream requires a follow-up migration to `jobs` itself.
17
+ //
18
+ // -----------------------------------------------------------------------------
19
+ // subjectType / subjectId — plain nullable TEXT, not typed or FK'd columns
20
+ // -----------------------------------------------------------------------------
21
+ // REJECTED: a column per subject shape (`mediaItemId`, `circleId`, ...). This
22
+ // table is generic across every handler `type` this app will ever register,
23
+ // present and future. A typed column per subject means every new job kind
24
+ // that targets a new kind of thing is a migration to this table, which is
25
+ // exactly the coupling a job queue must not have to its callers — the whole
26
+ // point of `type` being a free-form handler key is that new handlers cost
27
+ // zero schema changes. `subjectType` (a short discriminator string the
28
+ // handler itself defines, e.g. `'media_item'`) plus `subjectId` (that
29
+ // subject's id, stored as text specifically so it is not assumed to be a
30
+ // UUID — some future subject might key by something else) covers every shape
31
+ // with two columns, forever.
32
+ //
33
+ // REJECTED: payload-only, with no subject columns at all (dig the subject id
34
+ // out of the JSONB `payload` when you need it). This is what `[subjectType,
35
+ // subjectId]` below exists to make wrong: "cancel every pending job for this
36
+ // media item" and "is there already an active job for this exact subject"
37
+ // (the dedup index just below) are both real, known queries (not
38
+ // speculative), and neither is expressible as an index over JSONB without a
39
+ // GIN index and a much more awkward query. Two plain columns, indexed
40
+ // normally, are simpler and cheaper than making Postgres reach into JSON for
41
+ // a value that is known and named at enqueue time regardless.
42
+ //
43
+ // Both nullable, and BOTH null together is the documented spelling of "this
44
+ // is a global/system job" (a nightly sweep, a cache warm) that is not about
45
+ // any one row elsewhere in the database — there is no sentinel subjectType
46
+ // like `'system'` to keep in sync with anything.
47
+ //
48
+ // -----------------------------------------------------------------------------
49
+ // dedupKey / the active-dedup unique index — enforced by a hand-written
50
+ // partial index, NOT `findFirst` then `create` in the enqueue service
51
+ // -----------------------------------------------------------------------------
52
+ // REJECTED: "check whether an active job with this key already exists, and
53
+ // only insert if not" as application logic. That is a check-then-act race:
54
+ // two concurrent enqueue calls can both see "no active duplicate" and both
55
+ // insert, which is precisely the failure a dedup key exists to prevent, and
56
+ // under real concurrent load (a webhook retried while the first delivery is
57
+ // still being handled, two nodes reacting to the same event) it is not a
58
+ // rare edge case. Only the database can make "is there already an active job
59
+ // with this key" atomic with the insert that would violate it, which is what
60
+ // `jobs_active_dedup_uniq_idx` below is for: a partial UNIQUE index on
61
+ // `dedup_key` filtered to `status IN ('pending','running')`. The enqueue path
62
+ // (arriving with #260) inserts optimistically and treats the resulting
63
+ // unique-violation as "a duplicate is already in flight", not as an error —
64
+ // no pre-check query, no race window.
65
+ //
66
+ // The partial filter is exactly what makes the key reusable: once a job
67
+ // reaches `succeeded` (or `failed`) it drops out of the index's predicate, so
68
+ // the same `dedup_key` is free again for a new active job — retrying or
69
+ // re-triggering the same logical work later is not permanently blocked by
70
+ // its own history. `dedup_key IS NOT NULL` in the predicate is what lets
71
+ // jobs that opt out of dedup (most ad-hoc jobs) insert freely with a NULL key
72
+ // — Postgres does not consider NULLs equal for uniqueness, but the explicit
73
+ // clause documents that this is relied upon, not incidental.
74
+ //
75
+ // The key's actual format (`type:subjectType:subjectId`) is defined ONCE, in
76
+ // `buildDedupKey()` (`src/jobs/job-keys.ts`), which this comment and the
77
+ // migration's raw SQL both point back to rather than restating it — see that
78
+ // file for why the format is what it is.
79
+ //
80
+ // -----------------------------------------------------------------------------
81
+ // priority — ASCENDING is more urgent
82
+ // -----------------------------------------------------------------------------
83
+ // Chosen so `ORDER BY priority ASC, created_at ASC` is both "most urgent
84
+ // first" and "oldest first within a priority", the same direction Postgres
85
+ // already walks a plain ascending b-tree index in — no `DESC` index variant
86
+ // needed for the hot claim-query ordering that `[status, priority,
87
+ // createdAt]` and `[status, scheduledFor, priority, createdAt]` below exist
88
+ // to serve. `0` is the default (normal) priority; more urgent work is a
89
+ // smaller (including negative) number, not a larger one.
90
+ //
91
+ // -----------------------------------------------------------------------------
92
+ // attempts vs rateLimitedAt/rateLimitHits — two counters for two different
93
+ // reasons a job didn't finish, deliberately not merged into one
94
+ // -----------------------------------------------------------------------------
95
+ // `attempts` is charged AT CLAIM TIME (incremented when a worker takes the
96
+ // job, not when it fails) and is what a max-retry policy reads. Being rate
97
+ // limited by a downstream provider is not the job's fault and is not a
98
+ // retry in that sense — charging it against `attempts` would make a job that
99
+ // is simply waiting its turn look like a job that keeps failing, and could
100
+ // exhaust its retry budget for a reason that has nothing to do with the
101
+ // job's own correctness. `rateLimitedAt` / `rateLimitHits` track that
102
+ // separately: `scheduledFor` is pushed out to defer the job, `rateLimitHits`
103
+ // records how often this has happened (for observability/alerting on a
104
+ // provider that is persistently saturated), and neither touches `attempts`.
105
+ //
106
+ // -----------------------------------------------------------------------------
107
+ // providerKey / modelVersion — audit of WHAT ran the job, not inputs to it
108
+ // -----------------------------------------------------------------------------
109
+ // Both nullable and both write-once-at-completion-time metadata: which
110
+ // downstream provider handled this job and which model/version it ran
111
+ // against, for after-the-fact questions ("did the incident affect jobs that
112
+ // ran on provider X" / "which jobs ran before the model version bump").
113
+ // Neither is read back by the claim query — they exist for audit, not for
114
+ // scheduling.
115
+ //
116
+ // -----------------------------------------------------------------------------
117
+ // claimedByNodeId — a real relation as of issue #267, epic #254
118
+ // -----------------------------------------------------------------------------
119
+ // This column existed unconstrained (`String? @db.Uuid`) from #255 onward, so
120
+ // the claim query and its indexes ([status, leaseExpiresAt],
121
+ // [claimedByNodeId]) could be built against their final shape before the
122
+ // `WorkerNode` table it references existed. #267 adds that table and wires
123
+ // the FK on top of this already-populated column: `onDelete: SetNull`, so a
124
+ // node's disappearance (deregistered, pruned, or manually deleted by an
125
+ // admin) frees the jobs it was holding — via `leaseExpiresAt` already having
126
+ // them look abandoned to the reaper — rather than deleting job history. See
127
+ // the block comment above the `WorkerNode` model for the corresponding
128
+ // `onDelete` decision on `WorkerNode.createdBy`, which is a different
129
+ // question (what happens to the NODE when its owning USER is deleted, not
130
+ // what happens to a JOB when its claiming NODE is deleted).
131
+ //
132
+ // -----------------------------------------------------------------------------
133
+ // claimToken — per-claim identity, closing the two-replica lease hole
134
+ // (issue #361)
135
+ // -----------------------------------------------------------------------------
136
+ // `claimedByNodeId` distinguishes a node's claim from the server's, but every
137
+ // API replica claims with `claimedByNodeId: null` — so two different server
138
+ // replicas produce an IDENTICAL `heldLeaseWhere` predicate for the same job,
139
+ // and one replica's renewal cannot be told apart from another's. `claimToken`
140
+ // is minted fresh per claim (not per process, not per batch — see the field
141
+ // comment below for why that distinction matters) and is the extra predicate
142
+ // that makes a specific claim, not just a specific kind of claimant,
143
+ // checkable. Nullable with no default, same as `claimedByNodeId` and
144
+ // `leaseExpiresAt`: `NULL` iff not currently claimed, no backfill, no index —
145
+ // see the migration header (`20260908120000_add_job_claim_token`) for the
146
+ // full argument, which this comment does not restate.
147
+ //
148
+ // -----------------------------------------------------------------------------
149
+ // sumDurationMs on JobStatsRollup — Float (double precision), NOT BigInt
150
+ // -----------------------------------------------------------------------------
151
+ // REJECTED: BigInt. Prisma maps a BigInt column to the JS `bigint` primitive,
152
+ // which `JSON.stringify` refuses outright ("Do not know how to serialize a
153
+ // BigInt") — the not-yet-built insights endpoint that reads this rollup would
154
+ // crash on its very first response, not degrade gracefully. A running sum of
155
+ // per-job durations in milliseconds, even at very high job volume, never
156
+ // approaches the ~2^53 safe-integer boundary where `Float` would start
157
+ // losing precision that matters for an aggregate (an average duration off by
158
+ // fractions of a millisecond at that scale is not a real-world concern);
159
+ // BigInt's exactness buys nothing here and costs a runtime crash.
160
+ //
161
+ // -----------------------------------------------------------------------------
162
+ // Prisma-expressible indexes vs the three below written directly in the
163
+ // migration (INTENTIONAL SCHEMA DRIFT — see migration.sql in
164
+ // 20260906120000_add_jobs)
165
+ // -----------------------------------------------------------------------------
166
+ // Everything Prisma's index DSL can express is declared with `@@index` below.
167
+ // Three more indexes exist ONLY in `migration.sql` because the Prisma schema
168
+ // language has no syntax for a partial index (a `WHERE` clause on the index
169
+ // itself) — `@@index` always builds a full index over every row:
170
+ //
171
+ // - "jobs_active_dedup_uniq_idx": the partial UNIQUE index described above
172
+ // that actually enforces the active-dedup constraint (`dedup_key` where
173
+ // `status IN ('pending','running') AND dedup_key IS NOT NULL`).
174
+ // - "jobs_attempts_gt1_idx": a partial index on `attempts` where
175
+ // `attempts > 1`, sized to the small minority of jobs that have ever
176
+ // been retried, for "show me flaky jobs" without indexing the (large)
177
+ // majority that succeeded on the first attempt.
178
+ // - "jobs_succeeded_duration_idx": a partial index on
179
+ // `(finished_at, started_at, type)` where `status = 'succeeded' AND
180
+ // started_at IS NOT NULL AND finished_at IS NOT NULL`, for computing
181
+ // per-type duration stats (what feeds `JobStatsRollup`) without scanning
182
+ // jobs that were never actually timed.
183
+ //
184
+ // Because `prisma migrate dev`/`diff` cannot regenerate these from the
185
+ // schema file, they will show up as "drift" if anyone ever diffs this schema
186
+ // against the live database with a tool that expects `@@index` to be the
187
+ // full story. That drift is intentional and this comment (plus the ones in
188
+ // the migration itself) is the record of why — do not "fix" it by dropping
189
+ // these three indexes, and do not try to force them into `@@index` syntax
190
+ // that does not exist.
191
+ // @extensible — back-relations only, added with "extend model Job { ... }" in a fragment
192
+ model Job {
193
+ id String @id @default(uuid()) @db.Uuid
194
+
195
+ // The handler key a worker dispatches on (e.g. `'email.send'`,
196
+ // `'export.csv'`). A small, code-owned, growing set — deliberately a plain
197
+ // string and not a Prisma enum, for the same reason `NotificationDelivery
198
+ // .channel` and `.eventKey` are plain strings: new handlers must cost zero
199
+ // migrations, and a row must be able to outlive the handler that produced
200
+ // it (a removed/renamed handler's historical jobs stay queryable).
201
+ type String
202
+
203
+ // See the block comment above for why these are plain nullable TEXT rather
204
+ // than typed/FK'd columns, and why both null together means a
205
+ // global/system job.
206
+ subjectType String? @map("subject_type")
207
+ subjectId String? @map("subject_id")
208
+
209
+ // See the block comment above ("dedupKey / the active-dedup unique index")
210
+ // for the full mechanism. Built by `buildDedupKey()`
211
+ // (`src/jobs/job-keys.ts`); nullable because most jobs opt out of dedup
212
+ // entirely.
213
+ dedupKey String? @map("dedup_key")
214
+
215
+ status JobStatus @default(pending)
216
+
217
+ // Why the job was queued: a plain string (an open vocabulary), not a Prisma
218
+ // enum, for the reason `Job.type` is one. The built-ins are `upload`, `rerun`
219
+ // and `backfill`; an app adds its own through `registerJobReason`
220
+ // (`@marinoscar/platform-api/jobs`) at zero migrations. The set a row may
221
+ // carry is bounded by the contract's `jobReasonSchema` pattern at the API
222
+ // edge, not by a CHECK constraint, so a row outlives the reason that wrote it.
223
+ reason String
224
+
225
+ // Ascending = more urgent. See the block comment above.
226
+ priority Int @default(0)
227
+
228
+ // Audit of what actually ran this job — see the block comment above.
229
+ // Written once, at completion; never read by the claim query.
230
+ providerKey String? @map("provider_key")
231
+ modelVersion String? @map("model_version")
232
+
233
+ // Handler-defined input, opaque to this table.
234
+ payload Json? @db.JsonB
235
+
236
+ // Charged AT CLAIM TIME (see the block comment above for why this is kept
237
+ // separate from rateLimitHits).
238
+ attempts Int @default(0)
239
+
240
+ lastError String? @map("last_error")
241
+
242
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
243
+ startedAt DateTime? @map("started_at") @db.Timestamptz
244
+ finishedAt DateTime? @map("finished_at") @db.Timestamptz
245
+
246
+ // NULL means eligible now. A non-null value in the future is what makes a
247
+ // job invisible to the claim query until then (deferred rate-limit retries,
248
+ // deliberately scheduled work).
249
+ scheduledFor DateTime? @map("scheduled_for") @db.Timestamptz
250
+
251
+ // Deferral bookkeeping, deliberately separate from `attempts` — see the
252
+ // block comment above.
253
+ rateLimitedAt DateTime? @map("rate_limited_at") @db.Timestamptz
254
+ rateLimitHits Int @default(0) @map("rate_limit_hits")
255
+
256
+ // Node claiming. See the block comment above ("claimedByNodeId") for why
257
+ // this relation arrived a migration after the column did, and why
258
+ // `onDelete: SetNull` is correct here specifically.
259
+ claimedByNodeId String? @map("claimed_by_node_id") @db.Uuid
260
+ claimedByNode WorkerNode? @relation("NodeClaimedJobs", fields: [claimedByNodeId], references: [id], onDelete: SetNull)
261
+ leaseExpiresAt DateTime? @map("lease_expires_at") @db.Timestamptz
262
+ // 'server' | 'node' — which side actually executed the job. A plain
263
+ // string, not an enum, for the same "small closed set, but code-owned and
264
+ // not this table's own state machine" reasoning as
265
+ // NotificationDelivery.channel above.
266
+ executor String?
267
+
268
+ // The identity of ONE CLAIM, not one KIND of claimant (issue #361). Minted
269
+ // fresh by the claim statement itself (`gen_random_uuid()` in
270
+ // `job-claim.service.ts`) every time a job is claimed, so "do I still hold
271
+ // this row" is answerable without reference to who is asking —
272
+ // `claimedByNodeId` distinguishes a node's claim from the server's, but
273
+ // every server replica claims with `claimedByNodeId: null`, which makes
274
+ // two different replicas holding the SAME job at different times
275
+ // indistinguishable from each other. A token minted per row closes that:
276
+ // it must be minted PER CLAIM, not once per claiming process or per batch,
277
+ // because a process-level token has the identical defect one level up — it
278
+ // cannot tell one process apart from itself across two separate claims of
279
+ // the same job (claim, lose it to the reaper, claim it again).
280
+ //
281
+ // Non-null exactly while the row is claimed — the same "non-null iff held"
282
+ // invariant `claimedByNodeId`/`leaseExpiresAt` already carry. The claim
283
+ // writes it alongside them; every un-claim path (settle, the lease reaper,
284
+ // a retry reset) clears it in the same statement that clears them, never
285
+ // on its own.
286
+ //
287
+ // Deliberately NOT exposed by the admin API — `JOB_LIST_SELECT`
288
+ // (`job-admin.service.ts`) omits it. It is internal ownership machinery
289
+ // with no operator-facing meaning; an admin reading the job list cares
290
+ // which node holds a job, not which of that node's (or the server's)
291
+ // claims it currently is.
292
+ claimToken String? @map("claim_token") @db.Uuid
293
+
294
+ // The W3C `traceparent` of the span that was active when this job was
295
+ // ENQUEUED (issue #607), or NULL when nothing was being traced (OTel off, a
296
+ // cron tick with no active span, a row written before this column existed).
297
+ // Written once, by `JobsService` at insert time (`job-trace-context.ts`
298
+ // validates the format and bounds it at 256 characters); never updated.
299
+ // The server worker starts the job's span as a CHILD of it, and the node
300
+ // claim hands it to the node, so queued work stays connected to the request
301
+ // that queued it. Plain nullable TEXT: it is an opaque correlation handle,
302
+ // never a key, never indexed, never read by the claim query's WHERE.
303
+ traceContext String? @map("trace_context")
304
+
305
+ // The organization the work belongs to (issue #734), or NULL for a
306
+ // deployment-wide (system) job: housekeeping, fleet sweeps, backups. Written
307
+ // once at enqueue (`EnqueueJobInput.orgId`, else the app's ambient scope);
308
+ // never updated. `SetNull`: deleting an organization keeps its job history
309
+ // (offboarding cancels the org's pending jobs explicitly first, #743).
310
+ //
311
+ // ⚠ NO ROW-LEVEL SECURITY ON `jobs`, deliberately. The claim is ONE
312
+ // cross-organization statement (`FOR UPDATE SKIP LOCKED` over the whole
313
+ // queue, `job-claim.service.ts`), and a per-org policy would need the bypass
314
+ // connection for every claim, renewal and settle. Isolation is enforced at
315
+ // the API: the admin routes are system routes (`jobs:read` / `jobs:write`).
316
+ // A handler that touches an org table runs under `JobScope.run(job, fn)`.
317
+ //
318
+ // ⚠ NOT PART OF THE DEDUP KEY. `jobs_active_dedup_uniq_idx` is unchanged; an
319
+ // org-scoped job with no natural subject uses `subjectType: 'organization',
320
+ // subjectId: orgId`, so two organizations never collide.
321
+ orgId String? @map("org_id") @db.Uuid
322
+ org Organization? @relation(fields: [orgId], references: [id], onDelete: SetNull)
323
+
324
+ // See the block comment above ("Prisma-expressible indexes...") for why
325
+ // these eight are declared here and three more exist only in
326
+ // migration.sql.
327
+ //
328
+ // The hot claim-query orderings:
329
+
330
+ // composed from package:db-backup.prisma
331
+ // The `database_backup_runs` row this job produced, when `type` is
332
+ // `'db.backup.run'` (epic #345, issue #351). See `DatabaseBackupRun.jobId`
333
+ // for the full reasoning behind `@unique`/nullable/`SetNull` — this is
334
+ // just the required other half of that one-to-one relation.
335
+ backupRun DatabaseBackupRun?
336
+
337
+ // composed from app:kvox.ask.prisma
338
+ // Back-relations onto the platform's Job (an `extend model` block holds back-relation fields only).
339
+ // The `ask_messages` assistant turn this job is producing (or produced),
340
+ // when `type` is `'ask.respond'` (epic #348, issues #376/#378). Same
341
+ // one-to-one shape as `noteGeneration` above, for the same reason:
342
+ // `AskMessage.jobId` is `@unique`/nullable/`SetNull` because a saved
343
+ // conversation outlives `job.history.purge`'s schedule for this row.
344
+ askMessage AskMessage?
345
+
346
+ // composed from app:kvox.graph.prisma
347
+ // Back-relations onto the platform's Job (an `extend model` block holds back-relation fields only).
348
+ // The `kg_proposals` row this job is extracting (or extracted) into, when
349
+ // `type` is `'kg.extract'` (epic #344, issue #351). Same one-to-one shape
350
+ // as `transcriptNameCheck`/`noteGeneration`/`noteExport`/`transcriptExport`/
351
+ // `backupRun` above, for the same reason: `KgProposal.jobId` is
352
+ // `@unique`/nullable/`SetNull` because a proposal's own review lifetime is
353
+ // independent of `job.history.purge`'s schedule for this row.
354
+ kgProposal KgProposal?
355
+
356
+ // The `kg_exports` row this job rendered, when `type` is `'kg.export'`
357
+ // (issue #386). The `noteExport` shape exactly: `KgExport.jobId` is
358
+ // `@unique`/nullable/`SetNull` because an export's own 7-day retention is
359
+ // independent of `job.history.purge`'s schedule for this row.
360
+ kgExport KgExport?
361
+
362
+ // composed from app:kvox.notes.prisma
363
+ // Back-relations onto the platform's Job (an `extend model` block holds back-relation fields only).
364
+ // The `note_exports` row this job rendered, when `type` is `'note.export'`
365
+ // (issue #48, epic #45). Identical one-to-one shape as `transcriptExport`
366
+ // immediately above, for the identical reason: `NoteExport.jobId` is
367
+ // `@unique`/nullable/`SetNull` because export rows have their own 7-day
368
+ // retention (`notes.housekeeping`), independent of `job.history.purge`'s
369
+ // schedule for this row.
370
+ noteExport NoteExport?
371
+
372
+ // The `note_generations` row this job is producing (or produced), when
373
+ // `type` is `'note.generate'` (issue #48, epic #45). Same one-to-one
374
+ // shape as `noteExport`/`transcriptExport`/`backupRun` above, for the
375
+ // same reason: `NoteGeneration.jobId` is `@unique`/nullable/`SetNull`
376
+ // because a generation's own lifetime (read by the SSE stream and the
377
+ // note detail page) is independent of `job.history.purge`'s schedule for
378
+ // this row.
379
+ noteGeneration NoteGeneration?
380
+
381
+ // composed from app:kvox.transcripts.prisma
382
+ // Back-relations onto the platform's Job (an `extend model` block holds back-relation fields only).
383
+ // The `transcript_exports` row this job rendered, when `type` is
384
+ // `'transcript.export'` (issue #24, epic #19). Same one-to-one shape as
385
+ // `backupRun` immediately above, for the same reason: `TranscriptExport
386
+ // .jobId` is `@unique`/nullable/`SetNull` because export rows have their
387
+ // own 7-day retention (`transcripts.housekeeping`), independent of
388
+ // `job.history.purge`'s schedule for this row.
389
+ transcriptExport TranscriptExport?
390
+
391
+ // The `transcript_name_checks` row this job ran, when `type` is
392
+ // `'transcript.name_check'` (issue #328, epic #326). Same one-to-one shape as
393
+ // `transcriptExport`/`noteExport`/`noteGeneration` above, for the same
394
+ // reason: `TranscriptNameCheck.jobId` is `@unique`/nullable/`SetNull`
395
+ // because a run's own lifetime (read by the review UI long after job
396
+ // history is purged) is independent of `job.history.purge`'s schedule for
397
+ // this row.
398
+ transcriptNameCheck TranscriptNameCheck?
399
+
400
+ @@index([status, priority, createdAt])
401
+ @@index([status, scheduledFor, priority, createdAt])
402
+ // "Every job for this subject" / "is there already a job for this exact
403
+ // subject":
404
+ @@index([subjectType, subjectId])
405
+ // "How is this handler doing" (per-type status breakdown):
406
+ @@index([type, status])
407
+ // Lease expiry sweep (finding jobs whose claiming node went away):
408
+ @@index([status, leaseExpiresAt])
409
+ // "What is this node holding right now" — also backs the FK added by #267:
410
+ @@index([claimedByNodeId])
411
+ // Recency listings (admin job list, "what just happened"):
412
+ @@index([createdAt(sort: Desc)])
413
+ // Covering index: the two unconditional admin groupBy counts
414
+ // (`groupBy({ by: ['status'] })`-shaped queries) are answered entirely from
415
+ // this index — status, type and id are all it needs to project — so
416
+ // Postgres never has to visit the heap for them (index-only scan).
417
+ @@index([status, type, id])
418
+ // "This organization's jobs by status" (the admin list's `orgId` filter,
419
+ // offboarding's pending-job cancel; #734). Built CONCURRENTLY in a migration
420
+ // of its own (`0031_add_jobs_org_id_status_index`): `jobs` is a big table.
421
+ @@index([orgId, status])
422
+ @@map("jobs")
423
+ }
424
+
425
+ enum JobStatus {
426
+ pending
427
+ running
428
+ succeeded
429
+ failed
430
+ }
431
+
432
+ // =============================================================================
433
+ // Job Stats Rollup (issue #255, epic #254)
434
+ // =============================================================================
435
+ //
436
+ // One row per `type` (Job.type), maintained incrementally as jobs finish
437
+ // rather than computed by aggregating the full `jobs` table on every read —
438
+ // `jobs` is expected to grow without bound (or be pruned independently of
439
+ // this rollup's needs), so a per-type COUNT/AVG over it is not something the
440
+ // not-yet-built insights endpoint should be doing on every request.
441
+ //
442
+ // `sumDurationMs` is `Float`, deliberately not `BigInt` — see the block
443
+ // comment above the `Job` model ("sumDurationMs on JobStatsRollup") for why
444
+ // BigInt would crash `JSON.stringify` at the exact place this rollup is
445
+ // meant to be read from. Average duration for a type is
446
+ // `sumDurationMs / durationSamples`, computed by the reader; this table
447
+ // stores the two accumulators, not the derived average, so re-deriving after
448
+ // a correction never requires touching historical precision.
449
+ model JobStatsRollup {
450
+ type String @id
451
+
452
+ succeededCount Int @default(0) @map("succeeded_count")
453
+ failedCount Int @default(0) @map("failed_count")
454
+
455
+ // See the block comment above for why Float, not BigInt.
456
+ sumDurationMs Float @default(0) @map("sum_duration_ms")
457
+ durationSamples Int @default(0) @map("duration_samples")
458
+
459
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
460
+
461
+ @@map("job_stats_rollup")
462
+ }
463
+
464
+ // =============================================================================
465
+ // Worker Nodes (issue #267, epic #254)
466
+ // =============================================================================
467
+ //
468
+ // This issue adds only the schema half of the node plane: `WorkerNode`
469
+ // itself, `NodeCredential` (the `nod_` bearer credential a node authenticates
470
+ // with), and the `Job.claimedByNode` relation deferred by #255 (see the
471
+ // block comment above the `Job` model, "claimedByNodeId"). The service that
472
+ // mints/validates `NodeCredential` tokens, the `/api/node-credentials`
473
+ // endpoints, and the `Bearer nod_` branch in `jwt-auth.guard.ts` all arrive
474
+ // in the same issue's backend half; the register/heartbeat/claim control
475
+ // plane that actually populates and mutates `WorkerNode` rows is #268. What
476
+ // is here is deliberately complete on its own for that later work: every
477
+ // column and index the control plane needs already exists, so #268 requires
478
+ // no follow-up migration to either table.
479
+ //
480
+ // -----------------------------------------------------------------------------
481
+ // Why a `NodeCredential` distinct from `PersonalAccessToken`, not a reused PAT
482
+ // -----------------------------------------------------------------------------
483
+ // A worker node runs unattended for months on a machine its operator may not
484
+ // tightly control. A leaked PAT carries its owning user's FULL authority —
485
+ // admin routes included, if the owner is an admin. A node only ever needs to
486
+ // talk to `/api/nodes/*` (enforced by a route allowlist in the guard, not by
487
+ // this schema), so its credential is a distinct token family precisely so a
488
+ // leak's blast radius is confined to that allowlist even for an admin owner.
489
+ // `NodeCredential` mirrors `PersonalAccessToken`'s shape — same hash/prefix
490
+ // pattern, same show-once-in-full contract — MINUS the duration bookkeeping;
491
+ // see the block comment on `NodeCredential.expiresAt` below for why that one
492
+ // column is deliberately not carried over.
493
+ model WorkerNode {
494
+ id String @id @default(uuid()) @db.Uuid
495
+
496
+ name String
497
+
498
+ // Self-reported identity, refreshed on every successful register/reattach
499
+ // and heartbeat (#268) — none of these three are queried on individually,
500
+ // they exist for the fleet page and for operators diagnosing "which
501
+ // process is this row". Not part of any uniqueness: a node can rename its
502
+ // hostname or upgrade its CLI without becoming a different node, because
503
+ // identity is `(createdById, name)`, not any of these.
504
+ hostname String
505
+ platform String
506
+ cliVersion String @map("cli_version")
507
+
508
+ // The handler `type` keys (see `Job.type`'s own comment for why that column
509
+ // is a plain string, not an enum) this node is willing to claim. #268's
510
+ // claim endpoint INTERSECTS a request's requested types with this list — a
511
+ // node can only ever narrow what it claims, never broaden it beyond what it
512
+ // last declared itself capable of running.
513
+ eligibleTypes String[] @map("eligible_types")
514
+
515
+ // The node's own claimed concurrency ceiling, read LIVE by #268's claim
516
+ // endpoint (not cached at claim time) so a runtime `appctl node
517
+ // set-concurrency` takes effect on the very next claim without a
518
+ // re-register.
519
+ concurrency Int
520
+
521
+ // Operator/administrative state, NOT liveness — a node can be `online` and
522
+ // still be silently dead; that question is answered by comparing
523
+ // `lastHeartbeatAt` against the heartbeat interval, elsewhere (#270's fleet
524
+ // cron), not by this column. `disabled` is the operator-driven kill switch
525
+ // (refuse this node's claims regardless of what it reports); `draining` is
526
+ // "finish what you're holding, claim nothing new" — both are decisions a
527
+ // human or #270's lifecycle cron makes, not facts the node reports about
528
+ // itself.
529
+ status NodeStatus @default(offline)
530
+
531
+ // The node's last self-reported capability summary (CPU/RAM, GPU
532
+ // presence, disk headroom, ... — the exact shape is #268/#276's to define,
533
+ // not this schema's) from register or heartbeat. JSONB, not typed columns,
534
+ // for the same reason `Job.payload` is JSONB: this is opaque, evolving,
535
+ // node-supplied data that no query here needs to filter or index on —
536
+ // it exists for the fleet page to display, not for this table to reason
537
+ // about. Nullable: a node that has never heartbeated has reported nothing
538
+ // yet.
539
+ capabilities Json? @db.JsonB
540
+
541
+ // When this row was first created (register), vs. `lastHeartbeatAt` below
542
+ // which moves on every liveness ping. Distinct on purpose: "how long has
543
+ // this node existed" and "is it still alive" are different questions, and
544
+ // collapsing them into one timestamp would lose the first once the second
545
+ // starts moving.
546
+ registeredAt DateTime @default(now()) @map("registered_at") @db.Timestamptz
547
+
548
+ // NULL until the first heartbeat. #270's liveness/prune cron reads this to
549
+ // decide a node has gone dark; #268's lease reaper reads `Job
550
+ // .leaseExpiresAt` for the same question about an individual claim — the
551
+ // two are deliberately independent so a node with no jobs currently
552
+ // claimed still shows up as stale when it stops heartbeating.
553
+ lastHeartbeatAt DateTime? @map("last_heartbeat_at") @db.Timestamptz
554
+
555
+ // The node's last self-reported vitals snapshot (load, memory, disk, ...)
556
+ // carried on the heartbeat (#604). Opaque JSONB like `capabilities`: it is
557
+ // displayed, never filtered or indexed. `lastVitalsAt` is when the API
558
+ // stored it, so a stale snapshot is distinguishable from a fresh one.
559
+ // Both NULL until a heartbeat carries vitals.
560
+ lastVitals Json? @map("last_vitals") @db.JsonB
561
+ lastVitalsAt DateTime? @map("last_vitals_at") @db.Timestamptz
562
+
563
+ // The user who registered this node — see the long comment below for why
564
+ // this is required (not nullable) and `onDelete: Cascade`.
565
+ createdById String @map("created_by_id") @db.Uuid
566
+
567
+ // Relations
568
+ //
569
+ // -----------------------------------------------------------------------------
570
+ // createdBy — onDelete: Cascade, and `createdById` stays required
571
+ // -----------------------------------------------------------------------------
572
+ // This is NOT the audit-trail pattern this schema uses elsewhere
573
+ // (`AuditEvent.actorUserId`, `Credential.updatedByUser`,
574
+ // `NotificationDelivery.user` — all `SetNull`, all deliberately preserving a
575
+ // history record after its actor is gone). A `WorkerNode` is closer to
576
+ // `PushSubscription`: a LIVE registration with no standalone value once its
577
+ // owner is gone, not a record of a past event. Concretely:
578
+ //
579
+ // 1. `NodeCredential.user` is `onDelete: Cascade` (mirroring
580
+ // `PersonalAccessToken`, per this table's own design above) — so
581
+ // deleting the owning user ALREADY destroys every credential that
582
+ // could ever authenticate as this node. A `WorkerNode` row surviving
583
+ // its owner would be permanently unauthenticatable inventory: no
584
+ // token can ever heartbeat, claim, or deregister it again.
585
+ // 2. `assertOwnership` (#268) is `node.createdById === callerId` with no
586
+ // concept of "unowned" — a `SetNull`'d, ownerless node could not be
587
+ // managed (deregistered, disabled, viewed via `GET /nodes`) by
588
+ // anyone, ever, through the API this issue's sibling adds. It would
589
+ // sit as a permanently orphaned row no admin surface reaches.
590
+ // 3. `@@unique([createdById, name])` below is #268's register-or-reattach
591
+ // anchor. Keeping `createdById` NOT NULL keeps that constraint doing
592
+ // real work: two different (deleted-owner) nodes could otherwise both
593
+ // carry `createdById: null` with the same `name` and NOT collide
594
+ // (Postgres does not treat two NULLs as equal for uniqueness) — a
595
+ // silent hole in the exact invariant #268 depends on "existing
596
+ // exactly this way" for.
597
+ //
598
+ // Cascade does not touch job HISTORY: `Job.claimedByNode` is its own
599
+ // separate `onDelete: SetNull` (see the block comment above the `Job`
600
+ // model), so a cascaded `WorkerNode` deletion still only nulls out
601
+ // `claimedByNodeId` on whatever it was holding — it never deletes a `Job`
602
+ // row. Losing the fleet-inventory row (hostname, platform, capabilities
603
+ // history) when its owner's account is gone is an acceptable, even
604
+ // correct, cleanup; losing job history is not, and nothing here does that.
605
+ createdBy User @relation("UserWorkerNodes", fields: [createdById], references: [id], onDelete: Cascade)
606
+
607
+ // The jobs this node currently holds or has ever held. See `Job
608
+ // .claimedByNode`'s own comment for the `onDelete: SetNull` that keeps
609
+ // this list from ever forcing a `Job` deletion.
610
+ claimedJobs Job[] @relation("NodeClaimedJobs")
611
+
612
+ // THE register-or-reattach anchor (#268's own words: "existing exactly
613
+ // this way"). A node identifies itself to `/nodes/register` by
614
+ // `(callerId, name)`; this is what lets that endpoint find-or-create
615
+ // instead of always inserting, so a restarted container reattaches to its
616
+ // existing row (refreshing hostname/platform/cliVersion/eligibleTypes/
617
+ // concurrency and flipping back to `online`) rather than leaking a new
618
+ // node row on every restart. `name` is caller-chosen precisely so one
619
+ // user can register many distinctly-named nodes while each individual
620
+ // name stays idempotent for them.
621
+ @@unique([createdById, name])
622
+ // "Every online/draining/offline/disabled node" — the fleet page's own
623
+ // primary filter, and what #270's lifecycle cron scans to find nodes to
624
+ // act on.
625
+ @@index([status])
626
+ // "This user's own nodes" — backs `GET /nodes` (#268: "the caller's own
627
+ // nodes") and the ownership check inside `assertOwnership`, in addition to
628
+ // already being the left half of the composite unique index above.
629
+ @@index([createdById])
630
+ @@map("worker_nodes")
631
+ }
632
+
633
+ enum NodeStatus {
634
+ online
635
+ draining
636
+ offline
637
+ disabled
638
+ }
639
+
640
+ // -----------------------------------------------------------------------------
641
+ // NodeCredential — mirrors PersonalAccessToken minus the duration bookkeeping
642
+ // -----------------------------------------------------------------------------
643
+ // Same token lifecycle as `PersonalAccessToken` (`pat.service.ts` is the
644
+ // documented model for `NodeCredentialService`): a `nod_`-prefixed token is
645
+ // minted, hashed with sha256 into `tokenHash`, a short `tokenPrefix` is kept
646
+ // for display, and the raw value is shown exactly once. `validateToken`
647
+ // rejects an unknown hash, a `revokedAt`, an expired `expiresAt`, or an
648
+ // inactive owning user, then stamps `lastUsedAt` fire-and-forget — all of
649
+ // that is `NodeCredentialService`'s job (#267's backend half), not this
650
+ // schema's; this model exists to make those checks possible.
651
+ //
652
+ // -----------------------------------------------------------------------------
653
+ // expiresAt — nullable here, REQUIRED on PersonalAccessToken. This is the one
654
+ // deliberate divergence from the PAT shape, and it is worth writing out why.
655
+ // -----------------------------------------------------------------------------
656
+ // A PAT is typically minted for a person's own convenience (the CLI, a
657
+ // script they wrote) and a forced expiry is a reasonable default nudge
658
+ // toward rotation — a human is around to notice it expired and mint a new
659
+ // one. A `NodeCredential` authenticates a worker node that is expected to
660
+ // run UNATTENDED FOR MONTHS: a spare box, a container in someone else's
661
+ // cluster, a machine nobody is watching a dashboard for. If that credential
662
+ // had a mandatory expiry the way a PAT does, the failure mode is an entire
663
+ // fleet going dark at 3am because a timer nobody scheduled fired — the
664
+ // worker can't heartbeat, can't claim, can't report results, and the first
665
+ // anyone learns of it is jobs silently piling up unclaimed. That is a WORSE
666
+ // failure than a long-lived credential, because the credential's blast
667
+ // radius is already confined to `/api/nodes/*` by the guard's route
668
+ // allowlist (issue #267) even if it leaks — the thing a mandatory expiry on
669
+ // a PAT protects against (a leaked token with the owner's full authority)
670
+ // simply does not apply here to the same degree. So `expiresAt` is nullable
671
+ // and NULL is a real, supported, expected value meaning "never expires,
672
+ // authenticate indefinitely until revoked" — not "not yet set". Revocation
673
+ // (`revokedAt`) is the actual control for a node credential, and it is
674
+ // immediate: an operator who needs to cut a node off does so by revoking,
675
+ // not by waiting for a clock. An operator who DOES want an expiring node
676
+ // credential can still set one; the column supports it, it is just not
677
+ // forced.
678
+ model NodeCredential {
679
+ id String @id @default(uuid()) @db.Uuid
680
+
681
+ userId String @map("user_id") @db.Uuid
682
+
683
+ // Operator-chosen label ("prod-worker-1", "gpu-box-3"), shown alongside
684
+ // `tokenPrefix` in list views — same role as `PersonalAccessToken.name`.
685
+ name String
686
+
687
+ // sha256 hash of the full `nod_...` token; the only form of the token this
688
+ // table ever stores. Never the plaintext — see `NodeCredentialService
689
+ // .validateToken`'s own documentation for the lookup this backs.
690
+ tokenHash String @unique @map("token_hash")
691
+
692
+ // Short, non-secret display fragment (e.g. the first several characters
693
+ // after `nod_`) so an operator can tell two credentials apart in a list
694
+ // without either one's full value ever being shown again after creation.
695
+ tokenPrefix String @map("token_prefix")
696
+
697
+ // See the block comment above for why this is nullable, unlike
698
+ // `PersonalAccessToken.expiresAt` which is required. NULL = never expires.
699
+ expiresAt DateTime? @map("expires_at") @db.Timestamptz
700
+
701
+ // Stamped fire-and-forget by `validateToken` on each successful
702
+ // authentication — the operator-facing answer to "is this node actually
703
+ // still using this credential, or is it dead and safe to revoke".
704
+ lastUsedAt DateTime? @map("last_used_at") @db.Timestamptz
705
+
706
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
707
+
708
+ // NULL means active. Set once, never cleared — revocation is a one-way
709
+ // door, matching `PersonalAccessToken.revokedAt` and `RefreshToken
710
+ // .revokedAt` elsewhere in this schema. Revocation is immediate: the
711
+ // block comment above explains why this, not a clock, is the real control
712
+ // for a node credential.
713
+ revokedAt DateTime? @map("revoked_at") @db.Timestamptz
714
+
715
+ // Relations
716
+ //
717
+ // onDelete: Cascade, matching `PersonalAccessToken.user` exactly (per
718
+ // issue #267's own instruction) and for the identical reason: a node
719
+ // credential is a live, personal authentication artifact with no
720
+ // standalone value once its owning account is gone — there is no one left
721
+ // to authorize a node acting on this user's behalf, and no audit value in
722
+ // keeping hashed token material around for an account that no longer
723
+ // exists. See `WorkerNode.createdBy`'s own comment above for how this
724
+ // Cascade is part of the same reasoning that made `WorkerNode.createdBy`
725
+ // Cascade too: the two tables are designed to disappear together.
726
+ user User @relation("UserNodeCredentials", fields: [userId], references: [id], onDelete: Cascade)
727
+
728
+ // "This user's own credentials" — backs `GET /api/node-credentials`
729
+ // (#267's list endpoint), matching `PersonalAccessToken`'s own
730
+ // `@@index([userId])` for the identical query shape.
731
+ @@index([userId])
732
+ @@map("node_credentials")
733
+ }
734
+
735
+ // =============================================================================
736
+ // Job Node Secrets (issue #349, epic #345)
737
+ // =============================================================================
738
+ //
739
+ // THE AUDIT RECORD OF A CREDENTIAL A WORKER NODE ONCE HELD — and, precisely,
740
+ // NOT the credential. A node has no database access and no storage credentials
741
+ // by design (`docs/specs/worker-nodes.md` §8); epic #345 needs it to run work
742
+ // that genuinely requires one (a `pg_dump` needs a connection), so the server
743
+ // mints one per job, hands it over once, and destroys it on settlement. This
744
+ // table exists so that "which node held a credential to this deployment's
745
+ // database, of what kind, from when until when, and was it revoked" is a
746
+ // question with an answer.
747
+ //
748
+ // -----------------------------------------------------------------------------
749
+ // ⚠ THERE IS NO COLUMN HERE CAPABLE OF HOLDING SECRET MATERIAL, AND THERE MUST
750
+ // NEVER BE — NOT PLAINTEXT, NOT ENCRYPTED
751
+ // -----------------------------------------------------------------------------
752
+ //
753
+ // `handle` is an IDENTIFIER for the grant — a role name, a token id — and it is
754
+ // what `JobSecretBroker.revoke(handle)` takes. It is never the password. The
755
+ // material itself is serialised into exactly one HTTP response
756
+ // (`POST /api/nodes/:id/jobs/:jobId/secret`) and then dropped; nothing in this
757
+ // process, this database, or any backup of it retains a copy.
758
+ //
759
+ // REJECTED: an `encryptedMaterial` column, reusing `SECRETS_ENCRYPTION_KEY` the
760
+ // way the `credentials` table does. A credential that can be re-read is a
761
+ // credential that can be STOLEN TWICE, and nothing needs to re-read this one:
762
+ // the node received it once, and revocation is by handle rather than by
763
+ // presenting the secret back. `credentials` stores material because an SMTP
764
+ // password must be replayed on every send; a job credential must not be
765
+ // replayed at all. Storing it — even encrypted — would buy nothing and would
766
+ // put a live database password into this table, into every `pg_dump` of it,
767
+ // and into every copy of that dump, protected by a key that lives in the same
768
+ // deployment. That is the owner's rule for epic #345 expressed in schema rather
769
+ // than in a convention somebody can forget.
770
+ //
771
+ // -----------------------------------------------------------------------------
772
+ // `@@unique([jobId, kind])` — ONE CREDENTIAL PER JOB, EVER
773
+ // -----------------------------------------------------------------------------
774
+ //
775
+ // A node asks for its secret more than once as a matter of course: a process
776
+ // restarted while holding the lease, a response lost on the way back, a retry
777
+ // of the same job. Every one of those calls must land on the SAME grant,
778
+ // extended — never on a second one, because the revocation paths know exactly
779
+ // one handle per (job, kind) and a sibling grant would be one nothing ever
780
+ // destroys. The unique index makes the second row unrepresentable; the broker's
781
+ // own `issue` idempotence (see `job-secret-broker.ts`) is the other half.
782
+ //
783
+ // `@@index([expiresAt])` backs the sweeper, which scans unrevoked grants whose
784
+ // own clock has lapsed. See `NodeSecretBrokerService.sweep` for why the sweeper
785
+ // is NOT redundant with the settle-event revoker: three real cases emit no
786
+ // event at all.
787
+ //
788
+ // NO RELATIONS, DELIBERATELY. `jobId` and `nodeId` are plain UUID columns with
789
+ // no foreign key either way, for two reasons. `jobs` rows are deleted on a
790
+ // retention schedule (`job.history.purge`) that has nothing to do with a
791
+ // grant's lifetime, and a cascade from that purge would silently erase the
792
+ // audit record of a credential — which is the one thing this table is for. And
793
+ // a grant must stay revocable by handle after its job row is gone, which is
794
+ // why the sweeper resolves a broker by `kind` rather than by the job's `type`.
795
+ model JobNodeSecret {
796
+ id String @id @default(uuid()) @db.Uuid
797
+
798
+ jobId String @map("job_id") @db.Uuid
799
+ nodeId String @map("node_id") @db.Uuid
800
+
801
+ // Which broker minted this — `JobSecretBroker.kind`, e.g.
802
+ // `'postgres.readonly'`. Also how the revocation paths find their way back
803
+ // to the broker that can destroy the grant. A plain string, not an enum, for
804
+ // the same reason `Job.type` is: a fork's broker must cost zero migrations.
805
+ kind String
806
+
807
+ // The grant's identifier. ⚠ THE ROLE NAME. NEVER THE PASSWORD. See the block
808
+ // comment above.
809
+ handle String
810
+
811
+ issuedAt DateTime @default(now()) @map("issued_at") @db.Timestamptz
812
+ // The credential's own backstop, bounded by the job's lease (never a second
813
+ // clock of its own — see `JobSecretBroker.issue`). It is what makes a grant
814
+ // worthless if BOTH revocation paths somehow miss it.
815
+ expiresAt DateTime @map("expires_at") @db.Timestamptz
816
+ // NULL means the grant is still live as far as this server knows. Set by
817
+ // whichever revocation path got there first; both tolerate the other having
818
+ // won.
819
+ revokedAt DateTime? @map("revoked_at") @db.Timestamptz
820
+
821
+ @@unique([jobId, kind])
822
+ @@index([expiresAt])
823
+ @@map("job_node_secrets")
824
+ }