@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.
- package/LICENSE +28 -0
- package/README.md +167 -0
- package/dist/cjs/clients.js +62 -0
- package/dist/cjs/clients.js.map +1 -0
- package/dist/cjs/config.js +94 -0
- package/dist/cjs/config.js.map +1 -0
- package/dist/cjs/ensureSchema.js +53 -0
- package/dist/cjs/ensureSchema.js.map +1 -0
- package/dist/cjs/index.js +45 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/internal/ddl.js +436 -0
- package/dist/cjs/internal/ddl.js.map +1 -0
- package/dist/cjs/internal/matchSql.js +189 -0
- package/dist/cjs/internal/matchSql.js.map +1 -0
- package/dist/cjs/internal/readPlan.js +67 -0
- package/dist/cjs/internal/readPlan.js.map +1 -0
- package/dist/cjs/internal/subscribe.js +120 -0
- package/dist/cjs/internal/subscribe.js.map +1 -0
- package/dist/cjs/internal/transport.js +66 -0
- package/dist/cjs/internal/transport.js.map +1 -0
- package/dist/cjs/store.js +256 -0
- package/dist/cjs/store.js.map +1 -0
- package/dist/dts/clients.d.ts +34 -0
- package/dist/dts/clients.d.ts.map +1 -0
- package/dist/dts/config.d.ts +58 -0
- package/dist/dts/config.d.ts.map +1 -0
- package/dist/dts/ensureSchema.d.ts +35 -0
- package/dist/dts/ensureSchema.d.ts.map +1 -0
- package/dist/dts/index.d.ts +41 -0
- package/dist/dts/index.d.ts.map +1 -0
- package/dist/dts/internal/ddl.d.ts +174 -0
- package/dist/dts/internal/ddl.d.ts.map +1 -0
- package/dist/dts/internal/matchSql.d.ts +140 -0
- package/dist/dts/internal/matchSql.d.ts.map +1 -0
- package/dist/dts/internal/readPlan.d.ts +72 -0
- package/dist/dts/internal/readPlan.d.ts.map +1 -0
- package/dist/dts/internal/subscribe.d.ts +87 -0
- package/dist/dts/internal/subscribe.d.ts.map +1 -0
- package/dist/dts/internal/transport.d.ts +116 -0
- package/dist/dts/internal/transport.d.ts.map +1 -0
- package/dist/dts/store.d.ts +17 -0
- package/dist/dts/store.d.ts.map +1 -0
- package/dist/esm/clients.js +51 -0
- package/dist/esm/clients.js.map +1 -0
- package/dist/esm/config.js +87 -0
- package/dist/esm/config.js.map +1 -0
- package/dist/esm/ensureSchema.js +45 -0
- package/dist/esm/ensureSchema.js.map +1 -0
- package/dist/esm/index.js +40 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/ddl.js +424 -0
- package/dist/esm/internal/ddl.js.map +1 -0
- package/dist/esm/internal/matchSql.js +176 -0
- package/dist/esm/internal/matchSql.js.map +1 -0
- package/dist/esm/internal/readPlan.js +59 -0
- package/dist/esm/internal/readPlan.js.map +1 -0
- package/dist/esm/internal/subscribe.js +113 -0
- package/dist/esm/internal/subscribe.js.map +1 -0
- package/dist/esm/internal/transport.js +56 -0
- package/dist/esm/internal/transport.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/store.js +249 -0
- package/dist/esm/store.js.map +1 -0
- package/package.json +35 -0
- package/src/clients.ts +69 -0
- package/src/config.ts +92 -0
- package/src/ensureSchema.ts +46 -0
- package/src/index.ts +45 -0
- package/src/internal/ddl.ts +599 -0
- package/src/internal/matchSql.ts +219 -0
- package/src/internal/readPlan.ts +117 -0
- package/src/internal/transport.ts +141 -0
- 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))
|