@kairos-es/store-postgres 0.0.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 (73) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +167 -0
  3. package/dist/cjs/clients.js +62 -0
  4. package/dist/cjs/clients.js.map +1 -0
  5. package/dist/cjs/config.js +94 -0
  6. package/dist/cjs/config.js.map +1 -0
  7. package/dist/cjs/ensureSchema.js +53 -0
  8. package/dist/cjs/ensureSchema.js.map +1 -0
  9. package/dist/cjs/index.js +45 -0
  10. package/dist/cjs/index.js.map +1 -0
  11. package/dist/cjs/internal/ddl.js +436 -0
  12. package/dist/cjs/internal/ddl.js.map +1 -0
  13. package/dist/cjs/internal/matchSql.js +189 -0
  14. package/dist/cjs/internal/matchSql.js.map +1 -0
  15. package/dist/cjs/internal/readPlan.js +67 -0
  16. package/dist/cjs/internal/readPlan.js.map +1 -0
  17. package/dist/cjs/internal/subscribe.js +120 -0
  18. package/dist/cjs/internal/subscribe.js.map +1 -0
  19. package/dist/cjs/internal/transport.js +66 -0
  20. package/dist/cjs/internal/transport.js.map +1 -0
  21. package/dist/cjs/store.js +256 -0
  22. package/dist/cjs/store.js.map +1 -0
  23. package/dist/dts/clients.d.ts +34 -0
  24. package/dist/dts/clients.d.ts.map +1 -0
  25. package/dist/dts/config.d.ts +58 -0
  26. package/dist/dts/config.d.ts.map +1 -0
  27. package/dist/dts/ensureSchema.d.ts +35 -0
  28. package/dist/dts/ensureSchema.d.ts.map +1 -0
  29. package/dist/dts/index.d.ts +41 -0
  30. package/dist/dts/index.d.ts.map +1 -0
  31. package/dist/dts/internal/ddl.d.ts +174 -0
  32. package/dist/dts/internal/ddl.d.ts.map +1 -0
  33. package/dist/dts/internal/matchSql.d.ts +140 -0
  34. package/dist/dts/internal/matchSql.d.ts.map +1 -0
  35. package/dist/dts/internal/readPlan.d.ts +72 -0
  36. package/dist/dts/internal/readPlan.d.ts.map +1 -0
  37. package/dist/dts/internal/subscribe.d.ts +87 -0
  38. package/dist/dts/internal/subscribe.d.ts.map +1 -0
  39. package/dist/dts/internal/transport.d.ts +116 -0
  40. package/dist/dts/internal/transport.d.ts.map +1 -0
  41. package/dist/dts/store.d.ts +17 -0
  42. package/dist/dts/store.d.ts.map +1 -0
  43. package/dist/esm/clients.js +51 -0
  44. package/dist/esm/clients.js.map +1 -0
  45. package/dist/esm/config.js +87 -0
  46. package/dist/esm/config.js.map +1 -0
  47. package/dist/esm/ensureSchema.js +45 -0
  48. package/dist/esm/ensureSchema.js.map +1 -0
  49. package/dist/esm/index.js +40 -0
  50. package/dist/esm/index.js.map +1 -0
  51. package/dist/esm/internal/ddl.js +424 -0
  52. package/dist/esm/internal/ddl.js.map +1 -0
  53. package/dist/esm/internal/matchSql.js +176 -0
  54. package/dist/esm/internal/matchSql.js.map +1 -0
  55. package/dist/esm/internal/readPlan.js +59 -0
  56. package/dist/esm/internal/readPlan.js.map +1 -0
  57. package/dist/esm/internal/subscribe.js +113 -0
  58. package/dist/esm/internal/subscribe.js.map +1 -0
  59. package/dist/esm/internal/transport.js +56 -0
  60. package/dist/esm/internal/transport.js.map +1 -0
  61. package/dist/esm/package.json +4 -0
  62. package/dist/esm/store.js +249 -0
  63. package/dist/esm/store.js.map +1 -0
  64. package/package.json +35 -0
  65. package/src/clients.ts +69 -0
  66. package/src/config.ts +92 -0
  67. package/src/ensureSchema.ts +46 -0
  68. package/src/index.ts +45 -0
  69. package/src/internal/ddl.ts +599 -0
  70. package/src/internal/matchSql.ts +219 -0
  71. package/src/internal/readPlan.ts +117 -0
  72. package/src/internal/transport.ts +141 -0
  73. package/src/store.ts +413 -0
@@ -0,0 +1,599 @@
1
+ /**
2
+ * Internal DDL and object-naming for the Postgres `DcbEventStore` backend.
3
+ *
4
+ * This module is the single source of truth for every object name a store owns
5
+ * and for the plpgsql function bodies. It is deliberately factored apart from
6
+ * the public engine so that:
7
+ *
8
+ * 1. names are derived ONCE, from `{ schema, tablePrefix }`, and reused by both
9
+ * `ensureSchema` (DDL) and the runtime (`append`/`read`/`subscribe`), so the
10
+ * migration and the queries can never disagree about what a table is called;
11
+ * 2. the append-function body is assembled from COMPOSABLE parts — in
12
+ * particular the `LOCK TABLE … IN EXCLUSIVE MODE` clause is an omittable
13
+ * fragment — so a test can `CREATE OR REPLACE` a deliberately-LOCKLESS twin of
14
+ * the append function to prove the shared contract suite actually detects a
15
+ * non-serialising store. The production Layer never exposes a lockless option;
16
+ * the seam lives here, in an internal module, reachable only from the test
17
+ * graph.
18
+ *
19
+ * WHY names are never string-concatenated: every identifier flows through
20
+ * `sql(identifier)`, which quotes and dot-splits safely, so a hostile or merely
21
+ * awkward `schema`/`tablePrefix` cannot inject SQL or collide with a reserved
22
+ * word. The plpgsql *bodies*, which must embed the resolved names as literal
23
+ * text inside a `CREATE FUNCTION … $$ … $$` string, use the SAME quoting via a
24
+ * small `quoteQualified` helper so the body text matches what `sql(identifier)`
25
+ * would emit; VALUES stay bound parameters (`sql.unsafe` with `params`) and are
26
+ * never interpolated.
27
+ */
28
+ import type { SqlClient, Statement } from '@effect/sql'
29
+ import {
30
+ allowedTypesPredicate,
31
+ eventRecordsetColumns,
32
+ qualifiedRows,
33
+ taggedMatchChain,
34
+ } from './matchSql'
35
+
36
+ /**
37
+ * A store is one physical `{ schema, tablePrefix }` pair. Every object it owns
38
+ * (both tables, both indexes, both functions, and the NOTIFY channel) is derived
39
+ * from this pair, so one prefix is one completely isolated store: its own
40
+ * bigserial sequence, its own exclusive-lock scope, its own wake channel.
41
+ */
42
+ export interface ResolvedNames {
43
+ readonly schema: string
44
+ readonly tablePrefix: string
45
+ /** `<schema>.<prefix>` — the main event table. */
46
+ readonly mainTable: string
47
+ /** `<schema>.<prefix>_tags` — the junction tag table. */
48
+ readonly tagTable: string
49
+ /** `<prefix>_idx_id_type` — the covering unique index on the main table. */
50
+ readonly mainIndex: string
51
+ /** `<prefix>_idx_tag_main_id` — the composite B-tree on the tag table. */
52
+ readonly tagIndex: string
53
+ /** `<schema>.<prefix>_append` — the conditional (lock → check → insert → notify) function. */
54
+ readonly appendFn: string
55
+ /**
56
+ * `<schema>.<prefix>_append_unconditional` — the check-free insert twin. It takes
57
+ * the SAME exclusive lock as the conditional one, which is what discharges the
58
+ * subscribe machine's ascending-commit-order precondition on this engine;
59
+ * `unconditionalAppendBody` owns why a lock-free twin would not.
60
+ */
61
+ readonly appendUnconditionalFn: string
62
+ /**
63
+ * The `LISTEN`/`NOTIFY` channel: the fully-qualified events-table name with
64
+ * dots replaced by `_`. A channel is a single unqualified identifier, so the
65
+ * schema is folded into the name to keep two stores in different schemas but
66
+ * with the same prefix from sharing a channel.
67
+ */
68
+ readonly channel: string
69
+ }
70
+
71
+ /** Default schema when the config omits one. */
72
+ export const DEFAULT_SCHEMA = 'public'
73
+ /** Default table prefix when the config omits one. */
74
+ export const DEFAULT_TABLE_PREFIX = 'dcb_events'
75
+
76
+ /**
77
+ * Postgres truncates any identifier to 63 bytes (`NAMEDATALEN - 1`) with only a
78
+ * notice. Two configs whose derived names share a 63-byte prefix would then
79
+ * collide silently: `CREATE INDEX IF NOT EXISTS` sees a name-match and SKIPS
80
+ * creating the second store's index (an invisible performance cliff), and two
81
+ * NOTIFY channels truncate into one (cross-store wakes, defeating cohabitation).
82
+ */
83
+ const MAX_IDENTIFIER_BYTES = 63
84
+
85
+ /** UTF-8 byte length (identifiers are bytes, not chars, to Postgres). */
86
+ const utf8ByteLength = (value: string): number =>
87
+ new TextEncoder().encode(value).length
88
+
89
+ /**
90
+ * The single-atom identifiers Postgres actually materialises for a config —
91
+ * every table/index/function base name plus the NOTIFY channel. `schema.prefix`
92
+ * qualified names are two atoms to Postgres (`schema` and the table name), so
93
+ * each atom is measured separately, not the dotted whole.
94
+ */
95
+ const derivedAtoms = (config: {
96
+ readonly schema?: string
97
+ readonly tablePrefix?: string
98
+ }): ReadonlyArray<readonly [string, string]> => {
99
+ const schema = config.schema ?? DEFAULT_SCHEMA
100
+ const tablePrefix = config.tablePrefix ?? DEFAULT_TABLE_PREFIX
101
+ return [
102
+ ['schema', schema],
103
+ ['events table', tablePrefix],
104
+ ['tag table', `${tablePrefix}_tags`],
105
+ ['main index', `${tablePrefix}_idx_id_type`],
106
+ ['tag index', `${tablePrefix}_idx_tag_main_id`],
107
+ ['append function', `${tablePrefix}_append`],
108
+ ['unconditional append function', `${tablePrefix}_append_unconditional`],
109
+ ['NOTIFY channel', `${schema}_${tablePrefix}`.replace(/\./g, '_')],
110
+ ]
111
+ }
112
+
113
+ /**
114
+ * The first derived identifier that exceeds 63 bytes, as a human-readable
115
+ * message, or `undefined` when every name fits. Shared by `resolveNames` (which
116
+ * throws) and the `PostgresStoreConfig` schema filter (which fails validation),
117
+ * so the byte bound is enforced identically at both the name-derivation and the
118
+ * config-decode boundary.
119
+ */
120
+ export const oversizedIdentifier = (config: {
121
+ readonly schema?: string
122
+ readonly tablePrefix?: string
123
+ }): string | undefined => {
124
+ for (const [label, atom] of derivedAtoms(config)) {
125
+ const bytes = utf8ByteLength(atom)
126
+ if (bytes > MAX_IDENTIFIER_BYTES) {
127
+ return `the ${label} identifier "${atom}" is ${bytes} bytes, over Postgres's ${MAX_IDENTIFIER_BYTES}-byte limit; it would be silently truncated, risking a cross-store index-skip or NOTIFY-channel collision`
128
+ }
129
+ }
130
+ return undefined
131
+ }
132
+
133
+ /**
134
+ * Derive every owned object name from `{ schema, tablePrefix }`. The index and
135
+ * function *base* names are unqualified prefixes (`<prefix>_…`) because an index
136
+ * name is scoped to its table's schema and a function is created in `<schema>`;
137
+ * the tables and functions carry the schema explicitly so `sql(identifier)`
138
+ * dot-splits them.
139
+ *
140
+ * Throws at construction if any derived identifier would be silently truncated
141
+ * (see `oversizedIdentifier` / `MAX_IDENTIFIER_BYTES`) — a loud failure at the
142
+ * single choke point beats an invisible index-skip or channel collision later.
143
+ */
144
+ export const resolveNames = (config: {
145
+ readonly schema?: string
146
+ readonly tablePrefix?: string
147
+ }): ResolvedNames => {
148
+ const problem = oversizedIdentifier(config)
149
+ if (problem !== undefined) {
150
+ throw new Error(`resolveNames: ${problem}`)
151
+ }
152
+ const schema = config.schema ?? DEFAULT_SCHEMA
153
+ const tablePrefix = config.tablePrefix ?? DEFAULT_TABLE_PREFIX
154
+ return {
155
+ schema,
156
+ tablePrefix,
157
+ mainTable: `${schema}.${tablePrefix}`,
158
+ tagTable: `${schema}.${tablePrefix}_tags`,
159
+ mainIndex: `${tablePrefix}_idx_id_type`,
160
+ tagIndex: `${tablePrefix}_idx_tag_main_id`,
161
+ appendFn: `${schema}.${tablePrefix}_append`,
162
+ appendUnconditionalFn: `${schema}.${tablePrefix}_append_unconditional`,
163
+ channel: `${schema}_${tablePrefix}`.replace(/\./g, '_'),
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Quote a possibly-qualified identifier for embedding inside a plpgsql body as
169
+ * literal text. Splits on `.` and double-quotes each atom, mirroring what
170
+ * `sql(identifier)` emits, so the body's table references and the runtime's
171
+ * `sql(identifier)` references resolve to exactly the same object. Any embedded
172
+ * `"` is doubled per SQL identifier-quoting rules.
173
+ */
174
+ export const quoteQualified = (name: string): string =>
175
+ name
176
+ .split('.')
177
+ .map((atom) => `"${atom.replace(/"/g, '""')}"`)
178
+ .join('.')
179
+
180
+ /**
181
+ * A quoted single-atom identifier (no dot-splitting) — used where a bare name is
182
+ * required, e.g. index/schema names.
183
+ */
184
+ const quoteAtom = (name: string): string => `"${name.replace(/"/g, '""')}"`
185
+
186
+ /**
187
+ * A single-quoted SQL string LITERAL (not an identifier). Used for the
188
+ * `pg_notify(channel, payload)` channel argument, which is a `text` VALUE, not
189
+ * an identifier — double-quoting it there would (wrongly) parse as a column
190
+ * reference. Embedded single quotes are doubled per SQL string-literal rules.
191
+ */
192
+ const quoteLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`
193
+
194
+ /**
195
+ * The autovacuum storage parameters, ported verbatim from upstream
196
+ * `postgres_tt.py`: the event log is append-heavy and effectively immutable, so
197
+ * vacuum is tuned to run rarely (huge thresholds) while analyze stays keen
198
+ * (small thresholds) to keep the planner's row estimates fresh for the tag-first
199
+ * CTE. Applied identically to both tables.
200
+ */
201
+ const AUTOVACUUM_WITH =
202
+ 'autovacuum_enabled = true, ' +
203
+ 'autovacuum_vacuum_threshold = 100000000, ' +
204
+ 'autovacuum_vacuum_scale_factor = 0.5, ' +
205
+ 'autovacuum_analyze_threshold = 1000, ' +
206
+ 'autovacuum_analyze_scale_factor = 0.01'
207
+
208
+ /**
209
+ * The `LOCK TABLE <main> IN EXCLUSIVE MODE;` clause — the serialisation point of
210
+ * the whole engine. `EXCLUSIVE` blocks other writers (and other conditional
211
+ * appends) but lets readers proceed, so the check-then-insert of one append
212
+ * cannot interleave with another's: exactly-one-wins holds by construction, with
213
+ * no dependence on isolation level or unique constraints.
214
+ *
215
+ * It is a SEPARATE, OMITTABLE fragment purely so the test-only lockless twin can
216
+ * drop it. Production always includes it.
217
+ */
218
+ const lockClause = (names: ResolvedNames): string =>
219
+ `LOCK TABLE ${quoteQualified(names.mainTable)} IN EXCLUSIVE MODE;`
220
+
221
+ /**
222
+ * The named body-fragment seams, replacing positional boolean flags — the old
223
+ * `unconditionalAppendBody(names, true, false)` was boolean-blind. Both knobs
224
+ * default to the production configuration (locked + notifying); a test twin
225
+ * overrides one field BY NAME. This is the union of both knobs; each body `Pick`s
226
+ * only the ones it actually varies — the conditional body always notifies, so it
227
+ * never took `notify`.
228
+ *
229
+ * - `lock` — include the `LOCK TABLE … IN EXCLUSIVE MODE` clause (default true).
230
+ * BOTH twins vary it: the test-only lockless twins pass `false` to prove the
231
+ * lock is load-bearing (the conditional path's lockless meta-test and the
232
+ * unconditional commit-order repro).
233
+ * - `notify` — include the `pg_notify` wake (default true). ONLY the unconditional
234
+ * twin varies it: the poll-only delivery test passes `false` to prove the
235
+ * periodic poll, not NOTIFY, is the delivery guarantee.
236
+ */
237
+ export interface AppendBodyOptions {
238
+ readonly lock?: boolean
239
+ readonly notify?: boolean
240
+ }
241
+
242
+ /**
243
+ * The shared insert → tag fan-out → NOTIFY body, emitting into a local
244
+ * `new_head bigint` and then `RETURN QUERY SELECT new_head`. Written as explicit
245
+ * sequential statements (not one mega-CTE) so control flow is obvious and the
246
+ * `pg_notify` fires unconditionally after a successful insert rather than
247
+ * depending on a data-modifying CTE being referenced.
248
+ *
249
+ * A single `INSERT … SELECT … FROM jsonb_to_recordset(…) ORDER BY ordinality`
250
+ * preserves batch order, and `RETURNING id, tags` is captured into a temp
251
+ * result set so the tag fan-out and the head can both read it. `data` arrives as
252
+ * a base64 text field and is `decode(…, 'base64')`-restored to `bytea`.
253
+ */
254
+ const insertFanoutNotify = (
255
+ names: ResolvedNames,
256
+ options: Pick<AppendBodyOptions, 'notify'> = {},
257
+ ): string => {
258
+ const main = quoteQualified(names.mainTable)
259
+ const tag = quoteQualified(names.tagTable)
260
+ const channel = quoteLiteral(names.channel)
261
+ // `notify` is an OMITTABLE fragment (like the lock): production always
262
+ // notifies; the poll-only delivery test `CREATE OR REPLACE`s a NOTIFY-suppressed
263
+ // UNCONDITIONAL twin over its own prefix to prove the periodic poll — not
264
+ // NOTIFY — is the delivery guarantee. The conditional body never suppresses it.
265
+ const notify =
266
+ (options.notify ?? true)
267
+ ? `
268
+ -- Wake subscribers only if we actually inserted rows. NOTIFY is a payload-less
269
+ -- signal in spirit; a constant '1' payload is sent purely because the built-in
270
+ -- ref-counted listen client drops empty-payload notifications — the TS side
271
+ -- treats every notification as an opaque wake and ignores the payload.
272
+ IF new_head IS NOT NULL THEN
273
+ PERFORM pg_notify(${channel}, '1');
274
+ END IF;`
275
+ : ''
276
+ return ` WITH ne AS (
277
+ SELECT type, data, tags, uuid, occurred_at, ordinality
278
+ FROM ROWS FROM(
279
+ jsonb_to_recordset(new_events) AS (${eventRecordsetColumns})
280
+ ) WITH ORDINALITY
281
+ ),
282
+ inserted AS (
283
+ INSERT INTO ${main} (type, data, tags, uuid, occurred_at)
284
+ SELECT ne.type, decode(ne.data, 'base64'), ne.tags, ne.uuid,
285
+ ne.occurred_at
286
+ FROM ne
287
+ ORDER BY ne.ordinality
288
+ RETURNING id, tags
289
+ ),
290
+ fanned AS (
291
+ INSERT INTO ${tag} (tag, main_id)
292
+ SELECT unnest(inserted.tags), inserted.id
293
+ FROM inserted
294
+ RETURNING main_id
295
+ )
296
+ -- fanned is a DATA-MODIFYING CTE: Postgres runs it exactly once to completion
297
+ -- whether or not the primary query reads its output, so the tag fan-out
298
+ -- happens even though nothing selects from it — and so a count(*) over it
299
+ -- would add nothing but work.
300
+ SELECT MAX(inserted.id)
301
+ INTO new_head
302
+ FROM inserted;
303
+ ${notify}`
304
+ }
305
+
306
+ /**
307
+ * Build the body of the CONDITIONAL append function (lock → check → insert →
308
+ * notify), ported from upstream `dcb_conditional_append_tt` with the
309
+ * JSON-transport divergence (ADR-0002).
310
+ *
311
+ * Module-private: the ONLY caller is `appendFunctionStatement`, which owns the
312
+ * full `CREATE OR REPLACE` wrapper. Keeping the body builders unexported means a
313
+ * caller cannot re-introduce a hand-rolled wrapper (the drift this consolidation
314
+ * removes) — the wrapper is single-sourced.
315
+ */
316
+ const conditionalAppendBody = (
317
+ names: ResolvedNames,
318
+ options: Pick<AppendBodyOptions, 'lock'> = {},
319
+ ): string => {
320
+ const main = quoteQualified(names.mainTable)
321
+ const tag = quoteQualified(names.tagTable)
322
+ const lock = (options.lock ?? true) ? ` ${lockClause(names)}\n` : ''
323
+ // The conditional body always notifies — `insertFanoutNotify` defaults to it,
324
+ // and there is deliberately no `notify` seam here: only the unconditional twin
325
+ // is ever notify-suppressed, by the poll-only delivery test.
326
+ // The tagged branch shares its match SQL with the TS read path via one builder
327
+ // (`internal/matchSql.ts`) — plpgsql parameter tokens here (`query_items`,
328
+ // `COALESCE(after_id,0)`), bound `$n` on the read side. The wildcard/tagless
329
+ // branches are conflict-only (the read dispatches those shapes in TS) and reuse
330
+ // `qi` + the shared allowed-types predicate.
331
+ //
332
+ // No compiler reaches any of this. Core's `ServableQueryShape` appears nowhere in
333
+ // the body text, so a member added to that union is invisible here, where
334
+ // `internal/readPlan.ts`'s exhaustive `switch` stops compiling — and what stands
335
+ // in for the compiler is the generality the body comment below claims. Worth
336
+ // naming is why that claim holds: the three contributions partition a query by
337
+ // its ITEMS — none at all, an item carrying no tags, an item carrying tags —
338
+ // which between them cover every `Query` value the grammar can express, so a new
339
+ // SHAPE changes which SQL the read compiles without changing what this body has
340
+ // to match. The exposure is the GRAMMAR itself: a new `QueryItem` field, or a new
341
+ // meaning for an existing one, is something this body ignores rather than fails
342
+ // on, and no `switch` over the shape union would have caught that either.
343
+ const after = 'COALESCE(after_id, 0)'
344
+ return `
345
+ DECLARE
346
+ conflict_exists boolean;
347
+ new_head bigint;
348
+ BEGIN
349
+ -- lock_timeout is set per-call by the caller via set_config(..., is_local),
350
+ -- so the same function honours the configured timeout without recompilation.
351
+ ${lock} -- Conflict check, reduced to EXISTS/LIMIT 1: does ANY event after
352
+ -- COALESCE(after_id, 0) satisfy the guard query (items OR'd; within an item,
353
+ -- types OR-filter AND tags AND-superset)? Three contributions, OR'd:
354
+ -- * the WILDCARD (empty query_items) - any event after after_id conflicts;
355
+ -- * TAGLESS items (empty tags, maybe types) - a type-only / match-all item,
356
+ -- matched by type = ANY(types) (or any type when types is empty);
357
+ -- * TAGGED items - the shared tag-first match chain, identical to the read.
358
+ -- The servable grammar (assertServableQuery) guarantees a tagless item only
359
+ -- appears as the whole single-item query or the wildcard, but the check is
360
+ -- written generically so plain-data assembly cannot surprise it.
361
+ WITH ${taggedMatchChain({ queryItemsExpr: 'query_items', afterExpr: after, main, tag })},
362
+ -- Wildcard: no items at all means "match everything".
363
+ wildcard_conflict AS (
364
+ SELECT 1
365
+ FROM ${main} m
366
+ WHERE NOT EXISTS (SELECT 1 FROM qi)
367
+ AND m.id > ${after}
368
+ LIMIT 1
369
+ ),
370
+ -- Tagless items: match by type (or any type when the item has no types).
371
+ tagless_conflict AS (
372
+ SELECT 1
373
+ FROM qi
374
+ JOIN ${main} m ON m.id > ${after}
375
+ WHERE (qi.tags IS NULL OR array_length(qi.tags, 1) IS NULL)
376
+ AND ${allowedTypesPredicate('qi.types')}
377
+ LIMIT 1
378
+ ),
379
+ -- Tagged items: distinct-tag-count AND-superset matching (the shared source).
380
+ tagged_conflict AS (
381
+ SELECT 1
382
+ ${qualifiedRows({ main, afterExpr: after })}
383
+ LIMIT 1
384
+ )
385
+ SELECT
386
+ EXISTS (SELECT 1 FROM wildcard_conflict)
387
+ OR EXISTS (SELECT 1 FROM tagless_conflict)
388
+ OR EXISTS (SELECT 1 FROM tagged_conflict)
389
+ INTO conflict_exists;
390
+
391
+ IF NOT conflict_exists THEN
392
+ -- No conflict: insert the batch, fan tags out, wake subscribers, return head.
393
+ ${insertFanoutNotify(names)}
394
+ RETURN QUERY SELECT new_head WHERE new_head IS NOT NULL;
395
+ END IF;
396
+ -- conflict_exists = true falls through, returning zero rows = a conflict.
397
+ RETURN;
398
+ END;
399
+ `
400
+ }
401
+
402
+ /**
403
+ * Build the body of the UNCONDITIONAL append function: no conflict check, but the
404
+ * SAME `LOCK TABLE … IN EXCLUSIVE MODE` as the conditional twin.
405
+ *
406
+ * WHY the lock (a deliberate divergence from upstream's lock-free twin, ADR-0002):
407
+ * `bigserial` ids are allocated NON-transactionally, so two lock-free
408
+ * unconditional writers can allocate ids 5 and 6 and COMMIT them in reverse
409
+ * order. Commit-order then diverges from id-order, and two invariants the rest of
410
+ * the engine leans on silently break:
411
+ *
412
+ * 1. a `subscribe` poll that lands between the two commits sees id 6, advances
413
+ * its strict `> cursor` past 6, and PERMANENTLY skips id 5 when it commits
414
+ * milliseconds later — silent event loss on the live tail;
415
+ * 2. a no-limit read taken while the lower id is in flight returns `head` PAST
416
+ * an id no snapshot can yet see, so a decision model appending under
417
+ * `{ after: head }` never examines that id as a conflict — an escaped guard.
418
+ *
419
+ * The check-free FAST PATH is preserved (no conflict CTE); only the lock is
420
+ * added. It restores commit-order = id-order for EVERY append, making ADR-0002's
421
+ * "every append participates in the same mechanism by construction" true in the
422
+ * strong sense. The cost — unconditional writers serialise with all other writers
423
+ * — is exactly the write ceiling ADR-0002 already accepts and marks benchmarkable.
424
+ *
425
+ * `options.lock` is the same OMITTABLE-fragment seam as `conditionalAppendBody`:
426
+ * production defaults to `true`; the commit-order repro passes `{ lock: false }`
427
+ * over its own prefix to prove the subscribe-skip / head-overtake scenario
428
+ * genuinely reproduces the bug when the lock is absent (non-vacuity), exactly as
429
+ * the lockless meta-test does for the conditional path.
430
+ *
431
+ * `options.notify` is the analogous seam for the poll-only delivery test — a
432
+ * NOTIFY-suppressed twin (`{ notify: false }`) proves the periodic poll (not
433
+ * NOTIFY) is the delivery guarantee. Production always notifies.
434
+ *
435
+ * Module-private, like `conditionalAppendBody`: reached only via
436
+ * `appendFunctionStatement`.
437
+ */
438
+ const unconditionalAppendBody = (
439
+ names: ResolvedNames,
440
+ options: AppendBodyOptions = {},
441
+ ): string => {
442
+ const lock = (options.lock ?? true) ? ` ${lockClause(names)}\n` : ''
443
+ // Pass the `notify` option straight through — no unwrap-then-rewrap;
444
+ // `insertFanoutNotify` owns the default.
445
+ return `
446
+ DECLARE
447
+ new_head bigint;
448
+ BEGIN
449
+ ${lock}${insertFanoutNotify(names, options)}
450
+ RETURN QUERY SELECT new_head WHERE new_head IS NOT NULL;
451
+ END;
452
+ `
453
+ }
454
+
455
+ /**
456
+ * Which append function to (re)create, plus the body-fragment seams. `kind`
457
+ * selects the conditional (guarded) or unconditional (check-free) twin — fixing
458
+ * the function NAME and SIGNATURE — and the seams default to the production
459
+ * configuration.
460
+ *
461
+ * A DISCRIMINATED union, not `extends AppendBodyOptions`, so the seams a `kind`
462
+ * cannot vary are un-expressible: the conditional twin exposes only `lock` (it
463
+ * always notifies), so `{ kind: 'conditional', notify: false }` is a compile error
464
+ * rather than a silently-ignored dead option.
465
+ */
466
+ export type AppendFunctionOptions =
467
+ | ({ readonly kind: 'conditional' } & Pick<AppendBodyOptions, 'lock'>)
468
+ | ({ readonly kind: 'unconditional' } & AppendBodyOptions)
469
+
470
+ /**
471
+ * Build ONE complete `CREATE OR REPLACE FUNCTION … RETURNS SETOF bigint LANGUAGE
472
+ * plpgsql AS $kairos$…$kairos$` statement for an append function. This is the
473
+ * single source of the wrapper — its qualified name, argument SIGNATURE, and
474
+ * `$kairos$`-delimited body — consumed by `ddlStatements` (production, both twins)
475
+ * AND by the test installers that `CREATE OR REPLACE` a lockless / notify-
476
+ * suppressed twin over their own prefix.
477
+ *
478
+ * WHY one builder: `ddlStatements` plus three test twins previously hand-restated
479
+ * this wrapper, several with string-INTERPOLATED identifiers this module's own
480
+ * doctrine forbids (`"${schema}"."${prefix}_append"` rather than
481
+ * `quoteQualified(names.appendFn)`). A drift in the SIGNATURE across those copies
482
+ * is a silent footgun: `CREATE OR REPLACE` with a changed argument list creates a
483
+ * new OVERLOAD instead of replacing, leaving the original production function live
484
+ * under test. Single-sourcing name + signature makes a twin unable to diverge —
485
+ * a twin `CREATE OR REPLACE`s exactly the function `ddlStatements` created.
486
+ */
487
+ export const appendFunctionStatement = (
488
+ names: ResolvedNames,
489
+ options: AppendFunctionOptions,
490
+ ): string => {
491
+ // Only the qualified name, the argument SIGNATURE, and the body differ between
492
+ // the twins; the `CREATE OR REPLACE … RETURNS SETOF bigint … $kairos$…$kairos$`
493
+ // wrapper is one template, emitted once rather than restated per arm of the
494
+ // ternary below.
495
+ const [qualifiedName, argList, body]: readonly [string, string, string] =
496
+ options.kind === 'conditional'
497
+ ? [
498
+ names.appendFn,
499
+ 'query_items jsonb, after_id bigint, new_events jsonb',
500
+ conditionalAppendBody(names, options),
501
+ ]
502
+ : [
503
+ names.appendUnconditionalFn,
504
+ 'new_events jsonb',
505
+ unconditionalAppendBody(names, options),
506
+ ]
507
+ return `CREATE OR REPLACE FUNCTION ${quoteQualified(qualifiedName)}(
508
+ ${argList}
509
+ ) RETURNS SETOF bigint
510
+ LANGUAGE plpgsql AS $kairos$${body}$kairos$;`
511
+ }
512
+
513
+ /**
514
+ * The full ordered list of idempotent DDL statements that create/refresh a
515
+ * store's objects. Each entry is a `sql.unsafe` statement string; VALUES are not
516
+ * involved (pure DDL), and every identifier is pre-quoted via `quoteQualified`
517
+ * to match the runtime's `sql(identifier)` resolution.
518
+ *
519
+ * Ordering matters: tables before their indexes and before the tag table's FK,
520
+ * functions last (they reference the tables). Everything is `IF NOT EXISTS` /
521
+ * `CREATE OR REPLACE`, so re-running is a no-op — the migration payload is safe
522
+ * to register in any migrator or to run directly.
523
+ */
524
+ export const ddlStatements = (names: ResolvedNames): ReadonlyArray<string> => {
525
+ const main = quoteQualified(names.mainTable)
526
+ const tag = quoteQualified(names.tagTable)
527
+ const schema = quoteAtom(names.schema)
528
+ const mainIndex = quoteAtom(names.mainIndex)
529
+ const tagIndex = quoteAtom(names.tagIndex)
530
+
531
+ return [
532
+ // The schema may already exist (e.g. `public`); create it defensively so a
533
+ // custom schema does not require a separate provisioning step.
534
+ `CREATE SCHEMA IF NOT EXISTS ${schema};`,
535
+
536
+ // Main event table. `id bigserial` gives strictly-increasing (NOT gapless)
537
+ // positions — a rolled-back insert permanently consumes its id, which is
538
+ // fine: ordering is by id alone and the store is never made gapless.
539
+ // `data` is NULLABLE (a payload-less event is valid); `occurred_at` is
540
+ // informational domain time, never an ordering key.
541
+ `CREATE TABLE IF NOT EXISTS ${main} (
542
+ id bigserial,
543
+ type text NOT NULL,
544
+ data bytea,
545
+ tags text[] NOT NULL,
546
+ uuid text NOT NULL,
547
+ occurred_at timestamptz NOT NULL
548
+ ) WITH (${AUTOVACUUM_WITH});`,
549
+
550
+ // Covering unique index: uniqueness on id plus INCLUDE(type) so the read
551
+ // path's id→type lookups are index-only.
552
+ `CREATE UNIQUE INDEX IF NOT EXISTS ${mainIndex}
553
+ ON ${main} (id) INCLUDE (type);`,
554
+
555
+ // Junction tag table: one row per (tag, event) OCCURRENCE, under NO
556
+ // uniqueness constraint — the fan-out is a plain `unnest(tags)`, so an event
557
+ // whose own `tags` list repeats a tag lands TWO rows here. That is not a
558
+ // defect to close by constraining the table: a repeated tag is legal on an
559
+ // event and carries no information, so refusing it would break parity with
560
+ // the in-memory oracle over a physical-design decision the contract knows
561
+ // nothing about. The match side absorbs it instead, by counting DISTINCT tags
562
+ // per event — `internal/matchSql.ts` owns what that buys and what it obliges
563
+ // the requirement count to be.
564
+ //
565
+ // The `main_id … REFERENCES` FK ties a tag row to its event; it is a VERBATIM
566
+ // port of upstream `postgres_tt.py` (its junction DDL carries the same
567
+ // `main_id bigint REFERENCES {events_table} (id)`), not an addition —
568
+ // ADR-0002 adopts that design. Both inserts run in one
569
+ // statement/transaction so the referenced main row is always visible to the
570
+ // tag insert. Same autovacuum tuning as the main table.
571
+ `CREATE TABLE IF NOT EXISTS ${tag} (
572
+ tag text,
573
+ main_id bigint REFERENCES ${main} (id)
574
+ ) WITH (${AUTOVACUUM_WITH});`,
575
+
576
+ // Composite B-tree (tag, main_id): the tag-first CTE probes by tag then
577
+ // joins by main_id, so this index serves both the conflict check and reads.
578
+ `CREATE INDEX IF NOT EXISTS ${tagIndex}
579
+ ON ${tag} (tag, main_id);`,
580
+
581
+ // Conditional append (production): lock → check → insert → notify.
582
+ appendFunctionStatement(names, { kind: 'conditional' }),
583
+
584
+ // Unconditional twin: lock → insert → notify, no conflict check.
585
+ appendFunctionStatement(names, { kind: 'unconditional' }),
586
+ ]
587
+ }
588
+
589
+ /**
590
+ * Run the ordered DDL against the generic `SqlClient`. Kept here (not in the
591
+ * public `ensureSchema`) so the same builder feeds both the public migration
592
+ * payload and any internal test setup. Each statement is a separate `sql.unsafe`
593
+ * call so a driver that rejects multi-statement strings still works.
594
+ */
595
+ export const runDdl = (
596
+ sql: SqlClient.SqlClient,
597
+ names: ResolvedNames,
598
+ ): ReadonlyArray<Statement.Statement<unknown>> =>
599
+ ddlStatements(names).map((statement) => sql.unsafe(statement))