@affordance/core 0.1.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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/engine/compute.d.ts +147 -0
  4. package/dist/engine/compute.js +152 -0
  5. package/dist/engine/compute.js.map +1 -0
  6. package/dist/engine/engine.d.ts +162 -0
  7. package/dist/engine/engine.js +115 -0
  8. package/dist/engine/engine.js.map +1 -0
  9. package/dist/engine/errors.d.ts +6 -0
  10. package/dist/engine/errors.js +11 -0
  11. package/dist/engine/errors.js.map +1 -0
  12. package/dist/engine/index.d.ts +16 -0
  13. package/dist/engine/index.js +15 -0
  14. package/dist/engine/index.js.map +1 -0
  15. package/dist/errors.d.ts +46 -0
  16. package/dist/errors.js +42 -0
  17. package/dist/errors.js.map +1 -0
  18. package/dist/execution/delta.d.ts +42 -0
  19. package/dist/execution/delta.js +93 -0
  20. package/dist/execution/delta.js.map +1 -0
  21. package/dist/execution/errors.d.ts +94 -0
  22. package/dist/execution/errors.js +132 -0
  23. package/dist/execution/errors.js.map +1 -0
  24. package/dist/execution/execute.d.ts +179 -0
  25. package/dist/execution/execute.js +356 -0
  26. package/dist/execution/execute.js.map +1 -0
  27. package/dist/execution/index.d.ts +25 -0
  28. package/dist/execution/index.js +20 -0
  29. package/dist/execution/index.js.map +1 -0
  30. package/dist/execution/journal.d.ts +190 -0
  31. package/dist/execution/journal.js +192 -0
  32. package/dist/execution/journal.js.map +1 -0
  33. package/dist/execution/port.d.ts +81 -0
  34. package/dist/execution/port.js +101 -0
  35. package/dist/execution/port.js.map +1 -0
  36. package/dist/execution/replay.d.ts +58 -0
  37. package/dist/execution/replay.js +59 -0
  38. package/dist/execution/replay.js.map +1 -0
  39. package/dist/execution/timers.d.ts +21 -0
  40. package/dist/execution/timers.js +26 -0
  41. package/dist/execution/timers.js.map +1 -0
  42. package/dist/execution/transaction.d.ts +24 -0
  43. package/dist/execution/transaction.js +49 -0
  44. package/dist/execution/transaction.js.map +1 -0
  45. package/dist/guards/condition.d.ts +86 -0
  46. package/dist/guards/condition.js +11 -0
  47. package/dist/guards/condition.js.map +1 -0
  48. package/dist/guards/evaluate.d.ts +143 -0
  49. package/dist/guards/evaluate.js +167 -0
  50. package/dist/guards/evaluate.js.map +1 -0
  51. package/dist/guards/guard.d.ts +136 -0
  52. package/dist/guards/guard.js +118 -0
  53. package/dist/guards/guard.js.map +1 -0
  54. package/dist/guards/index.d.ts +20 -0
  55. package/dist/guards/index.js +17 -0
  56. package/dist/guards/index.js.map +1 -0
  57. package/dist/guards/time.d.ts +32 -0
  58. package/dist/guards/time.js +45 -0
  59. package/dist/guards/time.js.map +1 -0
  60. package/dist/index.d.ts +31 -0
  61. package/dist/index.js +20 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/ingestion/correlation.d.ts +54 -0
  64. package/dist/ingestion/correlation.js +78 -0
  65. package/dist/ingestion/correlation.js.map +1 -0
  66. package/dist/ingestion/index.d.ts +16 -0
  67. package/dist/ingestion/index.js +15 -0
  68. package/dist/ingestion/index.js.map +1 -0
  69. package/dist/ingestion/ingest.d.ts +196 -0
  70. package/dist/ingestion/ingest.js +275 -0
  71. package/dist/ingestion/ingest.js.map +1 -0
  72. package/dist/migration/index.d.ts +10 -0
  73. package/dist/migration/index.js +10 -0
  74. package/dist/migration/index.js.map +1 -0
  75. package/dist/migration/migrate.d.ts +119 -0
  76. package/dist/migration/migrate.js +195 -0
  77. package/dist/migration/migrate.js.map +1 -0
  78. package/dist/model/casetype.d.ts +53 -0
  79. package/dist/model/casetype.js +51 -0
  80. package/dist/model/casetype.js.map +1 -0
  81. package/dist/model/errors.d.ts +28 -0
  82. package/dist/model/errors.js +39 -0
  83. package/dist/model/errors.js.map +1 -0
  84. package/dist/model/handler.d.ts +120 -0
  85. package/dist/model/handler.js +16 -0
  86. package/dist/model/handler.js.map +1 -0
  87. package/dist/model/index.d.ts +24 -0
  88. package/dist/model/index.js +19 -0
  89. package/dist/model/index.js.map +1 -0
  90. package/dist/model/retry.d.ts +40 -0
  91. package/dist/model/retry.js +51 -0
  92. package/dist/model/retry.js.map +1 -0
  93. package/dist/model/scope.d.ts +67 -0
  94. package/dist/model/scope.js +19 -0
  95. package/dist/model/scope.js.map +1 -0
  96. package/dist/model/step.d.ts +232 -0
  97. package/dist/model/step.js +210 -0
  98. package/dist/model/step.js.map +1 -0
  99. package/dist/model/target.d.ts +175 -0
  100. package/dist/model/target.js +236 -0
  101. package/dist/model/target.js.map +1 -0
  102. package/dist/store/bootstrap.d.ts +57 -0
  103. package/dist/store/bootstrap.js +268 -0
  104. package/dist/store/bootstrap.js.map +1 -0
  105. package/dist/store/errors.d.ts +18 -0
  106. package/dist/store/errors.js +26 -0
  107. package/dist/store/errors.js.map +1 -0
  108. package/dist/store/ids.d.ts +15 -0
  109. package/dist/store/ids.js +12 -0
  110. package/dist/store/ids.js.map +1 -0
  111. package/dist/store/index.d.ts +12 -0
  112. package/dist/store/index.js +9 -0
  113. package/dist/store/index.js.map +1 -0
  114. package/dist/store/queryable.d.ts +60 -0
  115. package/dist/store/queryable.js +7 -0
  116. package/dist/store/queryable.js.map +1 -0
  117. package/dist/store/resolve.d.ts +66 -0
  118. package/dist/store/resolve.js +71 -0
  119. package/dist/store/resolve.js.map +1 -0
  120. package/dist/store/sql.d.ts +26 -0
  121. package/dist/store/sql.js +21 -0
  122. package/dist/store/sql.js.map +1 -0
  123. package/dist/store/store.d.ts +66 -0
  124. package/dist/store/store.js +108 -0
  125. package/dist/store/store.js.map +1 -0
  126. package/package.json +61 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Dedicated Postgres schema owning all framework tables.
3
+ * Named `affordance` because `case` itself is a SQL reserved word.
4
+ */
5
+ export const FRAMEWORK_SCHEMA = 'affordance';
6
+ /**
7
+ * The DDL revision below. Bump it whenever the DDL changes: a database
8
+ * already carrying this version skips the DDL entirely, which is what keeps
9
+ * a start-up from touching a busy database at all.
10
+ */
11
+ export const SCHEMA_VERSION = 4;
12
+ /**
13
+ * Framework DDL, `IF NOT EXISTS` throughout — no migration framework.
14
+ *
15
+ * `cases` columns:
16
+ * - `id` text — a typed id (`case:<uuid>`), minted by the store on
17
+ * creation; every framework id carries its kind (see `ids.ts`)
18
+ * - `case_type` the Case Type name (the code definition floats; only the
19
+ * name is persisted)
20
+ * - `state` the materialized Case State document
21
+ * - `seq` per-case monotonic sequence counter, starts at 0; bumped by
22
+ * every committed Execution
23
+ * - `ended_at` dormancy marker written by `end()` — null while active;
24
+ * dormancy, never a freeze (spec §Core model)
25
+ *
26
+ * `journal` is the immutable per-Execution record. The framework
27
+ * only ever **inserts** into it — no update or delete path exists anywhere in
28
+ * the library. One Execution contributes several entries (`claimed`, then any
29
+ * `attempt-failed`, then a terminal `completed` / `failed` / `expired`), each
30
+ * self-contained so a per-track audit is a filter, never a join:
31
+ * - `ordinal` bigserial — total insertion order; per-case order is
32
+ * `(case_id, ordinal)`
33
+ * - `entry` which lifecycle moment this row records
34
+ * - `step`/`scope_key`/`actor`/`input` — the Execution's identity, repeated on
35
+ * every entry so `where scope_key = …` is the per-track audit
36
+ * - `as_of`/`guard`/`state` — on `claimed`: the transactional guard
37
+ * re-evaluation, the instant it was evaluated as of, and the
38
+ * Case State it was evaluated against. Together they make
39
+ * audit reconstruction exact rather than approximate.
40
+ * - `delta` on `completed`: the JSON-Patch delta (previous → next)
41
+ * - `dormancy` on `completed`: `end()` / `reopen()` called by the handler
42
+ * - `error` on `attempt-failed` / `failed` / `expired`
43
+ *
44
+ * `correlations` and `ingested_events` are the two integration primitives.
45
+ * A correlation maps an external identifier to (case, scope
46
+ * element); it is written by the handler that starts the external
47
+ * interaction, and `unique (system, external_id)` makes re-registering the
48
+ * same envelope idempotent — a repeat changes nothing. `ingested_events` is
49
+ * both the dedup gate and the dead-letter surface: `unique (idempotency_key)`
50
+ * is what makes "three deliveries, one Execution" a database fact rather than
51
+ * a hope, and the `status` / `reason` columns are why an event that changed
52
+ * nothing is still visible.
53
+ *
54
+ * `claims` is the opposite kind of table: mutable, transient lease
55
+ * bookkeeping, one row per **in-flight** Execution, deleted the moment the
56
+ * Execution settles. `case_id` is its primary key — that single constraint is
57
+ * "one in-flight execution per case". `expires_at` is what keeps a crash
58
+ * from stranding a case: a crashed handler stops heartbeating and the next
59
+ * claimant takes the case over (journaling an `expired` entry for the
60
+ * abandoned Execution).
61
+ */
62
+ const DDL = `
63
+ select pg_advisory_xact_lock(hashtextextended('${FRAMEWORK_SCHEMA}.bootstrap', 0));
64
+
65
+ -- DDL must never be what blocks live work. "create index if not exists" and
66
+ -- friends take table locks whether or not they have anything to do, so a
67
+ -- bootstrap running against a busy database can queue behind -- or deadlock
68
+ -- with -- Executions committing. Bounding the wait makes this transaction
69
+ -- the one that yields, and bootstrap() retries it.
70
+ set local lock_timeout = '2s';
71
+
72
+ create schema if not exists ${FRAMEWORK_SCHEMA};
73
+
74
+ -- Schema v2 stores typed text ids ('case:<uuid>', 'execution:<uuid>', …); v1
75
+ -- stored bare uuids in uuid columns, which cannot hold them. There is no DDL
76
+ -- migration framework, so a v1 database fails loudly here rather than
77
+ -- corrupting silently on the first insert.
78
+ do $$
79
+ begin
80
+ if exists (
81
+ select 1 from information_schema.columns
82
+ where table_schema = '${FRAMEWORK_SCHEMA}' and table_name = 'cases'
83
+ and column_name = 'id' and data_type = 'uuid'
84
+ ) then
85
+ raise exception 'affordance schema v1 detected (uuid ids); v2 ids are text of the form kind:uuid. No automatic conversion exists — export anything you need, then: drop schema ${FRAMEWORK_SCHEMA} cascade; and re-bootstrap.';
86
+ end if;
87
+ end $$;
88
+
89
+ create table if not exists ${FRAMEWORK_SCHEMA}.cases (
90
+ id text primary key,
91
+ case_type text not null,
92
+ state jsonb not null,
93
+ seq bigint not null default 0,
94
+ ended_at timestamptz,
95
+ created_at timestamptz not null default now(),
96
+ updated_at timestamptz not null default now()
97
+ );
98
+
99
+ create table if not exists ${FRAMEWORK_SCHEMA}.journal (
100
+ ordinal bigserial primary key,
101
+ id text not null unique,
102
+ case_id text not null references ${FRAMEWORK_SCHEMA}.cases (id),
103
+ execution_id text not null,
104
+ entry text not null,
105
+ attempt integer not null default 1,
106
+ step text not null,
107
+ scope_key text,
108
+ actor jsonb,
109
+ input jsonb,
110
+ as_of timestamptz,
111
+ guard jsonb,
112
+ state jsonb,
113
+ delta jsonb,
114
+ dormancy text,
115
+ error jsonb,
116
+ recorded_at timestamptz not null default now()
117
+ );
118
+
119
+ -- Schema v4 removed rule automation, and with it the \`cause\` column (the
120
+ -- causality record an automatic Execution carried). The column is left in
121
+ -- place on a database that has it: journal rows are immutable history, and
122
+ -- old automatic Executions keep the cause they were recorded with. New
123
+ -- entries simply never write it.
124
+
125
+ create index if not exists journal_case_idx
126
+ on ${FRAMEWORK_SCHEMA}.journal (case_id, ordinal);
127
+ create index if not exists journal_scope_idx
128
+ on ${FRAMEWORK_SCHEMA}.journal (case_id, scope_key, ordinal);
129
+ create index if not exists journal_execution_idx
130
+ on ${FRAMEWORK_SCHEMA}.journal (execution_id, ordinal);
131
+
132
+ create table if not exists ${FRAMEWORK_SCHEMA}.claims (
133
+ case_id text primary key references ${FRAMEWORK_SCHEMA}.cases (id),
134
+ execution_id text not null,
135
+ step text not null,
136
+ scope_key text,
137
+ attempt integer not null default 1,
138
+ claimed_at timestamptz not null default now(),
139
+ heartbeat_at timestamptz not null default now(),
140
+ expires_at timestamptz not null
141
+ );
142
+
143
+ -- Schema v3 removed timer scheduling. Timer rows were derived state (a
144
+ -- case's future time-flips, recomputable from nothing but Case State), so
145
+ -- dropping the table on a v2 database loses no facts.
146
+ drop table if exists ${FRAMEWORK_SCHEMA}.timers;
147
+
148
+ create table if not exists ${FRAMEWORK_SCHEMA}.correlations (
149
+ id text primary key,
150
+ system text not null,
151
+ external_id text not null,
152
+ case_id text not null references ${FRAMEWORK_SCHEMA}.cases (id),
153
+ scope_key text,
154
+ step text,
155
+ metadata jsonb,
156
+ created_at timestamptz not null default now(),
157
+ unique (system, external_id)
158
+ );
159
+
160
+ create index if not exists correlations_case_idx
161
+ on ${FRAMEWORK_SCHEMA}.correlations (case_id, scope_key);
162
+
163
+ create table if not exists ${FRAMEWORK_SCHEMA}.ingested_events (
164
+ id text primary key,
165
+ system text not null,
166
+ external_id text not null,
167
+ type text not null,
168
+ idempotency_key text not null unique,
169
+ case_id text,
170
+ scope_key text,
171
+ step text,
172
+ status text not null,
173
+ reason text,
174
+ detail text,
175
+ execution_id text,
176
+ event jsonb not null,
177
+ received_at timestamptz not null default now()
178
+ );
179
+
180
+ create index if not exists ingested_events_dead_letter_idx
181
+ on ${FRAMEWORK_SCHEMA}.ingested_events (status, received_at desc);
182
+
183
+ create table if not exists ${FRAMEWORK_SCHEMA}.schema_version (
184
+ version integer primary key,
185
+ applied_at timestamptz not null default now()
186
+ );
187
+
188
+ insert into ${FRAMEWORK_SCHEMA}.schema_version (version)
189
+ values (${SCHEMA_VERSION}) on conflict do nothing;
190
+ `;
191
+ /**
192
+ * Every framework table that holds rows belonging to one case, with the
193
+ * column that names the case — listed in an order safe to delete from
194
+ * (children first; everything references `cases`). **The one answer to
195
+ * "which tables does the framework own"** outside the DDL above: a consumer
196
+ * that sweeps per-case rows (a dev console's case purge, a test harness's
197
+ * cleanup) iterates this instead of keeping a private copy that goes stale
198
+ * the release a table is added.
199
+ *
200
+ * `ingested_events.case_id` is nullable — an unrouted event belongs to no
201
+ * case and survives a per-case sweep, which is correct: it was never about
202
+ * the deleted case.
203
+ */
204
+ export const CASE_TABLES = [
205
+ { table: 'journal', caseColumn: 'case_id' },
206
+ { table: 'claims', caseColumn: 'case_id' },
207
+ { table: 'correlations', caseColumn: 'case_id' },
208
+ { table: 'ingested_events', caseColumn: 'case_id' },
209
+ { table: 'cases', caseColumn: 'id' },
210
+ ];
211
+ /**
212
+ * Whether the schema is already at {@link SCHEMA_VERSION} — two catalog reads
213
+ * that take no lock any Execution could ever be waiting on.
214
+ *
215
+ * This is what makes `bootstrap` free on every start after the first. The DDL
216
+ * below is idempotent, but idempotent is not the same as *harmless*: `create
217
+ * index if not exists` takes a table lock whether or not it has work to do,
218
+ * and a bootstrap holding one while Executions commit can deadlock with them
219
+ * — not as a rare race, but predictably, on every start against a busy
220
+ * database. Asking first means the locks are only ever taken when there is
221
+ * genuinely something to create.
222
+ */
223
+ const isCurrent = async (db) => {
224
+ const marker = await db.query(`select to_regclass('${FRAMEWORK_SCHEMA}.schema_version') is not null as present`);
225
+ if (marker.rows[0]?.present !== true)
226
+ return false;
227
+ const applied = await db.query(`select max(version) as version from ${FRAMEWORK_SCHEMA}.schema_version`);
228
+ return (applied.rows[0]?.version ?? 0) >= SCHEMA_VERSION;
229
+ };
230
+ /** Postgres says the transaction lost a race it can retry: deadlock, or the bounded lock wait. */
231
+ const isContention = (error) => {
232
+ const code = error?.code;
233
+ return code === '40P01' || code === '55P03' || code === '40001';
234
+ };
235
+ const wait = (ms) => new Promise((resolve) => {
236
+ setTimeout(resolve, ms);
237
+ });
238
+ /**
239
+ * Idempotent DDL bootstrap for the framework schema. Safe to call on every
240
+ * app start and from concurrent processes: the statements are sent as one
241
+ * multi-statement simple query, which Postgres runs on one connection inside
242
+ * a single implicit transaction, and the leading `pg_advisory_xact_lock`
243
+ * serializes racing bootstraps (concurrent `CREATE ... IF NOT EXISTS` can
244
+ * otherwise fail on catalog uniqueness).
245
+ *
246
+ * Also safe to call against a *busy* database, which is the harder promise,
247
+ * and is answered twice over. First, a bootstrap with nothing to do does
248
+ * nothing at all: {@link isCurrent} checks the version marker and returns
249
+ * before any DDL runs, so the common case takes no table locks whatsoever.
250
+ * Second, when there *is* work, the transaction bounds its own lock wait and
251
+ * this retries it — schema management yields to live work, never the reverse.
252
+ */
253
+ export const bootstrap = async (db, attempts = 5) => {
254
+ if (await isCurrent(db))
255
+ return;
256
+ for (let attempt = 1;; attempt += 1) {
257
+ try {
258
+ await db.query(DDL);
259
+ return;
260
+ }
261
+ catch (error) {
262
+ if (attempt >= attempts || !isContention(error))
263
+ throw error;
264
+ await wait(50 * attempt);
265
+ }
266
+ }
267
+ };
268
+ //# sourceMappingURL=bootstrap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bootstrap.js","sourceRoot":"","sources":["../../src/store/bootstrap.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,YAAY,CAAA;AAE5C;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAA;AAE/B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,MAAM,GAAG,GAAG;iDACqC,gBAAgB;;;;;;;;;8BASnC,gBAAgB;;;;;;;;;;4BAUlB,gBAAgB;;;qLAGyI,gBAAgB;;;;6BAIxK,gBAAgB;;;;;;;;;;6BAUhB,gBAAgB;;;qCAGR,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;OAwB9C,gBAAgB;;OAEhB,gBAAgB;;OAEhB,gBAAgB;;6BAEM,gBAAgB;wCACL,gBAAgB;;;;;;;;;;;;;uBAajC,gBAAgB;;6BAEV,gBAAgB;;;;qCAIR,gBAAgB;;;;;;;;;OAS9C,gBAAgB;;6BAEM,gBAAgB;;;;;;;;;;;;;;;;;;OAkBtC,gBAAgB;;6BAEM,gBAAgB;;;;;cAK/B,gBAAgB;UACpB,cAAc;CACvB,CAAA;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,EAAE,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE;IAC3C,EAAE,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE;IAC1C,EAAE,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,SAAS,EAAE;IAChD,EAAE,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,SAAS,EAAE;IACnD,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE;CAC5B,CAAA;AAEV;;;;;;;;;;;GAWG;AACH,MAAM,SAAS,GAAG,KAAK,EAAE,EAAa,EAAoB,EAAE;IAC1D,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,KAAK,CAC3B,uBAAuB,gBAAgB,0CAA0C,CAClF,CAAA;IACD,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI;QAAE,OAAO,KAAK,CAAA;IAClD,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,KAAK,CAC5B,uCAAuC,gBAAgB,iBAAiB,CACzE,CAAA;IACD,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,OAAO,IAAI,CAAC,CAAC,IAAI,cAAc,CAAA;AAC1D,CAAC,CAAA;AAED,kGAAkG;AAClG,MAAM,YAAY,GAAG,CAAC,KAAc,EAAW,EAAE;IAC/C,MAAM,IAAI,GAAI,KAAmC,EAAE,IAAI,CAAA;IACvD,OAAO,IAAI,KAAK,OAAO,IAAI,IAAI,KAAK,OAAO,IAAI,IAAI,KAAK,OAAO,CAAA;AACjE,CAAC,CAAA;AAED,MAAM,IAAI,GAAG,CAAC,EAAU,EAAiB,EAAE,CACzC,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;IACtB,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAA;AACzB,CAAC,CAAC,CAAA;AAEJ;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,KAAK,EAAE,EAAa,EAAE,QAAQ,GAAG,CAAC,EAAiB,EAAE;IAC5E,IAAI,MAAM,SAAS,CAAC,EAAE,CAAC;QAAE,OAAM;IAC/B,KAAK,IAAI,OAAO,GAAG,CAAC,GAAI,OAAO,IAAI,CAAC,EAAE,CAAC;QACrC,IAAI,CAAC;YACH,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;YACnB,OAAM;QACR,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,OAAO,IAAI,QAAQ,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC;gBAAE,MAAM,KAAK,CAAA;YAC5D,MAAM,IAAI,CAAC,EAAE,GAAG,OAAO,CAAC,CAAA;QAC1B,CAAC;IACH,CAAC;AACH,CAAC,CAAA","sourcesContent":["import type { Queryable } from './queryable.js'\n\n/**\n * Dedicated Postgres schema owning all framework tables.\n * Named `affordance` because `case` itself is a SQL reserved word.\n */\nexport const FRAMEWORK_SCHEMA = 'affordance'\n\n/**\n * The DDL revision below. Bump it whenever the DDL changes: a database\n * already carrying this version skips the DDL entirely, which is what keeps\n * a start-up from touching a busy database at all.\n */\nexport const SCHEMA_VERSION = 4\n\n/**\n * Framework DDL, `IF NOT EXISTS` throughout — no migration framework.\n *\n * `cases` columns:\n * - `id` text — a typed id (`case:<uuid>`), minted by the store on\n * creation; every framework id carries its kind (see `ids.ts`)\n * - `case_type` the Case Type name (the code definition floats; only the\n * name is persisted)\n * - `state` the materialized Case State document\n * - `seq` per-case monotonic sequence counter, starts at 0; bumped by\n * every committed Execution\n * - `ended_at` dormancy marker written by `end()` — null while active;\n * dormancy, never a freeze (spec §Core model)\n *\n * `journal` is the immutable per-Execution record. The framework\n * only ever **inserts** into it — no update or delete path exists anywhere in\n * the library. One Execution contributes several entries (`claimed`, then any\n * `attempt-failed`, then a terminal `completed` / `failed` / `expired`), each\n * self-contained so a per-track audit is a filter, never a join:\n * - `ordinal` bigserial — total insertion order; per-case order is\n * `(case_id, ordinal)`\n * - `entry` which lifecycle moment this row records\n * - `step`/`scope_key`/`actor`/`input` — the Execution's identity, repeated on\n * every entry so `where scope_key = …` is the per-track audit\n * - `as_of`/`guard`/`state` — on `claimed`: the transactional guard\n * re-evaluation, the instant it was evaluated as of, and the\n * Case State it was evaluated against. Together they make\n * audit reconstruction exact rather than approximate.\n * - `delta` on `completed`: the JSON-Patch delta (previous → next)\n * - `dormancy` on `completed`: `end()` / `reopen()` called by the handler\n * - `error` on `attempt-failed` / `failed` / `expired`\n *\n * `correlations` and `ingested_events` are the two integration primitives.\n * A correlation maps an external identifier to (case, scope\n * element); it is written by the handler that starts the external\n * interaction, and `unique (system, external_id)` makes re-registering the\n * same envelope idempotent — a repeat changes nothing. `ingested_events` is\n * both the dedup gate and the dead-letter surface: `unique (idempotency_key)`\n * is what makes \"three deliveries, one Execution\" a database fact rather than\n * a hope, and the `status` / `reason` columns are why an event that changed\n * nothing is still visible.\n *\n * `claims` is the opposite kind of table: mutable, transient lease\n * bookkeeping, one row per **in-flight** Execution, deleted the moment the\n * Execution settles. `case_id` is its primary key — that single constraint is\n * \"one in-flight execution per case\". `expires_at` is what keeps a crash\n * from stranding a case: a crashed handler stops heartbeating and the next\n * claimant takes the case over (journaling an `expired` entry for the\n * abandoned Execution).\n */\nconst DDL = `\nselect pg_advisory_xact_lock(hashtextextended('${FRAMEWORK_SCHEMA}.bootstrap', 0));\n\n-- DDL must never be what blocks live work. \"create index if not exists\" and\n-- friends take table locks whether or not they have anything to do, so a\n-- bootstrap running against a busy database can queue behind -- or deadlock\n-- with -- Executions committing. Bounding the wait makes this transaction\n-- the one that yields, and bootstrap() retries it.\nset local lock_timeout = '2s';\n\ncreate schema if not exists ${FRAMEWORK_SCHEMA};\n\n-- Schema v2 stores typed text ids ('case:<uuid>', 'execution:<uuid>', …); v1\n-- stored bare uuids in uuid columns, which cannot hold them. There is no DDL\n-- migration framework, so a v1 database fails loudly here rather than\n-- corrupting silently on the first insert.\ndo $$\nbegin\n if exists (\n select 1 from information_schema.columns\n where table_schema = '${FRAMEWORK_SCHEMA}' and table_name = 'cases'\n and column_name = 'id' and data_type = 'uuid'\n ) then\n raise exception 'affordance schema v1 detected (uuid ids); v2 ids are text of the form kind:uuid. No automatic conversion exists — export anything you need, then: drop schema ${FRAMEWORK_SCHEMA} cascade; and re-bootstrap.';\n end if;\nend $$;\n\ncreate table if not exists ${FRAMEWORK_SCHEMA}.cases (\n id text primary key,\n case_type text not null,\n state jsonb not null,\n seq bigint not null default 0,\n ended_at timestamptz,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n\ncreate table if not exists ${FRAMEWORK_SCHEMA}.journal (\n ordinal bigserial primary key,\n id text not null unique,\n case_id text not null references ${FRAMEWORK_SCHEMA}.cases (id),\n execution_id text not null,\n entry text not null,\n attempt integer not null default 1,\n step text not null,\n scope_key text,\n actor jsonb,\n input jsonb,\n as_of timestamptz,\n guard jsonb,\n state jsonb,\n delta jsonb,\n dormancy text,\n error jsonb,\n recorded_at timestamptz not null default now()\n);\n\n-- Schema v4 removed rule automation, and with it the \\`cause\\` column (the\n-- causality record an automatic Execution carried). The column is left in\n-- place on a database that has it: journal rows are immutable history, and\n-- old automatic Executions keep the cause they were recorded with. New\n-- entries simply never write it.\n\ncreate index if not exists journal_case_idx\n on ${FRAMEWORK_SCHEMA}.journal (case_id, ordinal);\ncreate index if not exists journal_scope_idx\n on ${FRAMEWORK_SCHEMA}.journal (case_id, scope_key, ordinal);\ncreate index if not exists journal_execution_idx\n on ${FRAMEWORK_SCHEMA}.journal (execution_id, ordinal);\n\ncreate table if not exists ${FRAMEWORK_SCHEMA}.claims (\n case_id text primary key references ${FRAMEWORK_SCHEMA}.cases (id),\n execution_id text not null,\n step text not null,\n scope_key text,\n attempt integer not null default 1,\n claimed_at timestamptz not null default now(),\n heartbeat_at timestamptz not null default now(),\n expires_at timestamptz not null\n);\n\n-- Schema v3 removed timer scheduling. Timer rows were derived state (a\n-- case's future time-flips, recomputable from nothing but Case State), so\n-- dropping the table on a v2 database loses no facts.\ndrop table if exists ${FRAMEWORK_SCHEMA}.timers;\n\ncreate table if not exists ${FRAMEWORK_SCHEMA}.correlations (\n id text primary key,\n system text not null,\n external_id text not null,\n case_id text not null references ${FRAMEWORK_SCHEMA}.cases (id),\n scope_key text,\n step text,\n metadata jsonb,\n created_at timestamptz not null default now(),\n unique (system, external_id)\n);\n\ncreate index if not exists correlations_case_idx\n on ${FRAMEWORK_SCHEMA}.correlations (case_id, scope_key);\n\ncreate table if not exists ${FRAMEWORK_SCHEMA}.ingested_events (\n id text primary key,\n system text not null,\n external_id text not null,\n type text not null,\n idempotency_key text not null unique,\n case_id text,\n scope_key text,\n step text,\n status text not null,\n reason text,\n detail text,\n execution_id text,\n event jsonb not null,\n received_at timestamptz not null default now()\n);\n\ncreate index if not exists ingested_events_dead_letter_idx\n on ${FRAMEWORK_SCHEMA}.ingested_events (status, received_at desc);\n\ncreate table if not exists ${FRAMEWORK_SCHEMA}.schema_version (\n version integer primary key,\n applied_at timestamptz not null default now()\n);\n\ninsert into ${FRAMEWORK_SCHEMA}.schema_version (version)\nvalues (${SCHEMA_VERSION}) on conflict do nothing;\n`\n\n/**\n * Every framework table that holds rows belonging to one case, with the\n * column that names the case — listed in an order safe to delete from\n * (children first; everything references `cases`). **The one answer to\n * \"which tables does the framework own\"** outside the DDL above: a consumer\n * that sweeps per-case rows (a dev console's case purge, a test harness's\n * cleanup) iterates this instead of keeping a private copy that goes stale\n * the release a table is added.\n *\n * `ingested_events.case_id` is nullable — an unrouted event belongs to no\n * case and survives a per-case sweep, which is correct: it was never about\n * the deleted case.\n */\nexport const CASE_TABLES = [\n { table: 'journal', caseColumn: 'case_id' },\n { table: 'claims', caseColumn: 'case_id' },\n { table: 'correlations', caseColumn: 'case_id' },\n { table: 'ingested_events', caseColumn: 'case_id' },\n { table: 'cases', caseColumn: 'id' },\n] as const\n\n/**\n * Whether the schema is already at {@link SCHEMA_VERSION} — two catalog reads\n * that take no lock any Execution could ever be waiting on.\n *\n * This is what makes `bootstrap` free on every start after the first. The DDL\n * below is idempotent, but idempotent is not the same as *harmless*: `create\n * index if not exists` takes a table lock whether or not it has work to do,\n * and a bootstrap holding one while Executions commit can deadlock with them\n * — not as a rare race, but predictably, on every start against a busy\n * database. Asking first means the locks are only ever taken when there is\n * genuinely something to create.\n */\nconst isCurrent = async (db: Queryable): Promise<boolean> => {\n const marker = await db.query<{ present: boolean }>(\n `select to_regclass('${FRAMEWORK_SCHEMA}.schema_version') is not null as present`,\n )\n if (marker.rows[0]?.present !== true) return false\n const applied = await db.query<{ version: number }>(\n `select max(version) as version from ${FRAMEWORK_SCHEMA}.schema_version`,\n )\n return (applied.rows[0]?.version ?? 0) >= SCHEMA_VERSION\n}\n\n/** Postgres says the transaction lost a race it can retry: deadlock, or the bounded lock wait. */\nconst isContention = (error: unknown): boolean => {\n const code = (error as { code?: unknown } | null)?.code\n return code === '40P01' || code === '55P03' || code === '40001'\n}\n\nconst wait = (ms: number): Promise<void> =>\n new Promise((resolve) => {\n setTimeout(resolve, ms)\n })\n\n/**\n * Idempotent DDL bootstrap for the framework schema. Safe to call on every\n * app start and from concurrent processes: the statements are sent as one\n * multi-statement simple query, which Postgres runs on one connection inside\n * a single implicit transaction, and the leading `pg_advisory_xact_lock`\n * serializes racing bootstraps (concurrent `CREATE ... IF NOT EXISTS` can\n * otherwise fail on catalog uniqueness).\n *\n * Also safe to call against a *busy* database, which is the harder promise,\n * and is answered twice over. First, a bootstrap with nothing to do does\n * nothing at all: {@link isCurrent} checks the version marker and returns\n * before any DDL runs, so the common case takes no table locks whatsoever.\n * Second, when there *is* work, the transaction bounds its own lock wait and\n * this retries it — schema management yields to live work, never the reverse.\n */\nexport const bootstrap = async (db: Queryable, attempts = 5): Promise<void> => {\n if (await isCurrent(db)) return\n for (let attempt = 1; ; attempt += 1) {\n try {\n await db.query(DDL)\n return\n } catch (error) {\n if (attempt >= attempts || !isContention(error)) throw error\n await wait(50 * attempt)\n }\n }\n}\n"]}
@@ -0,0 +1,18 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import { AffordanceError } from '../errors.js';
3
+ /**
4
+ * A Case State document failed validation against the provided state schema —
5
+ * either an invalid initial state on `createCase`, or a stored state that no
6
+ * longer satisfies the schema on `loadCase` (loud by design: cases float to
7
+ * the latest definitions, so a load-time mismatch is an app bug,
8
+ * never something to paper over).
9
+ */
10
+ export declare class CaseStateValidationError extends AffordanceError {
11
+ readonly issues: readonly StandardSchemaV1.Issue[];
12
+ constructor(context: string, issues: readonly StandardSchemaV1.Issue[]);
13
+ }
14
+ /** No case row exists for the given case id. */
15
+ export declare class CaseNotFoundError extends AffordanceError {
16
+ readonly caseId: string;
17
+ constructor(caseId: string);
18
+ }
@@ -0,0 +1,26 @@
1
+ import { AffordanceError } from '../errors.js';
2
+ /**
3
+ * A Case State document failed validation against the provided state schema —
4
+ * either an invalid initial state on `createCase`, or a stored state that no
5
+ * longer satisfies the schema on `loadCase` (loud by design: cases float to
6
+ * the latest definitions, so a load-time mismatch is an app bug,
7
+ * never something to paper over).
8
+ */
9
+ export class CaseStateValidationError extends AffordanceError {
10
+ issues;
11
+ constructor(context, issues) {
12
+ super('invalid-state', `invalid ${context}: ${issues.map((issue) => issue.message).join('; ')}`);
13
+ this.name = 'CaseStateValidationError';
14
+ this.issues = issues;
15
+ }
16
+ }
17
+ /** No case row exists for the given case id. */
18
+ export class CaseNotFoundError extends AffordanceError {
19
+ caseId;
20
+ constructor(caseId) {
21
+ super('not-found', `case not found: ${caseId}`);
22
+ this.name = 'CaseNotFoundError';
23
+ this.caseId = caseId;
24
+ }
25
+ }
26
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/store/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAE9C;;;;;;GAMG;AACH,MAAM,OAAO,wBAAyB,SAAQ,eAAe;IAClD,MAAM,CAAmC;IAElD,YAAY,OAAe,EAAE,MAAyC;QACpE,KAAK,CACH,eAAe,EACf,WAAW,OAAO,KAAK,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CACzE,CAAA;QACD,IAAI,CAAC,IAAI,GAAG,0BAA0B,CAAA;QACtC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;IACtB,CAAC;CACF;AAED,gDAAgD;AAChD,MAAM,OAAO,iBAAkB,SAAQ,eAAe;IAC3C,MAAM,CAAQ;IAEvB,YAAY,MAAc;QACxB,KAAK,CAAC,WAAW,EAAE,mBAAmB,MAAM,EAAE,CAAC,CAAA;QAC/C,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAA;QAC/B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;IACtB,CAAC;CACF","sourcesContent":["import type { StandardSchemaV1 } from '@standard-schema/spec'\nimport { AffordanceError } from '../errors.js'\n\n/**\n * A Case State document failed validation against the provided state schema —\n * either an invalid initial state on `createCase`, or a stored state that no\n * longer satisfies the schema on `loadCase` (loud by design: cases float to\n * the latest definitions, so a load-time mismatch is an app bug,\n * never something to paper over).\n */\nexport class CaseStateValidationError extends AffordanceError {\n readonly issues: readonly StandardSchemaV1.Issue[]\n\n constructor(context: string, issues: readonly StandardSchemaV1.Issue[]) {\n super(\n 'invalid-state',\n `invalid ${context}: ${issues.map((issue) => issue.message).join('; ')}`,\n )\n this.name = 'CaseStateValidationError'\n this.issues = issues\n }\n}\n\n/** No case row exists for the given case id. */\nexport class CaseNotFoundError extends AffordanceError {\n readonly caseId: string\n\n constructor(caseId: string) {\n super('not-found', `case not found: ${caseId}`)\n this.name = 'CaseNotFoundError'\n this.caseId = caseId\n }\n}\n"]}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The kinds of id the framework mints. One entry per entity that gets an
3
+ * id at runtime.
4
+ */
5
+ export type IdKind = 'case' | 'execution' | 'journal' | 'correlation' | 'event';
6
+ /**
7
+ * Mint a typed id: `kind:uuid`.
8
+ *
9
+ * Every framework-generated id carries its kind, so an id is
10
+ * self-describing wherever it travels — a log line, a journal row's
11
+ * `cause`, a correlation, a support ticket. The columns holding them are
12
+ * `text`; nothing anywhere parses the id back apart — the prefix is for
13
+ * humans, and equality is the only operation ids support.
14
+ */
15
+ export declare const mintId: (kind: IdKind) => string;
@@ -0,0 +1,12 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * Mint a typed id: `kind:uuid`.
4
+ *
5
+ * Every framework-generated id carries its kind, so an id is
6
+ * self-describing wherever it travels — a log line, a journal row's
7
+ * `cause`, a correlation, a support ticket. The columns holding them are
8
+ * `text`; nothing anywhere parses the id back apart — the prefix is for
9
+ * humans, and equality is the only operation ids support.
10
+ */
11
+ export const mintId = (kind) => `${kind}:${randomUUID()}`;
12
+ //# sourceMappingURL=ids.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ids.js","sourceRoot":"","sources":["../../src/store/ids.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAQxC;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,IAAY,EAAU,EAAE,CAAC,GAAG,IAAI,IAAI,UAAU,EAAE,EAAE,CAAA","sourcesContent":["import { randomUUID } from 'node:crypto'\n\n/**\n * The kinds of id the framework mints. One entry per entity that gets an\n * id at runtime.\n */\nexport type IdKind = 'case' | 'execution' | 'journal' | 'correlation' | 'event'\n\n/**\n * Mint a typed id: `kind:uuid`.\n *\n * Every framework-generated id carries its kind, so an id is\n * self-describing wherever it travels — a log line, a journal row's\n * `cause`, a correlation, a support ticket. The columns holding them are\n * `text`; nothing anywhere parses the id back apart — the prefix is for\n * humans, and equality is the only operation ids support.\n */\nexport const mintId = (kind: IdKind): string => `${kind}:${randomUUID()}`\n"]}
@@ -0,0 +1,12 @@
1
+ export { bootstrap, CASE_TABLES, FRAMEWORK_SCHEMA } from './bootstrap.js';
2
+ export { CaseNotFoundError, CaseStateValidationError } from './errors.js';
3
+ export type { IdKind } from './ids.js';
4
+ export { mintId } from './ids.js';
5
+ export type { DatabaseAccess, PoolLike, Queryable, Transaction, } from './queryable.js';
6
+ export { queryableOf } from './queryable.js';
7
+ export type { CaseTypeLookup, ResolvedCase } from './resolve.js';
8
+ export { resolveCase, resolveCaseForUpdate, resolveStoredState, validateCaseState, } from './resolve.js';
9
+ export type { SqlWhere } from './sql.js';
10
+ export { sqlWhere } from './sql.js';
11
+ export type { CaseHandle, Dormancy } from './store.js';
12
+ export { insertCase, selectCase, selectCaseForUpdate, selectCaseUntyped, updateCaseState, validateAgainstSchema, } from './store.js';
@@ -0,0 +1,9 @@
1
+ // Postgres persistence + case store.
2
+ export { bootstrap, CASE_TABLES, FRAMEWORK_SCHEMA } from './bootstrap.js';
3
+ export { CaseNotFoundError, CaseStateValidationError } from './errors.js';
4
+ export { mintId } from './ids.js';
5
+ export { queryableOf } from './queryable.js';
6
+ export { resolveCase, resolveCaseForUpdate, resolveStoredState, validateCaseState, } from './resolve.js';
7
+ export { sqlWhere } from './sql.js';
8
+ export { insertCase, selectCase, selectCaseForUpdate, selectCaseUntyped, updateCaseState, validateAgainstSchema, } from './store.js';
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/store/index.ts"],"names":[],"mappings":"AAAA,qCAAqC;AACrC,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACzE,OAAO,EAAE,iBAAiB,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAA;AAEzE,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAOjC,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAE5C,OAAO,EACL,WAAW,EACX,oBAAoB,EACpB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,cAAc,CAAA;AAErB,OAAO,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AAEnC,OAAO,EACL,UAAU,EACV,UAAU,EACV,mBAAmB,EACnB,iBAAiB,EACjB,eAAe,EACf,qBAAqB,GACtB,MAAM,YAAY,CAAA","sourcesContent":["// Postgres persistence + case store.\nexport { bootstrap, CASE_TABLES, FRAMEWORK_SCHEMA } from './bootstrap.js'\nexport { CaseNotFoundError, CaseStateValidationError } from './errors.js'\nexport type { IdKind } from './ids.js'\nexport { mintId } from './ids.js'\nexport type {\n DatabaseAccess,\n PoolLike,\n Queryable,\n Transaction,\n} from './queryable.js'\nexport { queryableOf } from './queryable.js'\nexport type { CaseTypeLookup, ResolvedCase } from './resolve.js'\nexport {\n resolveCase,\n resolveCaseForUpdate,\n resolveStoredState,\n validateCaseState,\n} from './resolve.js'\nexport type { SqlWhere } from './sql.js'\nexport { sqlWhere } from './sql.js'\nexport type { CaseHandle, Dormancy } from './store.js'\nexport {\n insertCase,\n selectCase,\n selectCaseForUpdate,\n selectCaseUntyped,\n updateCaseState,\n validateAgainstSchema,\n} from './store.js'\n"]}
@@ -0,0 +1,60 @@
1
+ import type { QueryResult, QueryResultRow } from 'pg';
2
+ /**
3
+ * Minimal query surface satisfied by pg.Pool, pg.Client, and pg.PoolClient.
4
+ *
5
+ * Every store internal takes a Queryable rather than a Pool so a future
6
+ * caller can run case-store queries on an existing client/transaction —
7
+ * the shared-transaction seam (spec §Mechanics/Persistence: handlers may
8
+ * join the framework transaction so app-table writes commit atomically
9
+ * with case state).
10
+ */
11
+ export interface Queryable {
12
+ query<R extends QueryResultRow = QueryResultRow>(text: string, values?: unknown[]): Promise<QueryResult<R>>;
13
+ }
14
+ /**
15
+ * A pooled connection source: `connect()` checks out a client that must be
16
+ * released. `pg.Pool` satisfies this structurally, and so does any
17
+ * multiplexing wrapper (an instrumented pool, a proxy) that declares
18
+ * checkout the same way.
19
+ */
20
+ export interface PoolLike extends Queryable {
21
+ connect(): Promise<Queryable & {
22
+ release(): void;
23
+ }>;
24
+ }
25
+ /**
26
+ * The database as the engine's caller supplies it. The union states, in the
27
+ * type, the one fact a transaction must know: whether `begin` needs a
28
+ * checked-out connection first. A pool multiplexes — issuing `begin` on it
29
+ * would put each statement on a different connection — so `pool` promises
30
+ * checkout via `connect()`, and `client` asserts a connection dedicated to
31
+ * the engine, safe to run a transaction on directly. The caller declares
32
+ * which they have; nothing downstream sniffs the object to guess.
33
+ */
34
+ export type DatabaseAccess = {
35
+ readonly pool: PoolLike;
36
+ } | {
37
+ readonly client: Queryable;
38
+ };
39
+ /**
40
+ * The plain query surface of either arm — what a single self-contained
41
+ * statement (a journal read, a heartbeat) runs against, where pool vs.
42
+ * client makes no difference.
43
+ */
44
+ export declare const queryableOf: (db: DatabaseAccess) => Queryable;
45
+ declare const transactionBrand: unique symbol;
46
+ /**
47
+ * A {@link Queryable} known to be inside an open transaction — the handle
48
+ * `withTransaction` passes to its callback, and the only place the brand is
49
+ * ever applied.
50
+ *
51
+ * Some operations are meaningless (or silently wrong) against a pool: a
52
+ * `select … for update` whose lock vanishes with the statement, a write
53
+ * that must commit in the same transaction as the state it accompanies.
54
+ * Those take a `Transaction`, so "pass the transaction handle, not a pool"
55
+ * is a compile error rather than a sentence a caller has to have read.
56
+ */
57
+ export interface Transaction extends Queryable {
58
+ readonly [transactionBrand]: true;
59
+ }
60
+ export {};
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The plain query surface of either arm — what a single self-contained
3
+ * statement (a journal read, a heartbeat) runs against, where pool vs.
4
+ * client makes no difference.
5
+ */
6
+ export const queryableOf = (db) => 'pool' in db ? db.pool : db.client;
7
+ //# sourceMappingURL=queryable.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queryable.js","sourceRoot":"","sources":["../../src/store/queryable.ts"],"names":[],"mappings":"AAyCA;;;;GAIG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,EAAkB,EAAa,EAAE,CAC3D,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,MAAM,CAAA","sourcesContent":["import type { QueryResult, QueryResultRow } from 'pg'\n\n/**\n * Minimal query surface satisfied by pg.Pool, pg.Client, and pg.PoolClient.\n *\n * Every store internal takes a Queryable rather than a Pool so a future\n * caller can run case-store queries on an existing client/transaction —\n * the shared-transaction seam (spec §Mechanics/Persistence: handlers may\n * join the framework transaction so app-table writes commit atomically\n * with case state).\n */\nexport interface Queryable {\n query<R extends QueryResultRow = QueryResultRow>(\n text: string,\n values?: unknown[],\n ): Promise<QueryResult<R>>\n}\n\n/**\n * A pooled connection source: `connect()` checks out a client that must be\n * released. `pg.Pool` satisfies this structurally, and so does any\n * multiplexing wrapper (an instrumented pool, a proxy) that declares\n * checkout the same way.\n */\nexport interface PoolLike extends Queryable {\n connect(): Promise<Queryable & { release(): void }>\n}\n\n/**\n * The database as the engine's caller supplies it. The union states, in the\n * type, the one fact a transaction must know: whether `begin` needs a\n * checked-out connection first. A pool multiplexes — issuing `begin` on it\n * would put each statement on a different connection — so `pool` promises\n * checkout via `connect()`, and `client` asserts a connection dedicated to\n * the engine, safe to run a transaction on directly. The caller declares\n * which they have; nothing downstream sniffs the object to guess.\n */\nexport type DatabaseAccess =\n | { readonly pool: PoolLike }\n | { readonly client: Queryable }\n\n/**\n * The plain query surface of either arm — what a single self-contained\n * statement (a journal read, a heartbeat) runs against, where pool vs.\n * client makes no difference.\n */\nexport const queryableOf = (db: DatabaseAccess): Queryable =>\n 'pool' in db ? db.pool : db.client\n\ndeclare const transactionBrand: unique symbol\n\n/**\n * A {@link Queryable} known to be inside an open transaction — the handle\n * `withTransaction` passes to its callback, and the only place the brand is\n * ever applied.\n *\n * Some operations are meaningless (or silently wrong) against a pool: a\n * `select … for update` whose lock vanishes with the statement, a write\n * that must commit in the same transaction as the state it accompanies.\n * Those take a `Transaction`, so \"pass the transaction handle, not a pool\"\n * is a compile error rather than a sentence a caller has to have read.\n */\nexport interface Transaction extends Queryable {\n readonly [transactionBrand]: true\n}\n"]}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Where the registry and the store meet: turning a case row into a Case Type
3
+ * definition and a Case State that can be trusted.
4
+ *
5
+ * Every read path needs the same three moves, in the same order — load the
6
+ * row, resolve `case_type` against the registered definitions, validate the
7
+ * stored document against that definition's schema — because the schema to
8
+ * validate against is only knowable *from* the row. What to do with a
9
+ * document that fails validation is a real decision, so it is expressed
10
+ * here as the interface rather than left to each caller:
11
+ *
12
+ * - {@link resolveCase} / {@link resolveCaseForUpdate} are **loud**. Their
13
+ * callers were handed a case id by somebody and owe them an answer about
14
+ * *that* case; a document that no longer validates is an app bug and says
15
+ * so ({@link CaseStateValidationError}).
16
+ * - {@link resolveStoredState} is **lenient**. Its callers sweep — a
17
+ * migration scanning a case type — and one
18
+ * unreadable case must not take the sweep down.
19
+ *
20
+ * The same lenient/loud pair the model draws around scope selection
21
+ * (`addressTarget`'s value vs `resolveTarget`'s throw), for the same reason.
22
+ */
23
+ import type { AnyCaseType } from '../model/index.js';
24
+ import type { Queryable, Transaction } from './queryable.js';
25
+ import type { CaseHandle } from './store.js';
26
+ /** Resolve a persisted `case_type` name to its registered definition; throws if unknown. */
27
+ export type CaseTypeLookup = (caseTypeName: string) => AnyCaseType;
28
+ /** A case row, its Case Type definition, and its validated Case State. */
29
+ export interface ResolvedCase {
30
+ readonly definition: AnyCaseType;
31
+ /** The row as persisted. Its `state` is the raw document; prefer {@link ResolvedCase.state}. */
32
+ readonly handle: CaseHandle<unknown>;
33
+ /** The stored Case State, validated against the definition's schema (defaults applied). */
34
+ readonly state: unknown;
35
+ }
36
+ /**
37
+ * Validate a Case State document against a Case Type's schema, loudly.
38
+ *
39
+ * `context` names what is being validated, and lands in the error message:
40
+ * `'stored state'` for a document read back, `"state returned by step 'x'"`
41
+ * for a handler's return. One function, because "does this document satisfy
42
+ * the case type" is one question however the document was obtained.
43
+ */
44
+ export declare const validateCaseState: (definition: AnyCaseType, value: unknown, context?: string) => Promise<unknown>;
45
+ /**
46
+ * Validate a Case State document already in hand, leniently: `null` when it
47
+ * no longer satisfies its Case Type's schema.
48
+ *
49
+ * The lenient twin of {@link validateCaseState} — literally: the same single
50
+ * Standard-Schema invocation (`validateAgainstSchema`), with the loud
51
+ * verdict absorbed. Only the validation verdict is absorbed; a schema whose
52
+ * `validate` itself throws is a definition bug and stays loud.
53
+ *
54
+ * Wrapped in an object rather than returned bare, because a valid Case State
55
+ * may legitimately *be* `null` and a sweep must not confuse the two.
56
+ */
57
+ export declare const resolveStoredState: (definition: AnyCaseType, value: unknown) => Promise<{
58
+ readonly state: unknown;
59
+ } | null>;
60
+ /** Load a case and resolve it against the registered definitions. Loud — see this module's note. */
61
+ export declare const resolveCase: (db: Queryable, caseTypeFor: CaseTypeLookup, caseId: string) => Promise<ResolvedCase>;
62
+ /**
63
+ * {@link resolveCase} taking the case row's lock — the execution lifecycle's
64
+ * serialization point.
65
+ */
66
+ export declare const resolveCaseForUpdate: (tx: Transaction, caseTypeFor: CaseTypeLookup, caseId: string) => Promise<ResolvedCase>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Where the registry and the store meet: turning a case row into a Case Type
3
+ * definition and a Case State that can be trusted.
4
+ *
5
+ * Every read path needs the same three moves, in the same order — load the
6
+ * row, resolve `case_type` against the registered definitions, validate the
7
+ * stored document against that definition's schema — because the schema to
8
+ * validate against is only knowable *from* the row. What to do with a
9
+ * document that fails validation is a real decision, so it is expressed
10
+ * here as the interface rather than left to each caller:
11
+ *
12
+ * - {@link resolveCase} / {@link resolveCaseForUpdate} are **loud**. Their
13
+ * callers were handed a case id by somebody and owe them an answer about
14
+ * *that* case; a document that no longer validates is an app bug and says
15
+ * so ({@link CaseStateValidationError}).
16
+ * - {@link resolveStoredState} is **lenient**. Its callers sweep — a
17
+ * migration scanning a case type — and one
18
+ * unreadable case must not take the sweep down.
19
+ *
20
+ * The same lenient/loud pair the model draws around scope selection
21
+ * (`addressTarget`'s value vs `resolveTarget`'s throw), for the same reason.
22
+ */
23
+ import { CaseStateValidationError } from './errors.js';
24
+ import { selectCaseForUpdate, selectCaseUntyped, validateAgainstSchema, } from './store.js';
25
+ /**
26
+ * Validate a Case State document against a Case Type's schema, loudly.
27
+ *
28
+ * `context` names what is being validated, and lands in the error message:
29
+ * `'stored state'` for a document read back, `"state returned by step 'x'"`
30
+ * for a handler's return. One function, because "does this document satisfy
31
+ * the case type" is one question however the document was obtained.
32
+ */
33
+ export const validateCaseState = async (definition, value, context = 'stored state') => validateAgainstSchema(definition.state, value, context);
34
+ /**
35
+ * Validate a Case State document already in hand, leniently: `null` when it
36
+ * no longer satisfies its Case Type's schema.
37
+ *
38
+ * The lenient twin of {@link validateCaseState} — literally: the same single
39
+ * Standard-Schema invocation (`validateAgainstSchema`), with the loud
40
+ * verdict absorbed. Only the validation verdict is absorbed; a schema whose
41
+ * `validate` itself throws is a definition bug and stays loud.
42
+ *
43
+ * Wrapped in an object rather than returned bare, because a valid Case State
44
+ * may legitimately *be* `null` and a sweep must not confuse the two.
45
+ */
46
+ export const resolveStoredState = async (definition, value) => {
47
+ try {
48
+ return { state: await validateCaseState(definition, value) };
49
+ }
50
+ catch (error) {
51
+ if (error instanceof CaseStateValidationError)
52
+ return null;
53
+ throw error;
54
+ }
55
+ };
56
+ const resolved = async (handle, caseTypeFor) => {
57
+ const definition = caseTypeFor(handle.caseTypeName);
58
+ return {
59
+ definition,
60
+ handle,
61
+ state: await validateCaseState(definition, handle.state),
62
+ };
63
+ };
64
+ /** Load a case and resolve it against the registered definitions. Loud — see this module's note. */
65
+ export const resolveCase = async (db, caseTypeFor, caseId) => resolved(await selectCaseUntyped(db, caseId), caseTypeFor);
66
+ /**
67
+ * {@link resolveCase} taking the case row's lock — the execution lifecycle's
68
+ * serialization point.
69
+ */
70
+ export const resolveCaseForUpdate = async (tx, caseTypeFor, caseId) => resolved(await selectCaseForUpdate(tx, caseId), caseTypeFor);
71
+ //# sourceMappingURL=resolve.js.map