@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,424 @@
1
+ import { allowedTypesPredicate, eventRecordsetColumns, qualifiedRows, taggedMatchChain } from "./matchSql.js";
2
+ /** Default schema when the config omits one. */
3
+ export const DEFAULT_SCHEMA = 'public';
4
+ /** Default table prefix when the config omits one. */
5
+ export const DEFAULT_TABLE_PREFIX = 'dcb_events';
6
+ /**
7
+ * Postgres truncates any identifier to 63 bytes (`NAMEDATALEN - 1`) with only a
8
+ * notice. Two configs whose derived names share a 63-byte prefix would then
9
+ * collide silently: `CREATE INDEX IF NOT EXISTS` sees a name-match and SKIPS
10
+ * creating the second store's index (an invisible performance cliff), and two
11
+ * NOTIFY channels truncate into one (cross-store wakes, defeating cohabitation).
12
+ */
13
+ const MAX_IDENTIFIER_BYTES = 63;
14
+ /** UTF-8 byte length (identifiers are bytes, not chars, to Postgres). */
15
+ const utf8ByteLength = value => new TextEncoder().encode(value).length;
16
+ /**
17
+ * The single-atom identifiers Postgres actually materialises for a config —
18
+ * every table/index/function base name plus the NOTIFY channel. `schema.prefix`
19
+ * qualified names are two atoms to Postgres (`schema` and the table name), so
20
+ * each atom is measured separately, not the dotted whole.
21
+ */
22
+ const derivedAtoms = config => {
23
+ const schema = config.schema ?? DEFAULT_SCHEMA;
24
+ const tablePrefix = config.tablePrefix ?? DEFAULT_TABLE_PREFIX;
25
+ return [['schema', schema], ['events table', tablePrefix], ['tag table', `${tablePrefix}_tags`], ['main index', `${tablePrefix}_idx_id_type`], ['tag index', `${tablePrefix}_idx_tag_main_id`], ['append function', `${tablePrefix}_append`], ['unconditional append function', `${tablePrefix}_append_unconditional`], ['NOTIFY channel', `${schema}_${tablePrefix}`.replace(/\./g, '_')]];
26
+ };
27
+ /**
28
+ * The first derived identifier that exceeds 63 bytes, as a human-readable
29
+ * message, or `undefined` when every name fits. Shared by `resolveNames` (which
30
+ * throws) and the `PostgresStoreConfig` schema filter (which fails validation),
31
+ * so the byte bound is enforced identically at both the name-derivation and the
32
+ * config-decode boundary.
33
+ */
34
+ export const oversizedIdentifier = config => {
35
+ for (const [label, atom] of derivedAtoms(config)) {
36
+ const bytes = utf8ByteLength(atom);
37
+ if (bytes > MAX_IDENTIFIER_BYTES) {
38
+ 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`;
39
+ }
40
+ }
41
+ return undefined;
42
+ };
43
+ /**
44
+ * Derive every owned object name from `{ schema, tablePrefix }`. The index and
45
+ * function *base* names are unqualified prefixes (`<prefix>_…`) because an index
46
+ * name is scoped to its table's schema and a function is created in `<schema>`;
47
+ * the tables and functions carry the schema explicitly so `sql(identifier)`
48
+ * dot-splits them.
49
+ *
50
+ * Throws at construction if any derived identifier would be silently truncated
51
+ * (see `oversizedIdentifier` / `MAX_IDENTIFIER_BYTES`) — a loud failure at the
52
+ * single choke point beats an invisible index-skip or channel collision later.
53
+ */
54
+ export const resolveNames = config => {
55
+ const problem = oversizedIdentifier(config);
56
+ if (problem !== undefined) {
57
+ throw new Error(`resolveNames: ${problem}`);
58
+ }
59
+ const schema = config.schema ?? DEFAULT_SCHEMA;
60
+ const tablePrefix = config.tablePrefix ?? DEFAULT_TABLE_PREFIX;
61
+ return {
62
+ schema,
63
+ tablePrefix,
64
+ mainTable: `${schema}.${tablePrefix}`,
65
+ tagTable: `${schema}.${tablePrefix}_tags`,
66
+ mainIndex: `${tablePrefix}_idx_id_type`,
67
+ tagIndex: `${tablePrefix}_idx_tag_main_id`,
68
+ appendFn: `${schema}.${tablePrefix}_append`,
69
+ appendUnconditionalFn: `${schema}.${tablePrefix}_append_unconditional`,
70
+ channel: `${schema}_${tablePrefix}`.replace(/\./g, '_')
71
+ };
72
+ };
73
+ /**
74
+ * Quote a possibly-qualified identifier for embedding inside a plpgsql body as
75
+ * literal text. Splits on `.` and double-quotes each atom, mirroring what
76
+ * `sql(identifier)` emits, so the body's table references and the runtime's
77
+ * `sql(identifier)` references resolve to exactly the same object. Any embedded
78
+ * `"` is doubled per SQL identifier-quoting rules.
79
+ */
80
+ export const quoteQualified = name => name.split('.').map(atom => `"${atom.replace(/"/g, '""')}"`).join('.');
81
+ /**
82
+ * A quoted single-atom identifier (no dot-splitting) — used where a bare name is
83
+ * required, e.g. index/schema names.
84
+ */
85
+ const quoteAtom = name => `"${name.replace(/"/g, '""')}"`;
86
+ /**
87
+ * A single-quoted SQL string LITERAL (not an identifier). Used for the
88
+ * `pg_notify(channel, payload)` channel argument, which is a `text` VALUE, not
89
+ * an identifier — double-quoting it there would (wrongly) parse as a column
90
+ * reference. Embedded single quotes are doubled per SQL string-literal rules.
91
+ */
92
+ const quoteLiteral = value => `'${value.replace(/'/g, "''")}'`;
93
+ /**
94
+ * The autovacuum storage parameters, ported verbatim from upstream
95
+ * `postgres_tt.py`: the event log is append-heavy and effectively immutable, so
96
+ * vacuum is tuned to run rarely (huge thresholds) while analyze stays keen
97
+ * (small thresholds) to keep the planner's row estimates fresh for the tag-first
98
+ * CTE. Applied identically to both tables.
99
+ */
100
+ const AUTOVACUUM_WITH = 'autovacuum_enabled = true, ' + 'autovacuum_vacuum_threshold = 100000000, ' + 'autovacuum_vacuum_scale_factor = 0.5, ' + 'autovacuum_analyze_threshold = 1000, ' + 'autovacuum_analyze_scale_factor = 0.01';
101
+ /**
102
+ * The `LOCK TABLE <main> IN EXCLUSIVE MODE;` clause — the serialisation point of
103
+ * the whole engine. `EXCLUSIVE` blocks other writers (and other conditional
104
+ * appends) but lets readers proceed, so the check-then-insert of one append
105
+ * cannot interleave with another's: exactly-one-wins holds by construction, with
106
+ * no dependence on isolation level or unique constraints.
107
+ *
108
+ * It is a SEPARATE, OMITTABLE fragment purely so the test-only lockless twin can
109
+ * drop it. Production always includes it.
110
+ */
111
+ const lockClause = names => `LOCK TABLE ${quoteQualified(names.mainTable)} IN EXCLUSIVE MODE;`;
112
+ /**
113
+ * The shared insert → tag fan-out → NOTIFY body, emitting into a local
114
+ * `new_head bigint` and then `RETURN QUERY SELECT new_head`. Written as explicit
115
+ * sequential statements (not one mega-CTE) so control flow is obvious and the
116
+ * `pg_notify` fires unconditionally after a successful insert rather than
117
+ * depending on a data-modifying CTE being referenced.
118
+ *
119
+ * A single `INSERT … SELECT … FROM jsonb_to_recordset(…) ORDER BY ordinality`
120
+ * preserves batch order, and `RETURNING id, tags` is captured into a temp
121
+ * result set so the tag fan-out and the head can both read it. `data` arrives as
122
+ * a base64 text field and is `decode(…, 'base64')`-restored to `bytea`.
123
+ */
124
+ const insertFanoutNotify = (names, options = {}) => {
125
+ const main = quoteQualified(names.mainTable);
126
+ const tag = quoteQualified(names.tagTable);
127
+ const channel = quoteLiteral(names.channel);
128
+ // `notify` is an OMITTABLE fragment (like the lock): production always
129
+ // notifies; the poll-only delivery test `CREATE OR REPLACE`s a NOTIFY-suppressed
130
+ // UNCONDITIONAL twin over its own prefix to prove the periodic poll — not
131
+ // NOTIFY — is the delivery guarantee. The conditional body never suppresses it.
132
+ const notify = options.notify ?? true ? `
133
+ -- Wake subscribers only if we actually inserted rows. NOTIFY is a payload-less
134
+ -- signal in spirit; a constant '1' payload is sent purely because the built-in
135
+ -- ref-counted listen client drops empty-payload notifications — the TS side
136
+ -- treats every notification as an opaque wake and ignores the payload.
137
+ IF new_head IS NOT NULL THEN
138
+ PERFORM pg_notify(${channel}, '1');
139
+ END IF;` : '';
140
+ return ` WITH ne AS (
141
+ SELECT type, data, tags, uuid, occurred_at, ordinality
142
+ FROM ROWS FROM(
143
+ jsonb_to_recordset(new_events) AS (${eventRecordsetColumns})
144
+ ) WITH ORDINALITY
145
+ ),
146
+ inserted AS (
147
+ INSERT INTO ${main} (type, data, tags, uuid, occurred_at)
148
+ SELECT ne.type, decode(ne.data, 'base64'), ne.tags, ne.uuid,
149
+ ne.occurred_at
150
+ FROM ne
151
+ ORDER BY ne.ordinality
152
+ RETURNING id, tags
153
+ ),
154
+ fanned AS (
155
+ INSERT INTO ${tag} (tag, main_id)
156
+ SELECT unnest(inserted.tags), inserted.id
157
+ FROM inserted
158
+ RETURNING main_id
159
+ )
160
+ -- fanned is a DATA-MODIFYING CTE: Postgres runs it exactly once to completion
161
+ -- whether or not the primary query reads its output, so the tag fan-out
162
+ -- happens even though nothing selects from it — and so a count(*) over it
163
+ -- would add nothing but work.
164
+ SELECT MAX(inserted.id)
165
+ INTO new_head
166
+ FROM inserted;
167
+ ${notify}`;
168
+ };
169
+ /**
170
+ * Build the body of the CONDITIONAL append function (lock → check → insert →
171
+ * notify), ported from upstream `dcb_conditional_append_tt` with the
172
+ * JSON-transport divergence (ADR-0002).
173
+ *
174
+ * Module-private: the ONLY caller is `appendFunctionStatement`, which owns the
175
+ * full `CREATE OR REPLACE` wrapper. Keeping the body builders unexported means a
176
+ * caller cannot re-introduce a hand-rolled wrapper (the drift this consolidation
177
+ * removes) — the wrapper is single-sourced.
178
+ */
179
+ const conditionalAppendBody = (names, options = {}) => {
180
+ const main = quoteQualified(names.mainTable);
181
+ const tag = quoteQualified(names.tagTable);
182
+ const lock = options.lock ?? true ? ` ${lockClause(names)}\n` : '';
183
+ // The conditional body always notifies — `insertFanoutNotify` defaults to it,
184
+ // and there is deliberately no `notify` seam here: only the unconditional twin
185
+ // is ever notify-suppressed, by the poll-only delivery test.
186
+ // The tagged branch shares its match SQL with the TS read path via one builder
187
+ // (`internal/matchSql.ts`) — plpgsql parameter tokens here (`query_items`,
188
+ // `COALESCE(after_id,0)`), bound `$n` on the read side. The wildcard/tagless
189
+ // branches are conflict-only (the read dispatches those shapes in TS) and reuse
190
+ // `qi` + the shared allowed-types predicate.
191
+ //
192
+ // No compiler reaches any of this. Core's `ServableQueryShape` appears nowhere in
193
+ // the body text, so a member added to that union is invisible here, where
194
+ // `internal/readPlan.ts`'s exhaustive `switch` stops compiling — and what stands
195
+ // in for the compiler is the generality the body comment below claims. Worth
196
+ // naming is why that claim holds: the three contributions partition a query by
197
+ // its ITEMS — none at all, an item carrying no tags, an item carrying tags —
198
+ // which between them cover every `Query` value the grammar can express, so a new
199
+ // SHAPE changes which SQL the read compiles without changing what this body has
200
+ // to match. The exposure is the GRAMMAR itself: a new `QueryItem` field, or a new
201
+ // meaning for an existing one, is something this body ignores rather than fails
202
+ // on, and no `switch` over the shape union would have caught that either.
203
+ const after = 'COALESCE(after_id, 0)';
204
+ return `
205
+ DECLARE
206
+ conflict_exists boolean;
207
+ new_head bigint;
208
+ BEGIN
209
+ -- lock_timeout is set per-call by the caller via set_config(..., is_local),
210
+ -- so the same function honours the configured timeout without recompilation.
211
+ ${lock} -- Conflict check, reduced to EXISTS/LIMIT 1: does ANY event after
212
+ -- COALESCE(after_id, 0) satisfy the guard query (items OR'd; within an item,
213
+ -- types OR-filter AND tags AND-superset)? Three contributions, OR'd:
214
+ -- * the WILDCARD (empty query_items) - any event after after_id conflicts;
215
+ -- * TAGLESS items (empty tags, maybe types) - a type-only / match-all item,
216
+ -- matched by type = ANY(types) (or any type when types is empty);
217
+ -- * TAGGED items - the shared tag-first match chain, identical to the read.
218
+ -- The servable grammar (assertServableQuery) guarantees a tagless item only
219
+ -- appears as the whole single-item query or the wildcard, but the check is
220
+ -- written generically so plain-data assembly cannot surprise it.
221
+ WITH ${taggedMatchChain({
222
+ queryItemsExpr: 'query_items',
223
+ afterExpr: after,
224
+ main,
225
+ tag
226
+ })},
227
+ -- Wildcard: no items at all means "match everything".
228
+ wildcard_conflict AS (
229
+ SELECT 1
230
+ FROM ${main} m
231
+ WHERE NOT EXISTS (SELECT 1 FROM qi)
232
+ AND m.id > ${after}
233
+ LIMIT 1
234
+ ),
235
+ -- Tagless items: match by type (or any type when the item has no types).
236
+ tagless_conflict AS (
237
+ SELECT 1
238
+ FROM qi
239
+ JOIN ${main} m ON m.id > ${after}
240
+ WHERE (qi.tags IS NULL OR array_length(qi.tags, 1) IS NULL)
241
+ AND ${allowedTypesPredicate('qi.types')}
242
+ LIMIT 1
243
+ ),
244
+ -- Tagged items: distinct-tag-count AND-superset matching (the shared source).
245
+ tagged_conflict AS (
246
+ SELECT 1
247
+ ${qualifiedRows({
248
+ main,
249
+ afterExpr: after
250
+ })}
251
+ LIMIT 1
252
+ )
253
+ SELECT
254
+ EXISTS (SELECT 1 FROM wildcard_conflict)
255
+ OR EXISTS (SELECT 1 FROM tagless_conflict)
256
+ OR EXISTS (SELECT 1 FROM tagged_conflict)
257
+ INTO conflict_exists;
258
+
259
+ IF NOT conflict_exists THEN
260
+ -- No conflict: insert the batch, fan tags out, wake subscribers, return head.
261
+ ${insertFanoutNotify(names)}
262
+ RETURN QUERY SELECT new_head WHERE new_head IS NOT NULL;
263
+ END IF;
264
+ -- conflict_exists = true falls through, returning zero rows = a conflict.
265
+ RETURN;
266
+ END;
267
+ `;
268
+ };
269
+ /**
270
+ * Build the body of the UNCONDITIONAL append function: no conflict check, but the
271
+ * SAME `LOCK TABLE … IN EXCLUSIVE MODE` as the conditional twin.
272
+ *
273
+ * WHY the lock (a deliberate divergence from upstream's lock-free twin, ADR-0002):
274
+ * `bigserial` ids are allocated NON-transactionally, so two lock-free
275
+ * unconditional writers can allocate ids 5 and 6 and COMMIT them in reverse
276
+ * order. Commit-order then diverges from id-order, and two invariants the rest of
277
+ * the engine leans on silently break:
278
+ *
279
+ * 1. a `subscribe` poll that lands between the two commits sees id 6, advances
280
+ * its strict `> cursor` past 6, and PERMANENTLY skips id 5 when it commits
281
+ * milliseconds later — silent event loss on the live tail;
282
+ * 2. a no-limit read taken while the lower id is in flight returns `head` PAST
283
+ * an id no snapshot can yet see, so a decision model appending under
284
+ * `{ after: head }` never examines that id as a conflict — an escaped guard.
285
+ *
286
+ * The check-free FAST PATH is preserved (no conflict CTE); only the lock is
287
+ * added. It restores commit-order = id-order for EVERY append, making ADR-0002's
288
+ * "every append participates in the same mechanism by construction" true in the
289
+ * strong sense. The cost — unconditional writers serialise with all other writers
290
+ * — is exactly the write ceiling ADR-0002 already accepts and marks benchmarkable.
291
+ *
292
+ * `options.lock` is the same OMITTABLE-fragment seam as `conditionalAppendBody`:
293
+ * production defaults to `true`; the commit-order repro passes `{ lock: false }`
294
+ * over its own prefix to prove the subscribe-skip / head-overtake scenario
295
+ * genuinely reproduces the bug when the lock is absent (non-vacuity), exactly as
296
+ * the lockless meta-test does for the conditional path.
297
+ *
298
+ * `options.notify` is the analogous seam for the poll-only delivery test — a
299
+ * NOTIFY-suppressed twin (`{ notify: false }`) proves the periodic poll (not
300
+ * NOTIFY) is the delivery guarantee. Production always notifies.
301
+ *
302
+ * Module-private, like `conditionalAppendBody`: reached only via
303
+ * `appendFunctionStatement`.
304
+ */
305
+ const unconditionalAppendBody = (names, options = {}) => {
306
+ const lock = options.lock ?? true ? ` ${lockClause(names)}\n` : '';
307
+ // Pass the `notify` option straight through — no unwrap-then-rewrap;
308
+ // `insertFanoutNotify` owns the default.
309
+ return `
310
+ DECLARE
311
+ new_head bigint;
312
+ BEGIN
313
+ ${lock}${insertFanoutNotify(names, options)}
314
+ RETURN QUERY SELECT new_head WHERE new_head IS NOT NULL;
315
+ END;
316
+ `;
317
+ };
318
+ /**
319
+ * Build ONE complete `CREATE OR REPLACE FUNCTION … RETURNS SETOF bigint LANGUAGE
320
+ * plpgsql AS $kairos$…$kairos$` statement for an append function. This is the
321
+ * single source of the wrapper — its qualified name, argument SIGNATURE, and
322
+ * `$kairos$`-delimited body — consumed by `ddlStatements` (production, both twins)
323
+ * AND by the test installers that `CREATE OR REPLACE` a lockless / notify-
324
+ * suppressed twin over their own prefix.
325
+ *
326
+ * WHY one builder: `ddlStatements` plus three test twins previously hand-restated
327
+ * this wrapper, several with string-INTERPOLATED identifiers this module's own
328
+ * doctrine forbids (`"${schema}"."${prefix}_append"` rather than
329
+ * `quoteQualified(names.appendFn)`). A drift in the SIGNATURE across those copies
330
+ * is a silent footgun: `CREATE OR REPLACE` with a changed argument list creates a
331
+ * new OVERLOAD instead of replacing, leaving the original production function live
332
+ * under test. Single-sourcing name + signature makes a twin unable to diverge —
333
+ * a twin `CREATE OR REPLACE`s exactly the function `ddlStatements` created.
334
+ */
335
+ export const appendFunctionStatement = (names, options) => {
336
+ // Only the qualified name, the argument SIGNATURE, and the body differ between
337
+ // the twins; the `CREATE OR REPLACE … RETURNS SETOF bigint … $kairos$…$kairos$`
338
+ // wrapper is one template, emitted once rather than restated per arm of the
339
+ // ternary below.
340
+ const [qualifiedName, argList, body] = options.kind === 'conditional' ? [names.appendFn, 'query_items jsonb, after_id bigint, new_events jsonb', conditionalAppendBody(names, options)] : [names.appendUnconditionalFn, 'new_events jsonb', unconditionalAppendBody(names, options)];
341
+ return `CREATE OR REPLACE FUNCTION ${quoteQualified(qualifiedName)}(
342
+ ${argList}
343
+ ) RETURNS SETOF bigint
344
+ LANGUAGE plpgsql AS $kairos$${body}$kairos$;`;
345
+ };
346
+ /**
347
+ * The full ordered list of idempotent DDL statements that create/refresh a
348
+ * store's objects. Each entry is a `sql.unsafe` statement string; VALUES are not
349
+ * involved (pure DDL), and every identifier is pre-quoted via `quoteQualified`
350
+ * to match the runtime's `sql(identifier)` resolution.
351
+ *
352
+ * Ordering matters: tables before their indexes and before the tag table's FK,
353
+ * functions last (they reference the tables). Everything is `IF NOT EXISTS` /
354
+ * `CREATE OR REPLACE`, so re-running is a no-op — the migration payload is safe
355
+ * to register in any migrator or to run directly.
356
+ */
357
+ export const ddlStatements = names => {
358
+ const main = quoteQualified(names.mainTable);
359
+ const tag = quoteQualified(names.tagTable);
360
+ const schema = quoteAtom(names.schema);
361
+ const mainIndex = quoteAtom(names.mainIndex);
362
+ const tagIndex = quoteAtom(names.tagIndex);
363
+ return [
364
+ // The schema may already exist (e.g. `public`); create it defensively so a
365
+ // custom schema does not require a separate provisioning step.
366
+ `CREATE SCHEMA IF NOT EXISTS ${schema};`,
367
+ // Main event table. `id bigserial` gives strictly-increasing (NOT gapless)
368
+ // positions — a rolled-back insert permanently consumes its id, which is
369
+ // fine: ordering is by id alone and the store is never made gapless.
370
+ // `data` is NULLABLE (a payload-less event is valid); `occurred_at` is
371
+ // informational domain time, never an ordering key.
372
+ `CREATE TABLE IF NOT EXISTS ${main} (
373
+ id bigserial,
374
+ type text NOT NULL,
375
+ data bytea,
376
+ tags text[] NOT NULL,
377
+ uuid text NOT NULL,
378
+ occurred_at timestamptz NOT NULL
379
+ ) WITH (${AUTOVACUUM_WITH});`,
380
+ // Covering unique index: uniqueness on id plus INCLUDE(type) so the read
381
+ // path's id→type lookups are index-only.
382
+ `CREATE UNIQUE INDEX IF NOT EXISTS ${mainIndex}
383
+ ON ${main} (id) INCLUDE (type);`,
384
+ // Junction tag table: one row per (tag, event) OCCURRENCE, under NO
385
+ // uniqueness constraint — the fan-out is a plain `unnest(tags)`, so an event
386
+ // whose own `tags` list repeats a tag lands TWO rows here. That is not a
387
+ // defect to close by constraining the table: a repeated tag is legal on an
388
+ // event and carries no information, so refusing it would break parity with
389
+ // the in-memory oracle over a physical-design decision the contract knows
390
+ // nothing about. The match side absorbs it instead, by counting DISTINCT tags
391
+ // per event — `internal/matchSql.ts` owns what that buys and what it obliges
392
+ // the requirement count to be.
393
+ //
394
+ // The `main_id … REFERENCES` FK ties a tag row to its event; it is a VERBATIM
395
+ // port of upstream `postgres_tt.py` (its junction DDL carries the same
396
+ // `main_id bigint REFERENCES {events_table} (id)`), not an addition —
397
+ // ADR-0002 adopts that design. Both inserts run in one
398
+ // statement/transaction so the referenced main row is always visible to the
399
+ // tag insert. Same autovacuum tuning as the main table.
400
+ `CREATE TABLE IF NOT EXISTS ${tag} (
401
+ tag text,
402
+ main_id bigint REFERENCES ${main} (id)
403
+ ) WITH (${AUTOVACUUM_WITH});`,
404
+ // Composite B-tree (tag, main_id): the tag-first CTE probes by tag then
405
+ // joins by main_id, so this index serves both the conflict check and reads.
406
+ `CREATE INDEX IF NOT EXISTS ${tagIndex}
407
+ ON ${tag} (tag, main_id);`,
408
+ // Conditional append (production): lock → check → insert → notify.
409
+ appendFunctionStatement(names, {
410
+ kind: 'conditional'
411
+ }),
412
+ // Unconditional twin: lock → insert → notify, no conflict check.
413
+ appendFunctionStatement(names, {
414
+ kind: 'unconditional'
415
+ })];
416
+ };
417
+ /**
418
+ * Run the ordered DDL against the generic `SqlClient`. Kept here (not in the
419
+ * public `ensureSchema`) so the same builder feeds both the public migration
420
+ * payload and any internal test setup. Each statement is a separate `sql.unsafe`
421
+ * call so a driver that rejects multi-statement strings still works.
422
+ */
423
+ export const runDdl = (sql, names) => ddlStatements(names).map(statement => sql.unsafe(statement));
424
+ //# sourceMappingURL=ddl.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ddl.js","names":["allowedTypesPredicate","eventRecordsetColumns","qualifiedRows","taggedMatchChain","DEFAULT_SCHEMA","DEFAULT_TABLE_PREFIX","MAX_IDENTIFIER_BYTES","utf8ByteLength","value","TextEncoder","encode","length","derivedAtoms","config","schema","tablePrefix","replace","oversizedIdentifier","label","atom","bytes","undefined","resolveNames","problem","Error","mainTable","tagTable","mainIndex","tagIndex","appendFn","appendUnconditionalFn","channel","quoteQualified","name","split","map","join","quoteAtom","quoteLiteral","AUTOVACUUM_WITH","lockClause","names","insertFanoutNotify","options","main","tag","notify","conditionalAppendBody","lock","after","queryItemsExpr","afterExpr","unconditionalAppendBody","appendFunctionStatement","qualifiedName","argList","body","kind","ddlStatements","runDdl","sql","statement","unsafe"],"sources":["../../../src/internal/ddl.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Single source of truth for the tag-first match SQL.
3
+ *
4
+ * The servable-query matching semantics — the tagged AND-superset match with
5
+ * distinct-tag counting, the allowed-types filter, the exclusive `after` bound —
6
+ * were hand-duplicated between the TS read path (`store.ts`) and the plpgsql
7
+ * conflict check (`ddl.ts`): two SQL dialect-copies, in two files, that drift
8
+ * independently (the read's wildcard mis-routing of a degenerate empty item was
9
+ * one such drift). This module emits the shared fragments ONCE, parameterised by:
10
+ *
11
+ * - the PLACEHOLDER TOKENS each call site binds — plpgsql parameter names
12
+ * (`query_items`, `COALESCE(after_id, 0)`) for the conflict body vs bound
13
+ * `$n` placeholders for the runtime `sql.unsafe(text, params)` read; and
14
+ * - the PROJECTION — full rows (read) vs an `EXISTS`/`LIMIT 1` probe (conflict),
15
+ * which each call site wraps around the shared `qualifiedRows` source.
16
+ *
17
+ * The `jsonb_to_recordset` event column list also lives here as ONE descriptor,
18
+ * so the write recordset (`ddl.ts`) and the read projection cannot disagree.
19
+ *
20
+ * Table names arrive PRE-QUOTED (via `quoteQualified` in `ddl.ts`), so this
21
+ * module never quotes identifiers itself and stays free of the naming logic.
22
+ * Every fragment assumes the main events table is aliased `m`.
23
+ */
24
+ /**
25
+ * The event payload columns shared by the write recordset and every read
26
+ * projection, as `[name, recordsetType]` pairs. `id` (the `bigserial` position)
27
+ * is prepended by the read projection and absent from the events recordset.
28
+ *
29
+ * `as const` freezes it into a tuple of literal names so `EventColumnName` (below)
30
+ * can derive the exact column-name union — the whole point of the "one descriptor"
31
+ * story: the TS transport shapes in `internal/transport.ts` are mapped types over
32
+ * that union, so a column rename here is a COMPILE error there, not a silent drift
33
+ * the lax read boundary (ADR-0006) would only surface as corrupt data under test.
34
+ */
35
+ const EVENT_COLUMNS = [['type', 'text'], ['data', 'text'], ['tags', 'text[]'], ['uuid', 'text'], ['occurred_at', 'timestamptz']];
36
+ /**
37
+ * The `jsonb_to_recordset(new_events) AS (…)` column definitions —
38
+ * `type text, data text, tags text[], uuid text, occurred_at timestamptz`. The
39
+ * transport `data` is base64 TEXT (restored to `bytea` by `decode(…, 'base64')`
40
+ * at insert), which is why it is `text` here, not `bytea`.
41
+ */
42
+ export const eventRecordsetColumns = /*#__PURE__*/EVENT_COLUMNS.map(([name, type]) => `${name} ${type}`).join(', ');
43
+ /**
44
+ * The read projection column list (`id` + the event columns), optionally
45
+ * prefixed with a table alias (`'m.'`). Sourced from the one descriptor so it
46
+ * cannot drift from the recordset.
47
+ */
48
+ export const readColumns = (prefix = '') => [`${prefix}id`, ...EVENT_COLUMNS.map(([name]) => `${prefix}${name}`)].join(', ');
49
+ /**
50
+ * The allowed-types filter for a candidate row aliased `m`: an item with no
51
+ * types matches ANY type; otherwise the event's type must be one of them.
52
+ * `typesCol` is the `text[]` column holding the item's types — `q.allowed_types`
53
+ * for a tagged match, `qi.types` for a tagless one.
54
+ */
55
+ export const allowedTypesPredicate = typesCol => `(array_length(${typesCol}, 1) IS NULL OR array_length(${typesCol}, 1) = 0 OR m.type = ANY(${typesCol}))`;
56
+ /**
57
+ * The tag-first match CTE chain shared by the read and conflict paths: expand
58
+ * the query items (`qi`), join the tag junction, count DISTINCT matched tags per
59
+ * (event, item), and keep events matching an item's FULL tag superset. Returns
60
+ * `qi, initial_matches, matched_groups, qualified_ids` — the caller follows it
61
+ * with a projection CTE selecting from `qualified_ids`.
62
+ *
63
+ * ## Both sides of the count are SET SIZES, and that is the whole invariant
64
+ *
65
+ * `qualified_ids` compares two numbers, and the ONE thing an edit here must
66
+ * preserve is that they count the same kind of thing: `matched_tag_count` is the
67
+ * number of DISTINCT tags the event carried out of the item's requirement, so
68
+ * `required_tag_count` is the size of that requirement AS A SET and never the
69
+ * length of the list it arrived as. A query item may list one tag twice — no
70
+ * constructor de-duplicates, `assertServableQuery` admits it and
71
+ * `classifyServableQuery` routes it here — and the in-memory oracle folds an
72
+ * item's tags into a superset test that a repeat cannot change. Comparing a
73
+ * de-duplicated 1 against a list length of 2 refused such a match: the READ
74
+ * returned nothing and, because the plpgsql conflict body composes this same
75
+ * chain, the GUARD found no conflict and the append it should have refused
76
+ * succeeded — an optimistic-concurrency check that escaped silently.
77
+ *
78
+ * The asymmetry has two repairs — de-duplicate both sides, or expand both — and
79
+ * only the first is available here. The matched side CANNOT become a plain row
80
+ * count, because this engine's junction holds one row per (tag, event) OCCURRENCE
81
+ * and not per pair, so an event whose own `tags` list repeats a tag has two rows
82
+ * there and a row count would refuse a single-tag item against it. `ddl.ts` states
83
+ * that property on the junction, and it is what `COUNT(DISTINCT tag)` is here to
84
+ * absorb; the requirement is de-duplicated to meet it.
85
+ *
86
+ * `required_tag_count` is therefore computed in `qi`, once per ITEM, rather than
87
+ * in the group that consumes it — the chain groups per (event, item), so a
88
+ * subquery in `matched_groups` would be re-evaluated per matched event. It is
89
+ * `count(*)` over a `SELECT DISTINCT` rather than `count(DISTINCT …)` because the
90
+ * latter drops a NULL element: a `[tag, NULL]` requirement would shrink to 1,
91
+ * meet a single matched tag and match, where the oracle's superset test cannot be
92
+ * satisfied by a NULL and refuses it.
93
+ *
94
+ * The `array_length(qi.tags, 1) >= 1` guard excludes tagless items from the
95
+ * tagged branch (load-bearing for the conflict body, which also has a separate
96
+ * tagless branch; a no-op for the read, whose tagged shape is all-tagged). It
97
+ * reads the item's own array because it is a test of the item's SHAPE — tagged or
98
+ * tagless — and not part of the match arithmetic above.
99
+ */
100
+ export const taggedMatchChain = tokens => `qi AS (
101
+ SELECT types, tags, ordinality,
102
+ -- Both sides of the match compare DISTINCT-TAG counts: this is the
103
+ -- requirement's SET size, never the length of the list it arrived as.
104
+ (SELECT count(*) FROM (SELECT DISTINCT req.tag FROM unnest(tags) AS req(tag)) d)
105
+ AS required_tag_count
106
+ FROM ROWS FROM(
107
+ jsonb_to_recordset(${tokens.queryItemsExpr}) AS (types text[], tags text[])
108
+ ) WITH ORDINALITY
109
+ ),
110
+ initial_matches AS (
111
+ SELECT t.main_id, qi.ordinality, qi.required_tag_count,
112
+ qi.types AS allowed_types, t.tag
113
+ FROM qi
114
+ JOIN ${tokens.tag} t ON t.tag = ANY(qi.tags)
115
+ WHERE t.main_id > ${tokens.afterExpr}
116
+ AND array_length(qi.tags, 1) >= 1
117
+ ),
118
+ matched_groups AS (
119
+ SELECT main_id, ordinality, required_tag_count, allowed_types,
120
+ COUNT(DISTINCT tag) AS matched_tag_count
121
+ FROM initial_matches
122
+ GROUP BY main_id, ordinality, required_tag_count, allowed_types
123
+ ),
124
+ qualified_ids AS (
125
+ SELECT main_id, allowed_types
126
+ FROM matched_groups
127
+ WHERE matched_tag_count = required_tag_count
128
+ )`;
129
+ /**
130
+ * The shared qualified-rows source: join `qualified_ids` back to the main table
131
+ * (aliased `m`), re-apply the exclusive `after` bound, and filter by allowed
132
+ * types. The read wraps this in an ordered/limited `DISTINCT m.id` CTE; the
133
+ * conflict wraps it in a `SELECT 1 … LIMIT 1` existence probe.
134
+ */
135
+ export const qualifiedRows = tokens => `FROM qualified_ids q
136
+ JOIN ${tokens.main} m ON m.id = q.main_id
137
+ WHERE m.id > ${tokens.afterExpr}
138
+ AND ${allowedTypesPredicate('q.allowed_types')}`;
139
+ /** The wildcard read (no items): every event after the bound, ordered/limited. */
140
+ export const wildcardReadSql = main => `SELECT ${readColumns()}
141
+ FROM ${main}
142
+ WHERE id > $1
143
+ ORDER BY id ASC
144
+ LIMIT $2`;
145
+ /** The type-only read (single tagless item): filter by `type = ANY($2)`. */
146
+ export const typeOnlyReadSql = main => `SELECT ${readColumns()}
147
+ FROM ${main}
148
+ WHERE id > $1 AND type = ANY($2)
149
+ ORDER BY id ASC
150
+ LIMIT $3`;
151
+ /**
152
+ * The tagged read: the shared match chain, then a `filtered_ids` CTE that orders
153
+ * and limits the qualified ids by position, then a full-row select. Bound params
154
+ * are `$1` = query-items jsonb, `$2` = exclusive `after`, `$3` = limit (`NULL` =
155
+ * no limit).
156
+ */
157
+ export const taggedReadSql = (main, tag) => `WITH ${taggedMatchChain({
158
+ queryItemsExpr: '$1::jsonb',
159
+ afterExpr: '$2',
160
+ main,
161
+ tag
162
+ })},
163
+ filtered_ids AS (
164
+ SELECT DISTINCT m.id
165
+ ${qualifiedRows({
166
+ main,
167
+ afterExpr: '$2'
168
+ })}
169
+ ORDER BY m.id ASC
170
+ LIMIT $3
171
+ )
172
+ SELECT ${readColumns('m.')}
173
+ FROM ${main} m
174
+ WHERE m.id IN (SELECT id FROM filtered_ids)
175
+ ORDER BY m.id ASC`;
176
+ //# sourceMappingURL=matchSql.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"matchSql.js","names":["EVENT_COLUMNS","eventRecordsetColumns","map","name","type","join","readColumns","prefix","allowedTypesPredicate","typesCol","taggedMatchChain","tokens","queryItemsExpr","tag","afterExpr","qualifiedRows","main","wildcardReadSql","typeOnlyReadSql","taggedReadSql"],"sources":["../../../src/internal/matchSql.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;AAwBA;;;;;;;;;;;AAWA,MAAMA,aAAa,GAAG,CACpB,CAAC,MAAM,EAAE,MAAM,CAAC,EAChB,CAAC,MAAM,EAAE,MAAM,CAAC,EAChB,CAAC,MAAM,EAAE,QAAQ,CAAC,EAClB,CAAC,MAAM,EAAE,MAAM,CAAC,EAChB,CAAC,aAAa,EAAE,aAAa,CAAC,CAG/B;AAYD;;;;;;AAMA,OAAO,MAAMC,qBAAqB,gBAAWD,aAAa,CAACE,GAAG,CAC5D,CAAC,CAACC,IAAI,EAAEC,IAAI,CAAC,KAAK,GAAGD,IAAI,IAAIC,IAAI,EAAE,CACpC,CAACC,IAAI,CAAC,IAAI,CAAC;AAEZ;;;;;AAKA,OAAO,MAAMC,WAAW,GAAGA,CAACC,MAAM,GAAG,EAAE,KACrC,CAAC,GAAGA,MAAM,IAAI,EAAE,GAAGP,aAAa,CAACE,GAAG,CAAC,CAAC,CAACC,IAAI,CAAC,KAAK,GAAGI,MAAM,GAAGJ,IAAI,EAAE,CAAC,CAAC,CAACE,IAAI,CACxE,IAAI,CACL;AAEH;;;;;;AAMA,OAAO,MAAMG,qBAAqB,GAAIC,QAAgB,IACpD,iBAAiBA,QAAQ,gCAAgCA,QAAQ,4BAA4BA,QAAQ,IAAI;AAc3G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CA,OAAO,MAAMC,gBAAgB,GAAIC,MAAmB,IAClD;;;;;;;2BAOyBA,MAAM,CAACC,cAAc;;;;;;;WAOrCD,MAAM,CAACE,GAAG;wBACGF,MAAM,CAACG,SAAS;;;;;;;;;;;;;IAapC;AAEJ;;;;;;AAMA,OAAO,MAAMC,aAAa,GACxBJ,MAA+C,IAE/C;WACSA,MAAM,CAACK,IAAI;mBACHL,MAAM,CAACG,SAAS;YACvBN,qBAAqB,CAAC,iBAAiB,CAAC,EAAE;AAEtD;AACA,OAAO,MAAMS,eAAe,GAAID,IAAY,IAC1C,UAAUV,WAAW,EAAE;UACfU,IAAI;;;YAGF;AAEZ;AACA,OAAO,MAAME,eAAe,GAAIF,IAAY,IAC1C,UAAUV,WAAW,EAAE;UACfU,IAAI;;;YAGF;AAEZ;;;;;;AAMA,OAAO,MAAMG,aAAa,GAAGA,CAACH,IAAY,EAAEH,GAAW,KACrD,QAAQH,gBAAgB,CAAC;EAAEE,cAAc,EAAE,WAAW;EAAEE,SAAS,EAAE,IAAI;EAAEE,IAAI;EAAEH;AAAG,CAAE,CAAC;;;MAGjFE,aAAa,CAAC;EAAEC,IAAI;EAAEF,SAAS,EAAE;AAAI,CAAE,CAAC;;;;WAInCR,WAAW,CAAC,IAAI,CAAC;SACnBU,IAAI;;oBAEO","ignoreList":[]}