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