@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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/dist/ask/index.d.ts +6 -0
- package/dist/ask/index.js +28 -0
- package/dist/ask/question-schema.d.ts +121 -0
- package/dist/ask/question-schema.js +167 -0
- package/dist/ask/report.d.ts +88 -0
- package/dist/ask/report.js +170 -0
- package/dist/ask/scorer.d.ts +104 -0
- package/dist/ask/scorer.js +164 -0
- package/dist/ask-run/ask-command.d.ts +62 -0
- package/dist/ask-run/ask-command.js +254 -0
- package/dist/ask-run/host/app.d.ts +65 -0
- package/dist/ask-run/host/app.js +146 -0
- package/dist/ask-run/host/db.d.ts +38 -0
- package/dist/ask-run/host/db.js +126 -0
- package/dist/ask-run/host/ports.d.ts +35 -0
- package/dist/ask-run/host/ports.js +113 -0
- package/dist/ask-run/index.d.ts +13 -0
- package/dist/ask-run/index.js +35 -0
- package/dist/ask-run/replay.d.ts +128 -0
- package/dist/ask-run/replay.js +212 -0
- package/dist/ask-run/run-ask-eval.d.ts +125 -0
- package/dist/ask-run/run-ask-eval.js +193 -0
- package/dist/ask-run/seed-golden-graph.d.ts +64 -0
- package/dist/ask-run/seed-golden-graph.js +495 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +12 -0
- package/dist/cli/kg-command.d.ts +66 -0
- package/dist/cli/kg-command.js +238 -0
- package/dist/cli/main.d.ts +18 -0
- package/dist/cli/main.js +69 -0
- package/dist/fixtures/domains.d.ts +16 -0
- package/dist/fixtures/domains.js +33 -0
- package/dist/fixtures/fixture-schema.d.ts +323 -0
- package/dist/fixtures/fixture-schema.js +199 -0
- package/dist/fixtures/index.d.ts +16 -0
- package/dist/fixtures/index.js +40 -0
- package/dist/fixtures/load.d.ts +73 -0
- package/dist/fixtures/load.js +137 -0
- package/dist/fixtures/questions.d.ts +15 -0
- package/dist/fixtures/questions.js +26 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +36 -0
- package/dist/kg/gold-runner.d.ts +13 -0
- package/dist/kg/gold-runner.js +57 -0
- package/dist/kg/index.d.ts +17 -0
- package/dist/kg/index.js +65 -0
- package/dist/kg/internals.d.ts +7 -0
- package/dist/kg/internals.js +17 -0
- package/dist/kg/prediction-schema.d.ts +245 -0
- package/dist/kg/prediction-schema.js +102 -0
- package/dist/kg/predictions.d.ts +11 -0
- package/dist/kg/predictions.js +49 -0
- package/dist/kg/report.d.ts +84 -0
- package/dist/kg/report.js +125 -0
- package/dist/kg/run-kg-eval.d.ts +77 -0
- package/dist/kg/run-kg-eval.js +137 -0
- package/dist/kg/runner.d.ts +72 -0
- package/dist/kg/runner.js +51 -0
- package/dist/kg/runners/extract-resolve-runner.d.ts +40 -0
- package/dist/kg/runners/extract-resolve-runner.js +199 -0
- package/dist/kg/runners/extract-runner.d.ts +65 -0
- package/dist/kg/runners/extract-runner.js +243 -0
- package/dist/kg/scorer.d.ts +226 -0
- package/dist/kg/scorer.js +463 -0
- package/dist/kg/targets.d.ts +17 -0
- package/dist/kg/targets.js +16 -0
- package/dist/kg/text.d.ts +45 -0
- package/dist/kg/text.js +100 -0
- package/dist/live/adapters.d.ts +16 -0
- package/dist/live/adapters.js +58 -0
- package/dist/live/env.d.ts +39 -0
- package/dist/live/env.js +56 -0
- package/dist/live/index.d.ts +6 -0
- package/dist/live/index.js +16 -0
- package/dist/live/live-ai.d.ts +104 -0
- package/dist/live/live-ai.js +157 -0
- package/dist/parity/constants.d.ts +79 -0
- package/dist/parity/constants.js +104 -0
- package/dist/parity/index.d.ts +20 -0
- package/dist/parity/index.js +38 -0
- package/dist/parity/internals.d.ts +8 -0
- package/dist/parity/internals.js +16 -0
- package/dist/parity/scenario.d.ts +45 -0
- package/dist/parity/scenario.js +245 -0
- package/dist/parity/script.d.ts +44 -0
- package/dist/parity/script.js +136 -0
- package/dist/parity/snapshot.d.ts +43 -0
- package/dist/parity/snapshot.js +71 -0
- package/dist/real-data/index.d.ts +2 -0
- package/dist/real-data/index.js +12 -0
- package/dist/real-data/real-data.d.ts +76 -0
- package/dist/real-data/real-data.js +156 -0
- package/fixtures/kg-golden/README.md +136 -0
- package/fixtures/kg-golden/ask-questions.json +458 -0
- package/fixtures/kg-golden/meetings/m01-northwind-pilot-kickoff.json +792 -0
- package/fixtures/kg-golden/meetings/m02-halden-migration-planning.json +814 -0
- package/fixtures/kg-golden/meetings/m03-larkspur-weekly-sync.json +601 -0
- package/fixtures/kg-golden/meetings/m04-corvid-intake-discovery.json +690 -0
- package/fixtures/kg-golden/meetings/m05-hosting-vendor-selection.json +631 -0
- package/fixtures/kg-golden/meetings/m06-northwind-floor-data-review.json +649 -0
- package/fixtures/kg-golden/meetings/m07-gullwing-intro-notes.json +428 -0
- package/fixtures/kg-golden/meetings/m08-halden-migration-checkpoint.json +672 -0
- package/fixtures/kg-golden/meetings/m09-northwind-platform-org-update.json +711 -0
- package/fixtures/kg-golden/meetings/m10-corvid-prototype-review.json +623 -0
- package/fixtures/kg-golden/meetings/m11-solvane-grid-dashboard-scoping.json +689 -0
- package/fixtures/kg-golden/meetings/m12-northwind-pilot-replan.json +681 -0
- package/fixtures/kg-golden/meetings/m13-halden-cutover-go-no-go.json +721 -0
- package/fixtures/kg-golden/meetings/m14-gullwing-discovery-readout.json +719 -0
- package/fixtures/kg-golden/meetings/m15-halden-handover-ravi-leaving.json +716 -0
- package/fixtures/kg-golden/meetings/m16-varden-claims-portal-intro.json +704 -0
- package/fixtures/kg-golden/meetings/m17-corvid-usability-debrief.json +572 -0
- package/fixtures/kg-golden/meetings/m18-hosting-decision-revisited.json +655 -0
- package/fixtures/kg-golden/meetings/m19-solvane-integration-kickoff.json +726 -0
- package/fixtures/kg-golden/meetings/m20-northwind-go-live-retro.json +673 -0
- package/fixtures/kg-golden/meetings/m21-quarterly-account-review.json +449 -0
- package/fixtures/kg-golden/meetings/m22-halden-intro-call-2020.json +558 -0
- package/fixtures/kg-golden/meetings/m23-gullwing-model-review.json +702 -0
- package/fixtures/kg-golden/meetings/m24-varden-discovery-midpoint.json +643 -0
- package/fixtures/kg-golden/meetings/m25-corvid-rollout-planning.json +642 -0
- package/fixtures/kg-golden/meetings/m26-larkspur-engineering-prep.json +695 -0
- package/fixtures/kg-golden/meetings/m27-solvane-phase-one-demo.json +649 -0
- package/fixtures/kg-golden/meetings/m28-northwind-building-5-scoping.json +715 -0
- package/fixtures/kg-golden/meetings/m29-halden-southern-cutover-review.json +752 -0
- package/fixtures/kg-golden/meetings/m30-varden-portal-build-kickoff.json +697 -0
- package/fixtures/kg-golden/meetings/m31-gullwing-rollout-renewal.json +681 -0
- package/fixtures/kg-golden/meetings/m32-brambleway-weekly-sync.json +472 -0
- package/fixtures/kg-golden/meetings/m33-nina-oliver-one-on-one.json +342 -0
- package/fixtures/kg-golden/meetings/m34-coffee-with-dana.json +249 -0
- package/fixtures/kg-golden/meetings/m35-brambleway-june-sync.json +553 -0
- package/fixtures/kg-golden/meetings/m36-copenhagen-conference-planning.json +296 -0
- package/host/generated/client.d.ts +1 -0
- package/host/generated/client.js +5 -0
- package/host/generated/default.d.ts +1 -0
- package/host/generated/default.js +5 -0
- package/host/generated/edge.d.ts +1 -0
- package/host/generated/edge.js +1658 -0
- package/host/generated/index-browser.js +1684 -0
- package/host/generated/index.d.ts +192335 -0
- package/host/generated/index.js +1658 -0
- package/host/generated/package.json +139 -0
- package/host/generated/query_compiler_fast_bg.js +2 -0
- package/host/generated/query_compiler_fast_bg.wasm +0 -0
- package/host/generated/query_compiler_fast_bg.wasm-base64.js +2 -0
- package/host/generated/runtime/client.d.ts +3433 -0
- package/host/generated/runtime/client.js +110 -0
- package/host/generated/runtime/index-browser.d.ts +90 -0
- package/host/generated/runtime/index-browser.js +6 -0
- package/host/generated/runtime/wasm-compiler-edge.js +100 -0
- package/host/generated/schema.prisma +6312 -0
- package/host/generated/wasm-edge-light-loader.mjs +5 -0
- package/host/generated/wasm-worker-loader.mjs +5 -0
- package/host/prisma/fragments/kvox.ask.prisma +126 -0
- package/host/prisma/fragments/kvox.graph.prisma +1073 -0
- package/host/prisma/fragments/kvox.notes.prisma +787 -0
- package/host/prisma/fragments/kvox.search.prisma +211 -0
- package/host/prisma/fragments/kvox.transcripts.prisma +790 -0
- package/host/prisma/kvox.lock +101 -0
- package/host/prisma/migrations/20240101000000_initial/migration.sql +297 -0
- package/host/prisma/migrations/20240101000001_add_personal_access_tokens/migration.sql +31 -0
- package/host/prisma/migrations/20240101000002_add_credentials/migration.sql +20 -0
- package/host/prisma/migrations/20240101000003_add_notification_deliveries/migration.sql +27 -0
- package/host/prisma/migrations/20240101000004_drop_stale_uuid_defaults/migration.sql +35 -0
- package/host/prisma/migrations/20240101000005_add_notifications/migration.sql +22 -0
- package/host/prisma/migrations/20240101000006_add_push_subscriptions/migration.sql +25 -0
- package/host/prisma/migrations/20240101000007_add_jobs/migration.sql +110 -0
- package/host/prisma/migrations/20240101000008_add_worker_nodes/migration.sql +90 -0
- package/host/prisma/migrations/20240101000009_add_database_backup_runs/migration.sql +104 -0
- package/host/prisma/migrations/20240101000010_add_notification_broadcasts/migration.sql +57 -0
- package/host/prisma/migrations/20240101000011_add_backup_run_job_link/migration.sql +87 -0
- package/host/prisma/migrations/20240101000012_add_job_node_secrets/migration.sql +61 -0
- package/host/prisma/migrations/20240101000013_add_backup_run_pg_dump_version/migration.sql +26 -0
- package/host/prisma/migrations/20240101000014_add_job_claim_token/migration.sql +92 -0
- package/host/prisma/migrations/20240101000015_add_ai_platform/migration.sql +108 -0
- package/host/prisma/migrations/20240101000016_revoke_viewer_ai_use/migration.sql +32 -0
- package/host/prisma/migrations/20240101000017_add_user_credentials/migration.sql +29 -0
- package/host/prisma/migrations/20240101000018_add_device_session_credential_link/migration.sql +52 -0
- package/host/prisma/migrations/20240101000019_add_worker_node_vitals/migration.sql +3 -0
- package/host/prisma/migrations/20240101000020_add_job_trace_context/migration.sql +6 -0
- package/host/prisma/migrations/20240101000021_add_retention_created_at_indexes/migration.sql +13 -0
- package/host/prisma/migrations/20240101000022_add_organizations/migration.sql +142 -0
- package/host/prisma/migrations/20240101000023_split_system_org_roles/migration.sql +131 -0
- package/host/prisma/migrations/20240101000024_org_scoped_rls/migration.sql +215 -0
- package/host/prisma/migrations/20240101000025_add_groups/migration.sql +165 -0
- package/host/prisma/migrations/20240101000026_add_grants/migration.sql +117 -0
- package/host/prisma/migrations/20240101000027_add_org_settings/migration.sql +49 -0
- package/host/prisma/migrations/20240101000028_add_org_credentials/migration.sql +54 -0
- package/host/prisma/migrations/20240101000029_add_jobs_org_id/migration.sql +18 -0
- package/host/prisma/migrations/20240101000030_add_jobs_org_id_status_index/migration.sql +6 -0
- package/host/prisma/migrations/20240101000031_add_broadcast_target_org/migration.sql +21 -0
- package/host/prisma/migrations/20240101000032_add_android_app/migration.sql +60 -0
- package/host/prisma/migrations/20240101000033_open_job_reason/migration.sql +16 -0
- package/host/prisma/migrations/20240101000034_ai_usage_feature/migration.sql +37 -0
- package/host/prisma/migrations/20240101000035_add_app_metadata/migration.sql +49 -0
- package/host/prisma/migrations/20240101000036_open_sharing_vocabularies/migration.sql +97 -0
- package/host/prisma/migrations/20260914130000_kvox_add_transcripts/migration.sql +322 -0
- package/host/prisma/migrations/20260914140000_kvox_add_notes/migration.sql +311 -0
- package/host/prisma/migrations/20260915120000_kvox_add_search_vectors/migration.sql +150 -0
- package/host/prisma/migrations/20260915130000_kvox_add_search_embeddings/migration.sql +362 -0
- package/host/prisma/migrations/20260915210000_kvox_add_note_title_source/migration.sql +30 -0
- package/host/prisma/migrations/20260923120000_kvox_add_note_generation_context_snapshot/migration.sql +24 -0
- package/host/prisma/migrations/20260923130000_kvox_add_user_hidden_note_templates/migration.sql +35 -0
- package/host/prisma/migrations/20260923140000_kvox_add_transcript_speaker_identities/migration.sql +6 -0
- package/host/prisma/migrations/20260923150000_kvox_transcript_name_checks/migration.sql +95 -0
- package/host/prisma/migrations/20260923160000_kvox_add_note_body_format/migration.sql +27 -0
- package/host/prisma/migrations/20260923170000_kvox_add_note_version_body_format/migration.sql +23 -0
- package/host/prisma/migrations/20260926012548_kvox_add_knowledge_graph/migration.sql +680 -0
- package/host/prisma/migrations/20260926030000_kvox_fix_kg_relations_valid_precision_chk/migration.sql +29 -0
- package/host/prisma/migrations/20260926030100_kvox_add_transcript_recorded_at/migration.sql +9 -0
- package/host/prisma/migrations/20260926030200_kvox_kg_evidence_invariant/migration.sql +60 -0
- package/host/prisma/migrations/20260926040000_kvox_add_kg_graph_layouts/migration.sql +30 -0
- package/host/prisma/migrations/20260926050000_kvox_add_ask_conversations/migration.sql +99 -0
- package/host/prisma/migrations/20260926231512_kvox_add_kg_exports/migration.sql +52 -0
- package/host/prisma/migrations/20261016120000_kvox_transcripts_org_id/migration.sql +49 -0
- package/host/prisma/platform.lock +319 -0
- package/host/prisma/schema/app.kvox.ask.prisma +110 -0
- package/host/prisma/schema/app.kvox.graph.prisma +1030 -0
- package/host/prisma/schema/app.kvox.notes.prisma +746 -0
- package/host/prisma/schema/app.kvox.search.prisma +208 -0
- package/host/prisma/schema/app.kvox.transcripts.prisma +726 -0
- package/host/prisma/schema/platform.ai.prisma +419 -0
- package/host/prisma/schema/platform.android-app.prisma +46 -0
- package/host/prisma/schema/platform.base.prisma +15 -0
- package/host/prisma/schema/platform.credentials.prisma +156 -0
- package/host/prisma/schema/platform.db-backup.prisma +359 -0
- package/host/prisma/schema/platform.identity.prisma +638 -0
- package/host/prisma/schema/platform.jobs.prisma +824 -0
- package/host/prisma/schema/platform.notifications.prisma +641 -0
- package/host/prisma/schema/platform.settings.prisma +53 -0
- package/host/prisma/schema/platform.sharing.prisma +208 -0
- package/host/prisma/schema/platform.storage.prisma +118 -0
- package/package.json +112 -1
- package/parity/requests.ask.json +2681 -0
- package/parity/requests.m01.json +1554 -0
- package/parity/requests.m02.json +1554 -0
- package/parity/requests.m03.json +1554 -0
- package/parity/requests.m04.json +1554 -0
- package/parity/requests.m05.json +1554 -0
- package/parity/requests.m06.json +1554 -0
- package/parity/requests.m07.json +1554 -0
- package/parity/requests.m08.json +1554 -0
- package/parity/requests.m09.json +1554 -0
- 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
|
+
}
|