@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,746 @@
1
+ // GENERATED by platform db compose — do not edit
2
+ // source: app:kvox.notes.prisma
3
+
4
+ // GENERATED by kvox-db sync — do not edit
5
+ // source: @marinoscar/kvox-db:schema/kvox.notes.prisma
6
+
7
+ // =============================================================================
8
+ // Notes (issue #48, epic #45) — docs/specs/notes.md §4
9
+ // =============================================================================
10
+ //
11
+ // Five tables — `notes`, `note_templates`, `note_generations`, `note_versions`,
12
+ // `note_exports` — for the AI-generated, user-correctable documents this epic
13
+ // adds on top of a transcript, another note, or an uploaded document. Nothing
14
+ // about generation, the stream, prompt assembly or export lands with this
15
+ // issue — only the schema and RBAC, ahead of the code that reads and writes
16
+ // it, matching the same "schema ships whole, code follows" precedent
17
+ // `jobs`/`worker_nodes` and `transcripts` (issue #24) already set. Every
18
+ // column, enum member and index below is named for and justified by
19
+ // docs/specs/notes.md §1, §4, §6 and §7 — read those sections for the full
20
+ // reasoning; this comment restates only what has to live beside the code to
21
+ // stay correct.
22
+ //
23
+ // -----------------------------------------------------------------------------
24
+ // owner_id CASCADES; the three source pointers and template_id RESTRICT or
25
+ // SetNull — the exact `Transcript` shape (schema.prisma's own block comment
26
+ // above), with one genuinely new wrinkle: a SELF-relation
27
+ // -----------------------------------------------------------------------------
28
+ // `owner_id` points UP the ownership chain, identically to `transcripts
29
+ // .owner_id`: a note has no meaning and this application's access model has
30
+ // no path to it once its owner is gone — there is deliberately no
31
+ // `notes:read_any` (spec §6.3) — so Cascade is the only outcome consistent
32
+ // with "no read-any, ever."
33
+ //
34
+ // The three source columns point SIDEWAYS and RESTRICT, but spec §4.1 is
35
+ // explicit that the *consequence* of that Restrict is not uniform across the
36
+ // three, and this schema does not paper over the difference:
37
+ //
38
+ // - `source_object_id` -> `storage_objects` is Restrict for the same
39
+ // belt-and-suspenders reason every `storage_objects` FK on `transcripts`
40
+ // is: the uploaded document is `managed_by: 'notes'`, so the generic
41
+ // storage `DELETE` already 409s on it before this FK is ever tested.
42
+ // - `source_transcript_id` -> `transcripts` and `source_note_id` -> `notes`
43
+ // (a SELF-relation — the one structural difference from every Restrict FK
44
+ // `transcripts` declares) point at rows that ARE independently,
45
+ // legitimately deletable through their own existing endpoints, which
46
+ // neither know nor care whether a note was ever derived from them.
47
+ // Restrict therefore has a real, user-visible consequence here:
48
+ // `DELETE /api/transcripts/:id` and (once #53 lands) `DELETE
49
+ // /api/notes/:id` on a note cited as another note's source must both gain
50
+ // a pre-check that 409s naming the blocking note id(s) BEFORE the row
51
+ // reaches this constraint, exactly as `transcripts.service.ts` already
52
+ // does for storage objects. This migration adds only the constraint; the
53
+ // one-line guard for the transcript side is backend work for the PR that
54
+ // lands transcripts.service.ts's next change, and the note-on-note guard
55
+ // is #53's own responsibility once `notes.service.ts` exists to hold it.
56
+ //
57
+ // `template_id` is the one FK in this feature that is `SetNull`, not
58
+ // `Restrict`, and that asymmetry is deliberate (spec §4.1's own closing
59
+ // paragraph): a template is a reusable RECIPE, never EVIDENCE — VISION.md's
60
+ // Trust-and-Provenance thesis is about a note staying connected to the
61
+ // INFORMATION it transformed, never about which instructions did the
62
+ // transforming. Making it Restrict would mean a custom template could never
63
+ // be deleted once it had produced even one note, an ordinary thing a user
64
+ // wants to do (spec §7.3's duplicate-and-edit flow exists precisely so this
65
+ // is safe to allow). Losing the *reference* on delete costs nothing durable:
66
+ // `note_generations.templateNameSnapshot` below keeps "which recipe produced
67
+ // this" as a plain string forever, independent of whether the template row
68
+ // itself survives.
69
+ //
70
+ // -----------------------------------------------------------------------------
71
+ // `note_generations` is deliberately SELF-SUFFICIENT — everything
72
+ // `assemblePrompt` needs is denormalized onto the row, because a template
73
+ // PREVIEW generation has no `note_id` to join back through
74
+ // -----------------------------------------------------------------------------
75
+ // `template_name_snapshot`, `context_text`, `source_type` and the three
76
+ // (deliberately PLAIN, non-FK) `source_*_id` columns, `provider_id` and
77
+ // `model` are all copies taken at the moment a generation is created, not
78
+ // joins. Two things follow from that, both worth stating explicitly rather
79
+ // than leaving implicit:
80
+ //
81
+ // - The three `source_*_id` columns here carry NO foreign key at all,
82
+ // unlike their namesakes on `notes` above. They are a denormalized RECORD
83
+ // of what was resolved when this generation started, not a reference this
84
+ // schema has to keep valid — a `kind: 'preview'` row has no `note_id` and
85
+ // is hard-deleted by its own TTL sweep regardless of what its source
86
+ // later does, so there is nothing for a Restrict to protect here that the
87
+ // owning `notes` row (for `create`/`regenerate`) is not already
88
+ // protecting on its own account.
89
+ // - `job_id` DOES EXIST ON THIS TABLE, `@unique`/nullable/`SetNull`,
90
+ // mirroring `DatabaseBackupRun.jobId` and `TranscriptExport.jobId` for
91
+ // the identical stated reason (issue #48): this row's own lifetime —
92
+ // read by the SSE endpoint and the note detail page for as long as
93
+ // generation is in flight or its content is still being displayed — is
94
+ // independent of `job.history.purge`'s retention schedule for the
95
+ // underlying `jobs` row. Losing the LINK on a history purge must never
96
+ // mean losing the GENERATION (its buffered `content` is what a
97
+ // `note_versions.ai_generated` row was stamped from). `SetNull` rather
98
+ // than `Restrict` for the same reason every other `jobId` in this schema
99
+ // is `SetNull`: a purged job's row disappearing is routine housekeeping,
100
+ // not a reason to block it.
101
+ //
102
+ // `error_class` is a proper enum (`NoteGenerationErrorClass`), unlike
103
+ // `Job.type`/`StorageObject.managedBy`'s deliberately open, code-owned plain
104
+ // strings: spec §2.2's taxonomy is a small, CLOSED, product-defined set (not
105
+ // a registry of pluggable handlers), the same reasoning every other
106
+ // small-closed-set field in this schema (`TranscriptExportStatus`,
107
+ // `DatabaseBackupTrigger`) is an enum rather than a string.
108
+ //
109
+ // -----------------------------------------------------------------------------
110
+ // `note_versions` stores a FULL BODY SNAPSHOT per row, not an operation log —
111
+ // the decision issue #46/#48 flag explicitly as a genuinely different answer
112
+ // from `transcript_versions`, restated here because it is the one place a
113
+ // reader of this schema without the spec open would reasonably ask "why does
114
+ // this feature not reuse the op-log machinery next door"
115
+ // -----------------------------------------------------------------------------
116
+ // `docs/specs/transcription.md` §11's own rejected-alternatives entry gives
117
+ // the arithmetic that ruled a full snapshot OUT for transcripts: roughly 7 MB
118
+ // per save for a 10-hour, 90,000-word recording. A note is a page or two of
119
+ // prose — a few kilobytes — so that arithmetic simply does not apply, and the
120
+ // machinery an op log needs to earn its cost (pure reducers, `materialize()`,
121
+ // gap-based ordinals, per-entity `rev`) has no equivalent structure here: a
122
+ // note has no segments, no speakers, and no concurrent-editor reconciliation
123
+ // to do (spec §5's stream is the only writer while generating). Storing the
124
+ // whole markdown string per save is not a shortcut around building that
125
+ // machinery — for a document this small, the op log is what would have been
126
+ // over-engineering.
127
+ //
128
+ // -----------------------------------------------------------------------------
129
+ // `note_exports` mirrors `transcript_exports` FIELD FOR FIELD (spec §4.6),
130
+ // including the one place this migration follows the REAL, already-shipped
131
+ // `TranscriptExport` implementation over spec §4.6's own shorthand prose
132
+ // -----------------------------------------------------------------------------
133
+ // Spec §4.6 describes the reuse-by-content-hash lookup with the English word
134
+ // "unique". The actual `transcript_exports` table it says this mirrors does
135
+ // NOT enforce that with a `@@unique` — see this schema's own block comment
136
+ // above `TranscriptExport`: "an expired or failed row may briefly coexist
137
+ // with a fresh one sharing the same key before housekeeping removes the
138
+ // stale one." A literal `@@unique` here would make that ordinary coexistence
139
+ // a write failure. This migration reuses `transcript_exports_lookup_idx`'s
140
+ // actual shape — a plain, non-unique `@@index` — because "mirrors
141
+ // `transcript_exports` field for field" (issue #48's own proposed solution)
142
+ // is the more specific, more load-bearing instruction, and because matching
143
+ // the table this feature explicitly exists to let #54 "reuse #28's registry
144
+ // unchanged" against means matching what that registry's code actually
145
+ // queries, not a one-word paraphrase of it.
146
+ // =============================================================================
147
+
148
+ enum NoteStatus {
149
+ draft
150
+ generating
151
+ ready
152
+ failed
153
+ deleting
154
+ }
155
+
156
+ enum NoteSourceType {
157
+ transcript
158
+ note
159
+ document
160
+ }
161
+
162
+ enum NoteGenerationKind {
163
+ create
164
+ regenerate
165
+ preview
166
+ }
167
+
168
+ enum NoteGenerationStatus {
169
+ pending
170
+ streaming
171
+ succeeded
172
+ failed
173
+ }
174
+
175
+ enum NoteGenerationErrorClass {
176
+ auth
177
+ refusal
178
+ rate_limit
179
+ other
180
+ }
181
+
182
+ enum NoteVersionKind {
183
+ ai_generated
184
+ edit
185
+ restore
186
+ }
187
+
188
+ enum NoteExportStatus {
189
+ pending
190
+ ready
191
+ failed
192
+ }
193
+
194
+ enum NoteTitleSource {
195
+ ai
196
+ user
197
+ template
198
+ }
199
+
200
+ model Note {
201
+ id String @id @default(uuid()) @db.Uuid
202
+
203
+ ownerId String @map("owner_id") @db.Uuid
204
+ title String
205
+
206
+ // WHO chose the current `title`, not just what it says (issue #180, epic
207
+ // #163). A title had no provenance before this column: nothing could tell
208
+ // a name the user typed apart from one the product guessed, so a
209
+ // `regenerate` or a future bulk-retitle pass had no way to know a note's
210
+ // owner had already renamed it — the exact failure mode this column exists
211
+ // to rule out is silently overwriting a human-chosen title with an AI
212
+ // guess. `template` is the default because that is literally what every
213
+ // row in this table has been until now: `notes.service.ts` has always
214
+ // fallen back to the source template's `name` when nothing more specific
215
+ // was available, so `template` is the truthful value for the entire
216
+ // existing dataset, not a placeholder invented for this migration.
217
+ // `user` is STICKY — once a person edits the title directly it becomes
218
+ // `user` and the AI titling path (present or future) must never overwrite
219
+ // it or flip it back to `ai`/`template`; that check belongs to the service
220
+ // layer, this column only makes the state it must check representable.
221
+ titleSource NoteTitleSource @default(template) @map("title_source")
222
+
223
+ // The LIVE working copy of the note's markdown body (issue #48). @db.Text,
224
+ // defaulting to an empty string so a freshly created `draft` note (no
225
+ // version yet — `currentVersion` starts at 0) has something to read rather
226
+ // than a nullable hole every reader would have to special-case.
227
+ //
228
+ // THE INVARIANT THIS COLUMN EXISTS UNDER: `notes.body` always equals the
229
+ // `note_versions` row at `note_versions.version = notes.current_version`.
230
+ // `note_versions.body` is the immutable, append-only SNAPSHOT — one row
231
+ // per save, never rewritten, the full history #53's version list and
232
+ // restore endpoint read from. `notes.body` is the single denormalized
233
+ // COPY of whichever snapshot is current, kept in lockstep by the same
234
+ // write path that bumps `currentVersion` (spec §1.3): every write that
235
+ // appends a `note_versions` row — AI generation completing, a `PATCH`
236
+ // editing the body, a restore — updates both columns together, in the
237
+ // same transaction, or not at all. This is what lets `GET /api/notes/:id`
238
+ // (#53) answer "what does this note say right now" with a single-row read
239
+ // and no join, while `GET /api/notes/:id/versions` still has the full,
240
+ // independently-readable history to page through. A `notes.body` that
241
+ // ever disagreed with its own `currentVersion`'s snapshot would be a bug
242
+ // in that write path, not an expected state.
243
+ body String @default("") @db.Text
244
+
245
+ // The markdown-vs-plain-text syntax `body` is written in, SNAPSHOTTED from
246
+ // the generating template's `bodyFormat` at generation time (never a live
247
+ // read of the template) — so a later edit to the template's body format
248
+ // never changes how an already-generated note renders. Plain String, not
249
+ // an enum, mirroring `NoteTemplate.bodyFormat` exactly.
250
+ bodyFormat String @default("markdown") @map("body_format")
251
+
252
+ status NoteStatus @default(draft)
253
+
254
+ // Bumped only by the write path that commits a new `note_versions` row
255
+ // (spec §1.3) — the same "current pointer, not a computed MAX()"
256
+ // convention `transcripts.currentVersion` already uses.
257
+ currentVersion Int @default(0) @map("current_version")
258
+
259
+ // Which registered `AiProvider` (issue #47) and which of its models
260
+ // produced the CURRENT body — denormalized onto the note itself (issue
261
+ // #48) so a note detail page can state "generated by GPT-4o" without
262
+ // joining to `note_generations`, whose rows `notes.housekeeping` is free
263
+ // to sweep (preview generations expire; nothing guarantees a `create`/
264
+ // `regenerate` generation outlives the note it produced forever). Plain
265
+ // strings, not an enum/FK — the provider registry is code-owned and open,
266
+ // the same `Transcript.provider`/`NoteGeneration.providerId` reasoning
267
+ // applied here. Both null until the first successful generation; a body
268
+ // edited by a human afterward does NOT clear either — they answer "what
269
+ // produced this note originally," not "who wrote the current body."
270
+ provider String?
271
+ model String?
272
+
273
+ // The generation to watch (spec §1.3) — set the moment a create/regenerate
274
+ // request enqueues a job, never cleared afterward, so a `ready` note's
275
+ // pointer always names the generation that produced its current content.
276
+ // SetNull rather than Restrict: losing this pointer loses only "which
277
+ // generation is live right now," never the generation row itself or any
278
+ // `note_versions` row it already produced.
279
+ currentGenerationId String? @map("current_generation_id") @db.Uuid
280
+
281
+ // Exactly one of the three below is set, matching `sourceType` — enforced
282
+ // by the service layer (`notes.service.ts`, #53), not by the database:
283
+ // Postgres has no portable "exactly one of these three columns is
284
+ // non-null" constraint short of a CHECK this schema does not add, the same
285
+ // posture `transcripts` takes toward its own multi-column invariants.
286
+ sourceType NoteSourceType @map("source_type")
287
+ sourceTranscriptId String? @map("source_transcript_id") @db.Uuid
288
+ sourceNoteId String? @map("source_note_id") @db.Uuid
289
+ sourceObjectId String? @map("source_object_id") @db.Uuid
290
+
291
+ // SetNull, not Restrict — see the block comment above the enums.
292
+ templateId String? @map("template_id") @db.Uuid
293
+
294
+ // The optional free-text Context (spec §3.1) — short, user-authored,
295
+ // placed ahead of the source in every assembled prompt.
296
+ contextText String? @map("context_text")
297
+
298
+ failureReason String? @map("failure_reason")
299
+
300
+ // GENERATED ALWAYS AS (setweight(to_tsvector('english'::regconfig,
301
+ // coalesce(title, '')), 'A') || setweight(to_tsvector('english'::regconfig,
302
+ // coalesce(body, '')), 'B')) STORED, plus a GIN index — issue #174, epic
303
+ // #164. Weighted A/B, unlike `TranscriptSegment.searchVector`: `title` is
304
+ // human-authored (the user wrote it) and `body` is AI-generated (or later
305
+ // user-edited), so a title hit is stronger evidence and `ts_rank`/
306
+ // `ts_rank_cd` can reflect that. Hand-written in
307
+ // `20260915120000_add_search_vectors/migration.sql`; see that migration's
308
+ // header for the full argument, including why the two-argument
309
+ // `to_tsvector` form is required and why a generated column rather than a
310
+ // trigger (restore-safety — a trigger does not fire under
311
+ // `session_replication_role = replica`). `Unsupported` and
312
+ // nullable-optional for the same reason `Transcript.titleSearchVector`
313
+ // is: never selectable or writable through the Prisma client — every read
314
+ // goes through raw SQL.
315
+ searchVector Unsupported("tsvector")? @map("search_vector")
316
+
317
+ deletedAt DateTime? @map("deleted_at") @db.Timestamptz
318
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
319
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
320
+
321
+ owner User @relation("NoteOwner", fields: [ownerId], references: [id], onDelete: Cascade)
322
+ sourceTranscript Transcript? @relation("NoteSourceTranscript", fields: [sourceTranscriptId], references: [id], onDelete: Restrict)
323
+ // Self-relation — see the block comment above the enums for why this one
324
+ // Restrict FK has a real, user-visible delete-blocking consequence.
325
+ sourceNote Note? @relation("NoteSourceNote", fields: [sourceNoteId], references: [id], onDelete: Restrict)
326
+ sourceObject StorageObject? @relation("NoteSourceObject", fields: [sourceObjectId], references: [id], onDelete: Restrict)
327
+ template NoteTemplate? @relation("NoteTemplateSelection", fields: [templateId], references: [id], onDelete: SetNull)
328
+ currentGeneration NoteGeneration? @relation("NoteCurrentGeneration", fields: [currentGenerationId], references: [id], onDelete: SetNull)
329
+
330
+ // The other side of the self-relation above: every note that named THIS
331
+ // note as its source. What #53's own delete guard queries before allowing
332
+ // `DELETE /api/notes/:id` to proceed.
333
+ derivedNotes Note[] @relation("NoteSourceNote")
334
+ generations NoteGeneration[] @relation("NoteGenerations")
335
+ versions NoteVersion[]
336
+ exports NoteExport[]
337
+
338
+ // Connected Knowledge (epic #344, issue #351). `kgEvidence`/`kgProposals`
339
+ // are SetNull (a citation, and an extraction run's own source pointer,
340
+ // both survive losing the note they named — see the block comments above
341
+ // `KgEvidence`/`KgProposal`); `kgMentions` Cascades (see the block
342
+ // comment above `KgMention`).
343
+ kgEvidence KgEvidence[]
344
+ kgMentions KgMention[]
345
+ kgProposals KgProposal[]
346
+
347
+ // "This owner's notes, newest first" — the notes library list view.
348
+ @@index([ownerId, updatedAt(sort: Desc)])
349
+ // "Every note in state X" — `notes.housekeeping`'s scans (#53) and any
350
+ // future admin-adjacent health check, matching `transcripts_status_idx`.
351
+ @@index([status])
352
+ @@map("notes")
353
+ }
354
+
355
+ // -----------------------------------------------------------------------------
356
+ // `note_templates.owner_id IS NULL` means BUILT-IN — readable by every user,
357
+ // editable by nobody through the API (spec §7.1-7.2) — the same "null means
358
+ // something specific and permanent" convention `transcript_speakers.label`
359
+ // established for a different column entirely. There is deliberately NO
360
+ // `is_built_in` boolean (spec §4.3): a second, independently-settable flag
361
+ // that could disagree with `owner_id IS NULL` is exactly the failure mode
362
+ // `job-handler.interface.ts`'s "presence is the declaration" rule already
363
+ // forbids for node eligibility.
364
+ //
365
+ // `instructions` PLUS six structured fields, not "one field, not several
366
+ // structured knobs" (issue #48's actual, explicit column list —
367
+ // `outputFormat`, `structure`, `tone`, `length`, `model`, `isArchived` — all
368
+ // six). `instructions` stays the free-text prompt body the system role
369
+ // carries verbatim; the structured fields sit BESIDE it, not composed into
370
+ // it, because #56's template editor round-trips them as real controls (an
371
+ // Output format select, an editable ordered list for Structure, Tone and
372
+ // Length pickers, an optional Model override) — a user who edits a template
373
+ // and reopens it must get their structured choices back, which is impossible
374
+ // if they were only ever flattened into prose on save with nothing left to
375
+ // read them back out of. `assemblePrompt` (#49) reads all of `instructions`
376
+ // AND the structured fields when building a generation's prompt; neither
377
+ // replaces the other.
378
+ //
379
+ // `structure` is `Json @db.JsonB` — an ordered array of section/heading
380
+ // strings (e.g. `["Overview", "Key Points", "Decisions"]`), the shape #56's
381
+ // editable ordered list edits directly. `outputFormat` is required (every
382
+ // template, built-in or custom, names one); `tone`, `length` and `model` are
383
+ // optional — a template author may leave any of them unset and let
384
+ // `assemblePrompt` fall back to a deployment default. `isArchived` defaults
385
+ // to `false`: an archived custom template is hidden from the picker without
386
+ // being deleted (and without orphaning notes/generations that already
387
+ // reference it via `SetNull`, see below).
388
+ //
389
+ // `@@unique([ownerId, name])` (issue #48): a user may not have two templates
390
+ // sharing a name. PostgreSQL's standard NULLS-DISTINCT behaviour means this
391
+ // is silently a no-op among built-ins — every built-in's `ownerId` is
392
+ // permanently `NULL`, and NULL is never equal to NULL for uniqueness
393
+ // purposes, so any number of built-in rows may coexist regardless of name.
394
+ // The constraint only ever fires among a single owner's own rows.
395
+ // -----------------------------------------------------------------------------
396
+ model NoteTemplate {
397
+ id String @id @default(uuid()) @db.Uuid
398
+ ownerId String? @map("owner_id") @db.Uuid
399
+
400
+ name String
401
+ description String
402
+ // The prompt body the system role carries verbatim (spec §3.1), read
403
+ // together with the structured fields below by `assemblePrompt` (#49).
404
+ // @db.Text: free-text prompt instructions can run well past what a
405
+ // VARCHAR would comfortably hold.
406
+ instructions String @db.Text
407
+
408
+ // Structured fields #56's template editor presents as real controls, and
409
+ // reads back unchanged on reopen — see the block comment above the enums
410
+ // for why these six are stored beside `instructions` rather than composed
411
+ // into it.
412
+ outputFormat String @map("output_format")
413
+ // The markdown-vs-plain-text SYNTAX the AI is asked to write in — a
414
+ // separate axis from `outputFormat` above, which names the document TYPE
415
+ // (meeting notes, summary, …) rather than how its text is styled. Plain
416
+ // String, not an enum: same reasoning as `outputFormat` — validated by
417
+ // Zod in the API ('markdown' | 'plain_text'), not by a database enum.
418
+ bodyFormat String @default("markdown") @map("body_format")
419
+ // Ordered array of section/heading strings, e.g. `["Overview", "Key
420
+ // Points", "Decisions"]` — the shape #56's editable ordered list edits.
421
+ structure Json @db.JsonB
422
+ tone String?
423
+ length String?
424
+ // Optional per-template model override, populated in the web editor from
425
+ // `GET /api/ai/config`'s permitted list so a user can never select a
426
+ // model the deployment forbids. Plain string, not an enum/FK — same
427
+ // "code-owned, open registry" reasoning as `NoteGeneration.model`.
428
+ model String?
429
+ // Hides a custom template from the picker without deleting it (and
430
+ // without orphaning anything that already references it — `notes
431
+ // .templateId`/`note_generations.templateId` are both `SetNull`, so
432
+ // archiving never breaks a note or generation that used this template).
433
+ // Never meaningfully set on a built-in row today, but not schema-
434
+ // prevented from being so in the future.
435
+ isArchived Boolean @default(false) @map("is_archived")
436
+
437
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
438
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
439
+
440
+ // Cascade: a user's own custom template has no meaning once its owner is
441
+ // gone, the identical "belongs entirely to this account" shape
442
+ // `user_settings`/`personal_access_tokens` already use. Never fires for a
443
+ // built-in row, whose `owner_id` is permanently NULL.
444
+ owner User? @relation("NoteTemplateOwner", fields: [ownerId], references: [id], onDelete: Cascade)
445
+
446
+ notes Note[] @relation("NoteTemplateSelection")
447
+ generations NoteGeneration[] @relation("NoteGenerationTemplateSelection")
448
+
449
+ // Who has hidden THIS template from their own picker (#310, epic #306) —
450
+ // the built-in-safe complement to `isArchived` above. See the block
451
+ // comment above `UserHiddenNoteTemplate`.
452
+ hiddenBy UserHiddenNoteTemplate[]
453
+
454
+ // A user may not have two templates sharing a name — see the block
455
+ // comment above the enums for why PostgreSQL's NULLS-DISTINCT behaviour
456
+ // leaves built-ins (permanently null `ownerId`) entirely unaffected by
457
+ // this constraint.
458
+ @@unique([ownerId, name])
459
+ @@map("note_templates")
460
+ }
461
+
462
+ // =============================================================================
463
+ // `user_hidden_note_templates`: a per-user LISTING preference, never access
464
+ // control (#310, epic #306)
465
+ // =============================================================================
466
+ //
467
+ // A join table, deliberately, over the two alternatives this could have been:
468
+ //
469
+ // 1. A `user_settings` namespace (an array of hidden template ids in the
470
+ // caller's own JSONB blob). Rejected on three counts: a JSONB array has
471
+ // no foreign key, so a deleted template leaves a dangling id nothing ever
472
+ // cleans up — the exact failure mode `user_settings`'s existing namespaces
473
+ // (`notifications`, `onboarding`, ...) never need to worry about, because
474
+ // none of them names another table's row by id; "is this specific
475
+ // template hidden" and "list every template I've hidden" both become an
476
+ // application-level filter over a blob instead of a `WHERE` clause an
477
+ // index can serve; and there is no natural cap, so a user who hides
478
+ // hundreds of templates over time grows one JSONB column without bound.
479
+ // It would also cost the six-file parity `user-settings-parity.spec.ts`
480
+ // guards for every namespace, for a preference that isn't shaped like the
481
+ // rest of that document at all — it names rows in another table, not a
482
+ // scalar or a small enum a client renders as a toggle.
483
+ // 2. `note_templates.is_archived`, reused per-caller. Rejected outright:
484
+ // `is_archived` is a property of the TEMPLATE, shared by every user who
485
+ // can see it (a built-in row is everyone's row), so "archived" can only
486
+ // ever mean one thing for all viewers at once. Hiding is a property of
487
+ // the (user, template) PAIR — my hiding a built-in template from my own
488
+ // picker must never affect anyone else's.
489
+ //
490
+ // A join table gives real FK integrity in both directions (see the two
491
+ // `onDelete: Cascade`s below — hiding a template is meaningless once either
492
+ // side is gone), a plain indexed `WHERE user_id = ...` for the picker's
493
+ // exclusion query, no cap, and zero settings-parity cost.
494
+ //
495
+ // ⚠ HIDING IS A LISTING PREFERENCE ONLY, NEVER ACCESS CONTROL. A row here
496
+ // must never be consulted by `POST /api/notes` (create), `POST
497
+ // /api/notes/:id/regenerate`, or `POST /api/note-templates/preview` — all
498
+ // three continue to accept a hidden template by id exactly as before. This
499
+ // table only narrows what a picker LISTS, the identical scope
500
+ // `note_templates.is_archived` already keeps for the built-in-vs-custom
501
+ // axis: archiving/hiding decide what is offered, never what is honoured.
502
+ // =============================================================================
503
+ model UserHiddenNoteTemplate {
504
+ userId String @map("user_id") @db.Uuid
505
+ templateId String @map("template_id") @db.Uuid
506
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
507
+
508
+ // Both cascade: a hidden-template row has no meaning once either the user
509
+ // or the template it names is gone.
510
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
511
+ template NoteTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
512
+
513
+ @@id([userId, templateId])
514
+ @@index([templateId])
515
+ @@map("user_hidden_note_templates")
516
+ }
517
+
518
+ // -----------------------------------------------------------------------------
519
+ // `note_generations` is the durable buffer spec §5.1 streams from — see the
520
+ // block comment above the enums for why it denormalizes its inputs and why
521
+ // it deliberately carries no `job_id`.
522
+ // -----------------------------------------------------------------------------
523
+ model NoteGeneration {
524
+ id String @id @default(uuid()) @db.Uuid
525
+
526
+ // NULL for a template preview (spec §4.4) — a preview has no note to
527
+ // belong to and is hard-deleted by its own `expiresAt` sweep, never a
528
+ // `note_versions` row.
529
+ noteId String? @map("note_id") @db.Uuid
530
+ kind NoteGenerationKind
531
+
532
+ status NoteGenerationStatus @default(pending)
533
+ errorClass NoteGenerationErrorClass? @map("error_class")
534
+ errorDetail String? @map("error_detail")
535
+
536
+ // Denormalized inputs — see the block comment above the enums. templateId
537
+ // is nullable/SetNull for the same reason `notes.templateId` is (a
538
+ // deleted template loses only the reference); templateNameSnapshot is
539
+ // NOT NULL because a generation is never created without a template
540
+ // having been resolved first, and the snapshot is what survives the
541
+ // template's own later deletion.
542
+ templateId String? @map("template_id") @db.Uuid
543
+ templateNameSnapshot String @map("template_name_snapshot")
544
+ contextText String? @map("context_text")
545
+ sourceType NoteSourceType @map("source_type")
546
+ sourceTranscriptId String? @map("source_transcript_id") @db.Uuid
547
+ sourceNoteId String? @map("source_note_id") @db.Uuid
548
+ sourceObjectId String? @map("source_object_id") @db.Uuid
549
+ // Which registered `AiProvider` (issue #47) this generation was submitted
550
+ // to, and which of its models. Plain strings, not an enum/FK — the
551
+ // provider registry is code-owned and open, the same `Transcript.provider`
552
+ // reasoning applied to a second, independent registry.
553
+ providerId String @map("provider_id")
554
+ model String
555
+
556
+ // The stream itself (spec §5.1). Append-only, flushed on a short interval
557
+ // together with `lastEventId` in the SAME UPDATE, so the SSE `id:` sequence
558
+ // is gapless and stable by construction — there is no separate counter
559
+ // that could disagree with what actually landed in `content`.
560
+ content String @default("") @db.Text
561
+ lastEventId Int @default(0) @map("last_event_id")
562
+
563
+ promptTokens Int? @map("prompt_tokens")
564
+ completionTokens Int? @map("completion_tokens")
565
+
566
+ // A snapshot of WHAT WAS SENT to the AI provider — the exact
567
+ // `assemblePrompt()` output (apps/api/src/notes/generation/prompt.ts) —
568
+ // written BEFORE the provider call so a failed generation still records
569
+ // it (issue #307). `sourceVersion` is the transcript `currentVersion` /
570
+ // source note `currentVersion` actually materialized into the prompt;
571
+ // NULL for a document source, which carries no version. NULL across all
572
+ // four columns means the generation predates issue #307 — deliberately
573
+ // no backfill, because re-materializing the source today would fabricate
574
+ // history: the source transcript/note may have been corrected since, and
575
+ // the template may have been edited since.
576
+ systemPrompt String? @map("system_prompt") @db.Text
577
+ userContent String? @map("user_content") @db.Text
578
+ sourceVersion Int? @map("source_version")
579
+ contextCapturedAt DateTime? @map("context_captured_at") @db.Timestamptz
580
+
581
+ // The `note.generate` job that produced (or is producing) this row, when
582
+ // one exists. `@unique`/nullable/`SetNull`, mirroring `DatabaseBackupRun
583
+ // .jobId` and `TranscriptExport.jobId` (issue #48): this row's own
584
+ // lifetime — read by the SSE stream and the note detail page for as long
585
+ // as generation is in flight, and kept afterward as the AI-generated
586
+ // version's provenance — is independent of `job.history.purge`'s
587
+ // retention schedule for the underlying `jobs` row. See the block comment
588
+ // above the enums for the full reasoning.
589
+ jobId String? @unique @map("job_id") @db.Uuid
590
+
591
+ // Only ever set for `kind: 'preview'` — what `notes.housekeeping`'s TTL
592
+ // sweep hard-deletes past (spec §4.4). A `create`/`regenerate` generation
593
+ // has no expiry of its own; it lives as long as its `note_versions` row
594
+ // needs it for provenance.
595
+ expiresAt DateTime? @map("expires_at") @db.Timestamptz
596
+ startedAt DateTime? @map("started_at") @db.Timestamptz
597
+ completedAt DateTime? @map("completed_at") @db.Timestamptz
598
+ // NOTE: deliberately no `updatedAt` here, matching docs/specs/notes.md
599
+ // §4.4's Lifecycle group exactly — this row is already flushed on a
600
+ // 250ms interval while streaming (spec §5.1); a second auto-maintained
601
+ // timestamp on the same hot write path would cost index/row-version
602
+ // churn spec §4.4 does not ask for and nothing downstream reads.
603
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
604
+
605
+ note Note? @relation("NoteGenerations", fields: [noteId], references: [id], onDelete: Cascade)
606
+ template NoteTemplate? @relation("NoteGenerationTemplateSelection", fields: [templateId], references: [id], onDelete: SetNull)
607
+ job Job? @relation(fields: [jobId], references: [id], onDelete: SetNull)
608
+
609
+ // The inverse of `Note.currentGenerationId` above — structurally a list
610
+ // because the FK carries no `@unique`, even though at most one note will
611
+ // ever actually point here in practice (the note this generation belongs
612
+ // to, if any).
613
+ currentForNotes Note[] @relation("NoteCurrentGeneration")
614
+ versions NoteVersion[] @relation("NoteVersionGeneration")
615
+
616
+ @@map("note_generations")
617
+ }
618
+
619
+ // -----------------------------------------------------------------------------
620
+ // `note_versions` mirrors `transcript_versions` in spirit only — see the
621
+ // block comment above the enums for why a full body snapshot replaces the op
622
+ // log entirely rather than reusing it.
623
+ // -----------------------------------------------------------------------------
624
+ model NoteVersion {
625
+ id String @id @default(uuid()) @db.Uuid
626
+ noteId String @map("note_id") @db.Uuid
627
+ version Int
628
+ kind NoteVersionKind
629
+
630
+ // The full markdown, as stored — see the block comment above the enums.
631
+ body String @db.Text
632
+
633
+ // The syntax THIS version's body is written in ('markdown'|'plain_text',
634
+ // validated by Zod in the API, not by a database enum) — issue #337.
635
+ // Immutable once written: the source of truth for rendering/exporting
636
+ // that version, not a live read of the note's or template's current
637
+ // format. Restoring a version brings its format back onto the note,
638
+ // exactly as it brings the body back.
639
+ bodyFormat String @default("markdown") @map("body_format")
640
+
641
+ // A short, optional human- or AI-authored description of what changed in
642
+ // this version (issue #48) — e.g. "fixed the attendee list" for an
643
+ // `edit`, or left unset for most `ai_generated`/`restore` rows. Purely a
644
+ // display convenience for the version history list (#53); nothing in this
645
+ // schema derives it and nothing requires it to be set.
646
+ summary String?
647
+
648
+ // NULL means "the AI" — the identical convention `transcript_versions
649
+ // .authorId` already establishes, not a missing value. SetNull: a version
650
+ // is permanent history and must survive its human author's own account
651
+ // being deleted.
652
+ authorId String? @map("author_id") @db.Uuid
653
+
654
+ // Which generation produced this version, for `kind: 'ai_generated'`; NULL
655
+ // for `edit`/`restore`. SetNull for the identical reason `authorId` is —
656
+ // the version's `body` already carries everything durable; losing the
657
+ // pointer back to the generation that produced it loses only "which
658
+ // streaming run wrote this," never the content itself.
659
+ generationId String? @map("generation_id") @db.Uuid
660
+
661
+ // The version number this row restored FROM, when `kind: 'restore'` — a
662
+ // plain version number, not a self-relation FK, matching `transcripts
663
+ // .restoredFromVersion` exactly and for the identical reason: it names
664
+ // another row's `(noteId, version)` pair, not a column any FK can target.
665
+ restoredFromVersion Int? @map("restored_from_version")
666
+ // Idempotency key for the save endpoint (#53) — the same purpose as
667
+ // `transcript_versions.clientBatchId`, and the same free NULLS-DISTINCT
668
+ // behaviour below rather than a hand-written partial index.
669
+ clientBatchId String? @map("client_batch_id")
670
+
671
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
672
+
673
+ note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
674
+ author User? @relation("NoteVersionAuthor", fields: [authorId], references: [id], onDelete: SetNull)
675
+ generation NoteGeneration? @relation("NoteVersionGeneration", fields: [generationId], references: [id], onDelete: SetNull)
676
+
677
+ // The version sequence itself.
678
+ @@unique([noteId, version])
679
+ // The idempotent-retry key. Plain `@@unique`, not hand-written: Postgres's
680
+ // standard NULLS DISTINCT behaviour already gives the wanted semantics for
681
+ // this nullable column (any number of null-`clientBatchId` rows coexist).
682
+ @@unique([noteId, clientBatchId])
683
+ @@map("note_versions")
684
+ }
685
+
686
+ // -----------------------------------------------------------------------------
687
+ // `note_exports` mirrors `transcript_exports` FIELD FOR FIELD — see the block
688
+ // comment above the enums for the one place ("@@index", not "@@unique") this
689
+ // migration follows that table's real, already-shipped shape over spec
690
+ // §4.6's own shorthand prose.
691
+ // -----------------------------------------------------------------------------
692
+ model NoteExport {
693
+ id String @id @default(uuid()) @db.Uuid
694
+ noteId String @map("note_id") @db.Uuid
695
+ version Int
696
+
697
+ // The exporter's own registry key (`markdown`, `pdf`, `docx` — spec §8.1,
698
+ // §8.3). Plain string, not an enum, for the same self-registering-registry
699
+ // reason `TranscriptExport.format` is: adding an exporter must cost zero
700
+ // migrations to this table.
701
+ format String
702
+ options Json @db.JsonB
703
+ optionsHash String @map("options_hash")
704
+ status NoteExportStatus @default(pending)
705
+
706
+ // Restrict, matching every `storage_objects` FK on `transcripts`: only
707
+ // `notes.housekeeping`'s expiry sweep (spec §8.4) may remove the rendered
708
+ // file, and only after deleting this row first.
709
+ objectId String? @map("object_id") @db.Uuid
710
+ // The `note.export` job that rendered this row, when one did.
711
+ // `@unique`/SetNull, mirroring `TranscriptExport.jobId` exactly: a second
712
+ // render for the SAME job is unrepresentable, and this row's own 7-day
713
+ // retention is independent of `job.history.purge`'s schedule for the
714
+ // underlying `jobs` row.
715
+ jobId String? @unique @map("job_id") @db.Uuid
716
+
717
+ // Who asked for this export. NOT NULL and Cascade, matching
718
+ // `TranscriptExport.requestedById`: an export is a disposable,
719
+ // byte-for-byte reproducible artifact with its own 7-day expiry already
720
+ // built in — nothing is lost by deleting a pending/ready row alongside the
721
+ // account that requested it.
722
+ requestedById String @map("requested_by_id") @db.Uuid
723
+
724
+ error String?
725
+ expiresAt DateTime @map("expires_at") @db.Timestamptz
726
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
727
+
728
+ note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
729
+ object StorageObject? @relation("NoteExportObject", fields: [objectId], references: [id], onDelete: Restrict)
730
+ job Job? @relation(fields: [jobId], references: [id], onDelete: SetNull)
731
+ requestedBy User @relation("NoteExportRequestedBy", fields: [requestedById], references: [id], onDelete: Cascade)
732
+
733
+ // The reuse-by-content-hash lookup (spec §8.4) — deliberately a plain,
734
+ // NON-unique index. See the block comment above the enums for why this
735
+ // migration follows `transcript_exports_lookup_idx`'s real shape rather
736
+ // than spec §4.6's shorthand "unique" wording: an expired or failed row
737
+ // may briefly coexist with a fresh one sharing the same key before
738
+ // housekeeping removes the stale one.
739
+ //
740
+ // `map:` pins an explicit name for the identical reason
741
+ // `transcript_exports_lookup_idx` needed one: Prisma's own default name
742
+ // for this four-column index is one byte over PostgreSQL's 63-byte
743
+ // identifier limit.
744
+ @@index([noteId, version, format, optionsHash], map: "note_exports_lookup_idx")
745
+ @@map("note_exports")
746
+ }