@photon-grove/evt 0.1.0 → 0.1.2

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 (96) hide show
  1. package/README.md +85 -9
  2. package/dist/aggregate.d.ts +26 -0
  3. package/dist/aggregate.d.ts.map +1 -1
  4. package/dist/aggregate.js.map +1 -1
  5. package/dist/dynamodb/client.d.ts +83 -0
  6. package/dist/dynamodb/client.d.ts.map +1 -0
  7. package/dist/dynamodb/client.js +6 -0
  8. package/dist/dynamodb/client.js.map +1 -0
  9. package/dist/dynamodb/index.d.ts +6 -1
  10. package/dist/dynamodb/index.d.ts.map +1 -1
  11. package/dist/dynamodb/index.js +8 -3
  12. package/dist/dynamodb/index.js.map +1 -1
  13. package/dist/dynamodb/item.d.ts +2 -0
  14. package/dist/dynamodb/item.d.ts.map +1 -1
  15. package/dist/dynamodb/item.js +5 -4
  16. package/dist/dynamodb/item.js.map +1 -1
  17. package/dist/dynamodb/marker.d.ts +11 -0
  18. package/dist/dynamodb/marker.d.ts.map +1 -0
  19. package/dist/dynamodb/marker.js +56 -0
  20. package/dist/dynamodb/marker.js.map +1 -0
  21. package/dist/dynamodb/repository.d.ts +61 -0
  22. package/dist/dynamodb/repository.d.ts.map +1 -0
  23. package/dist/dynamodb/repository.js +748 -0
  24. package/dist/dynamodb/repository.js.map +1 -0
  25. package/dist/dynamodb/requests.d.ts +68 -0
  26. package/dist/dynamodb/requests.d.ts.map +1 -0
  27. package/dist/dynamodb/requests.js +194 -0
  28. package/dist/dynamodb/requests.js.map +1 -0
  29. package/dist/dynamodb/views.d.ts +18 -0
  30. package/dist/dynamodb/views.d.ts.map +1 -0
  31. package/dist/dynamodb/views.js +56 -0
  32. package/dist/dynamodb/views.js.map +1 -0
  33. package/dist/errors.d.ts +109 -2
  34. package/dist/errors.d.ts.map +1 -1
  35. package/dist/errors.js +118 -2
  36. package/dist/errors.js.map +1 -1
  37. package/dist/features.d.ts +55 -0
  38. package/dist/features.d.ts.map +1 -0
  39. package/dist/features.js +126 -0
  40. package/dist/features.js.map +1 -0
  41. package/dist/index.d.ts +4 -3
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +2 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/memory/index.d.ts +1 -1
  46. package/dist/memory/index.d.ts.map +1 -1
  47. package/dist/memory/index.js +1 -1
  48. package/dist/memory/index.js.map +1 -1
  49. package/dist/memory/repository.d.ts +38 -2
  50. package/dist/memory/repository.d.ts.map +1 -1
  51. package/dist/memory/repository.js +95 -6
  52. package/dist/memory/repository.js.map +1 -1
  53. package/dist/postgres/client.d.ts +32 -0
  54. package/dist/postgres/client.d.ts.map +1 -0
  55. package/dist/postgres/client.js +4 -0
  56. package/dist/postgres/client.js.map +1 -0
  57. package/dist/postgres/index.d.ts +4 -0
  58. package/dist/postgres/index.d.ts.map +1 -0
  59. package/dist/postgres/index.js +6 -0
  60. package/dist/postgres/index.js.map +1 -0
  61. package/dist/postgres/repository.d.ts +55 -0
  62. package/dist/postgres/repository.d.ts.map +1 -0
  63. package/dist/postgres/repository.js +527 -0
  64. package/dist/postgres/repository.js.map +1 -0
  65. package/dist/postgres/schema.d.ts +81 -0
  66. package/dist/postgres/schema.d.ts.map +1 -0
  67. package/dist/postgres/schema.js +233 -0
  68. package/dist/postgres/schema.js.map +1 -0
  69. package/dist/repository.d.ts +56 -7
  70. package/dist/repository.d.ts.map +1 -1
  71. package/dist/repository.js +24 -9
  72. package/dist/repository.js.map +1 -1
  73. package/dist/store.d.ts +7 -5
  74. package/dist/store.d.ts.map +1 -1
  75. package/dist/store.js +34 -9
  76. package/dist/store.js.map +1 -1
  77. package/package.json +16 -3
  78. package/src/aggregate.ts +28 -0
  79. package/src/dynamodb/client.ts +102 -0
  80. package/src/dynamodb/index.ts +27 -2
  81. package/src/dynamodb/item.ts +6 -4
  82. package/src/dynamodb/marker.ts +64 -0
  83. package/src/dynamodb/repository.ts +1053 -0
  84. package/src/dynamodb/requests.ts +276 -0
  85. package/src/dynamodb/views.ts +67 -0
  86. package/src/errors.ts +176 -2
  87. package/src/features.ts +175 -0
  88. package/src/index.ts +28 -1
  89. package/src/memory/index.ts +9 -1
  90. package/src/memory/repository.ts +156 -6
  91. package/src/postgres/client.ts +37 -0
  92. package/src/postgres/index.ts +23 -0
  93. package/src/postgres/repository.ts +773 -0
  94. package/src/postgres/schema.ts +286 -0
  95. package/src/repository.ts +86 -13
  96. package/src/store.ts +61 -11
@@ -0,0 +1,773 @@
1
+ // The PostgreSQL repository: events in one table keyed by (entity_id, sequence), snapshots in a
2
+ // second keyed by entity_id, and the engine rows (the feature marker, the rebuild fence, the
3
+ // stream heads) in tables derived from the events table's name (storage format, "PostgreSQL").
4
+ // Every commit is one READ COMMITTED transaction that runs the statements Go and Rust run, in
5
+ // their order: the entity lock, the feature-marker check under its shared lock, the rebuild-lock
6
+ // assertion under its own, the head statement, the event inserts, then the snapshot upsert.
7
+ //
8
+ // Each statement of a transaction needs the one before it, so they await in loops on purpose.
9
+ /* oxlint-disable no-await-in-loop */
10
+
11
+ import {
12
+ CompactionUncoveredError,
13
+ ConflictError,
14
+ EvtError,
15
+ HistoryDependentProjectionError,
16
+ RebuildInProgressError,
17
+ SequenceGapError,
18
+ StreamHeadDriftError,
19
+ StreamHeadsNotMigratedError,
20
+ TransientError,
21
+ } from '../errors.js'
22
+ import type {SerializedEvent, SerializedSnapshot} from '../event.js'
23
+ import {
24
+ type FeatureManifest,
25
+ checkSupported,
26
+ checkWritable,
27
+ declaresStreamHeads,
28
+ decodeManifest,
29
+ encodeManifest,
30
+ withStreamHeads,
31
+ } from '../features.js'
32
+ import {int64Argument} from '../fields.js'
33
+ import {decodeMetadata, emptyMetadata, encodeMetadata} from '../metadata.js'
34
+ import {
35
+ type CallOptions,
36
+ type Commit,
37
+ type Repository,
38
+ type StoreInitializer,
39
+ checkBudget,
40
+ checkNotReserved,
41
+ commitSizes,
42
+ validateCommit,
43
+ } from '../repository.js'
44
+ import type {PgClient, PgConnection, PgQueryable} from './client.js'
45
+ import {
46
+ ENGINE_LOCK_EXCLUSIVE,
47
+ ENGINE_LOCK_SHARED,
48
+ ENTITY_LOCK,
49
+ type PgTables,
50
+ SCHEMA_LOCK,
51
+ featuresLockKey,
52
+ featuresTable,
53
+ pgTables,
54
+ rebuildGenerationTable,
55
+ rebuildLockKey,
56
+ rebuildLockTable,
57
+ schemaDdl,
58
+ streamHeadsTable,
59
+ } from './schema.js'
60
+
61
+ /** The tables a {@link PgRepository} stores its event log in. */
62
+ export interface PgRepositoryOptions {
63
+ /** The events table, `evt_events` by default. The engine tables derive their names from it. */
64
+ eventsTable?: string
65
+ /** The snapshots table, `evt_snapshots` by default. */
66
+ snapshotsTable?: string
67
+ }
68
+
69
+ /** The backend name a projector group for the PostgreSQL repository would carry. */
70
+ export const POSTGRES_BACKEND = 'postgres'
71
+
72
+ // The event columns every read selects, each as text, so no driver parses a stored value.
73
+ const EVENT_COLUMNS =
74
+ 'event_id, sequence::text AS sequence, event_type, version::text AS version, entity_id, ' +
75
+ "entity_type, encode(payload, 'hex') AS payload, metadata::text AS metadata"
76
+
77
+ // SQLSTATEs for which PostgreSQL rolled the transaction back and a retry may not hit again: a
78
+ // serialization failure and a detected deadlock.
79
+ const TRANSIENT_STATES = new Set(['40001', '40P01'])
80
+
81
+ // One statement inside a transaction: it checks the signal first, so an abort rolls back.
82
+ type Statement = (text: string, values?: (string | null)[]) => Promise<Row[]>
83
+
84
+ type Row = Record<string, unknown>
85
+
86
+ /**
87
+ * A {@link Repository} on PostgreSQL, through any client shaped like `pg`'s `Pool`
88
+ * ({@link PgClient}).
89
+ *
90
+ * Run {@link PgRepository.ensureSchema} once before the first write. A writer initializes a fresh
91
+ * store itself on its first write, as Go's and Rust's do: the check is two `EXISTS` probes under
92
+ * the lock it takes anyway. {@link PgRepository.initialize} is for a deploy step that treats every
93
+ * backend alike.
94
+ *
95
+ * The repository validates the store's feature manifest at its first write and caches the version
96
+ * it validated. Every commit and snapshot write reads the manifest again under a shared lock that
97
+ * no declaration can take while the write is open, and validates it again when it changed.
98
+ */
99
+ export class PgRepository implements Repository, StoreInitializer {
100
+ readonly #client: PgClient
101
+ readonly #tables: PgTables
102
+
103
+ // The manifest version this writer validated, or null until it validates.
104
+ #validated: bigint | null = null
105
+ // Whether the rebuild-lock table exists, which a commit's assertion probes for until it does.
106
+ #rebuildReady = false
107
+
108
+ /**
109
+ * Takes the client and the table names, which must be plain identifiers that fit their limits
110
+ * ({@link pgTables}), or the constructor fails with `invalidArgument`.
111
+ */
112
+ constructor(client: PgClient, options: PgRepositoryOptions = {}) {
113
+ this.#client = client
114
+ this.#tables = pgTables(options.eventsTable, options.snapshotsTable)
115
+ }
116
+
117
+ /** The tables this repository reads and writes, folded to lower case. */
118
+ get tables(): PgTables {
119
+ return this.#tables
120
+ }
121
+
122
+ /**
123
+ * Creates the event log's tables if they don't exist, with the DDL Go and Rust run
124
+ * ({@link schemaDdl}), under the schema lock they take. Safe to call on every start.
125
+ */
126
+ async ensureSchema(options?: CallOptions): Promise<void> {
127
+ await this.#transaction(options, async (run) => {
128
+ await run(SCHEMA_LOCK, [this.#tables.events])
129
+ for (const statement of statements(schemaDdl(this.#tables))) {
130
+ await run(statement)
131
+ }
132
+ })
133
+ }
134
+
135
+ /**
136
+ * Declares stream-heads on a store whose events and snapshots tables are empty, under the
137
+ * manifest's exclusive lock, and resolves `true` when this call declared it. A store that
138
+ * already declares it is left alone. Run {@link PgRepository.ensureSchema} first.
139
+ */
140
+ async initialize(options?: CallOptions): Promise<boolean> {
141
+ try {
142
+ return await this.#transaction(options, async (run) => {
143
+ await run(ENGINE_LOCK_EXCLUSIVE, [featuresLockKey(this.#tables)])
144
+ const manifest = await this.#readManifest(run)
145
+ checkSupported(manifest)
146
+ if (declaresStreamHeads(manifest)) {
147
+ return false
148
+ }
149
+
150
+ const {events, snapshots} = this.#tables
151
+ const populated = await run(
152
+ `SELECT 1 WHERE EXISTS (SELECT 1 FROM ${events}) OR EXISTS (SELECT 1 FROM ${snapshots})`,
153
+ )
154
+ if (populated.length > 0) {
155
+ throw new StreamHeadsNotMigratedError()
156
+ }
157
+
158
+ await this.#writeManifest(run, manifest, withStreamHeads(manifest))
159
+
160
+ return true
161
+ })
162
+ } finally {
163
+ // A declaration moved the version: the next write reads it again.
164
+ this.#validated = null
165
+ }
166
+ }
167
+
168
+ async commit(commit: Commit, options?: CallOptions): Promise<void> {
169
+ options?.signal?.throwIfAborted()
170
+ validateCommit(commit)
171
+ checkNotReserved(commit.entityId)
172
+ if (commit.events.length === 0) {
173
+ return
174
+ }
175
+
176
+ for (const group of commit.projections ?? []) {
177
+ if (group.backend !== POSTGRES_BACKEND || group.operations.length > 0) {
178
+ throw new EvtError(
179
+ 'invalidCommit',
180
+ `invalid commit: a PostgreSQL repository can't write a ${group.backend} projector group`,
181
+ )
182
+ }
183
+ }
184
+
185
+ // Every backend budgets a commit the same way, so an oversized one fails before any request.
186
+ checkBudget(commitSizes(commit, () => undefined))
187
+ await this.#ensureStreamHeads(options)
188
+
189
+ const {entityId, events} = commit
190
+ const first = events[0]!.sequence
191
+ const last = events.at(-1)!.sequence
192
+ await this.#transaction(options, async (run) => {
193
+ await run(ENTITY_LOCK, [entityId])
194
+ await this.#checkManifest(run)
195
+ await this.#assertNoRebuildLock(run)
196
+
197
+ if (!(await this.#moveHead(run, entityId, events[0]!.entityType, first, last))) {
198
+ throw await this.#probe(run, entityId, first)
199
+ }
200
+
201
+ for (const event of events) {
202
+ if (!(await this.#insertEvent(run, event))) {
203
+ // The head statement passed and the row exists: an event sits above the head. The
204
+ // entity lock rules out a racing writer.
205
+ throw drift(entityId, first - 1n, await this.#derivedHead(run, entityId))
206
+ }
207
+ }
208
+
209
+ const snapshot = commit.snapshot
210
+ if (snapshot) {
211
+ const rows = await run(
212
+ `SELECT sequence::text AS sequence FROM ${this.#tables.snapshots} WHERE entity_id = $1`,
213
+ [entityId],
214
+ )
215
+ const stored = rows[0] === undefined ? null : BigInt(text(rows[0], 'sequence'))
216
+ if (stored === null && snapshot.sequence !== 1n) {
217
+ throw new ConflictError(
218
+ entityId,
219
+ `snapshot sequence ${snapshot.sequence} has no stored predecessor`,
220
+ )
221
+ }
222
+ if (stored !== null && snapshot.sequence !== stored + 1n) {
223
+ throw new ConflictError(
224
+ entityId,
225
+ `snapshot sequence moved: stored ${stored}, commit writes ${snapshot.sequence}`,
226
+ )
227
+ }
228
+
229
+ await this.#upsertSnapshot(run, {...snapshot, entityId, eventSequence: last})
230
+ }
231
+ })
232
+ }
233
+
234
+ async events(entityId: string, options?: CallOptions): Promise<SerializedEvent[]> {
235
+ options?.signal?.throwIfAborted()
236
+ const rows = await this.#read(
237
+ `SELECT ${EVENT_COLUMNS} FROM ${this.#tables.events} WHERE entity_id = $1 ` +
238
+ 'ORDER BY sequence ASC',
239
+ [entityId],
240
+ )
241
+
242
+ return rows.map(eventFromRow)
243
+ }
244
+
245
+ async eventsAfter(
246
+ entityId: string,
247
+ after: bigint,
248
+ options?: CallOptions,
249
+ ): Promise<SerializedEvent[]> {
250
+ options?.signal?.throwIfAborted()
251
+ int64Argument(after, 'after')
252
+ const rows = await this.#read(
253
+ `SELECT ${EVENT_COLUMNS} FROM ${this.#tables.events} ` +
254
+ 'WHERE entity_id = $1 AND sequence > $2::bigint ORDER BY sequence ASC',
255
+ [entityId, after.toString()],
256
+ )
257
+
258
+ return rows.map(eventFromRow)
259
+ }
260
+
261
+ async latest(entityId: string, n: number, options?: CallOptions): Promise<SerializedEvent[]> {
262
+ options?.signal?.throwIfAborted()
263
+ if (!Number.isSafeInteger(n) || n < 1) {
264
+ throw new EvtError(
265
+ 'invalidArgument',
266
+ `latest(n) requires a whole number of at least one event: got n = ${n}`,
267
+ )
268
+ }
269
+
270
+ const rows = await this.#read(
271
+ `SELECT ${EVENT_COLUMNS} FROM ${this.#tables.events} WHERE entity_id = $1 ` +
272
+ 'ORDER BY sequence DESC LIMIT $2::bigint',
273
+ [entityId, n.toString()],
274
+ )
275
+
276
+ return rows.map(eventFromRow)
277
+ }
278
+
279
+ async snapshot(entityId: string, options?: CallOptions): Promise<SerializedSnapshot | null> {
280
+ options?.signal?.throwIfAborted()
281
+ const rows = await this.#read(
282
+ 'SELECT entity_type, entity_id, sequence::text AS sequence, ' +
283
+ "event_sequence::text AS event_sequence, encode(payload, 'hex') AS payload " +
284
+ `FROM ${this.#tables.snapshots} WHERE entity_id = $1`,
285
+ [entityId],
286
+ )
287
+
288
+ return rows[0] === undefined ? null : snapshotFromRow(rows[0])
289
+ }
290
+
291
+ async putSnapshot(snapshot: SerializedSnapshot, options?: CallOptions): Promise<boolean> {
292
+ options?.signal?.throwIfAborted()
293
+ checkNotReserved(snapshot.entityId)
294
+ int64Argument(snapshot.sequence, 'snapshot.sequence')
295
+ const eventSequence = int64Argument(snapshot.eventSequence, 'snapshot.eventSequence')
296
+ await this.#ensureStreamHeads(options)
297
+
298
+ return this.#transaction(options, async (run) => {
299
+ const {entityId} = snapshot
300
+ await run(ENTITY_LOCK, [entityId])
301
+ await this.#checkManifest(run)
302
+
303
+ // The head must cover the snapshot. A snapshot of an empty stream needs no head. The entity
304
+ // lock holds the head steady.
305
+ const head = await this.#storedHead(run, entityId)
306
+ if (eventSequence > 0n && head < eventSequence) {
307
+ const derived = await this.#derivedHead(run, entityId)
308
+ throw drift(entityId, head, derived > eventSequence ? derived : eventSequence)
309
+ }
310
+
311
+ return this.#upsertSnapshot(run, snapshot)
312
+ })
313
+ }
314
+
315
+ async compactBelow(entityId: string, through: bigint, options?: CallOptions): Promise<number> {
316
+ options?.signal?.throwIfAborted()
317
+ checkNotReserved(entityId)
318
+ if (int64Argument(through, 'through') < 1n) {
319
+ return 0
320
+ }
321
+
322
+ // One transaction under the manifest's shared lock, held through the delete, so no
323
+ // declaration lands between the check and the delete it authorizes. It never declares
324
+ // stream-heads, and never touches the head.
325
+ return this.#transaction(options, async (run) => {
326
+ await run(ENGINE_LOCK_SHARED, [featuresLockKey(this.#tables)])
327
+ const manifest = await this.#readManifest(run)
328
+ checkWritable(manifest)
329
+
330
+ const rows = await run(
331
+ 'SELECT entity_type, event_sequence::text AS event_sequence ' +
332
+ `FROM ${this.#tables.snapshots} WHERE entity_id = $1`,
333
+ [entityId],
334
+ )
335
+ const covered = rows[0] === undefined ? null : BigInt(text(rows[0], 'event_sequence'))
336
+ if (covered === null || covered < through) {
337
+ throw new CompactionUncoveredError(entityId, through, covered)
338
+ }
339
+
340
+ const entityType = text(rows[0]!, 'entity_type')
341
+ if (manifest!.historyDependent.includes(entityType)) {
342
+ throw new HistoryDependentProjectionError(entityType, entityId)
343
+ }
344
+
345
+ const deleted = await run(
346
+ `WITH deleted AS (DELETE FROM ${this.#tables.events} ` +
347
+ 'WHERE entity_id = $1 AND sequence >= 1 AND sequence <= $2::bigint RETURNING 1) ' +
348
+ 'SELECT count(*)::text AS deleted FROM deleted',
349
+ [entityId, through.toString()],
350
+ )
351
+
352
+ return Number(text(deleted[0]!, 'deleted'))
353
+ })
354
+ }
355
+
356
+ // Makes sure the store is ready for this writer's first write: it declares stream-heads. A
357
+ // writer initializes a store whose manifest lacks it, which refuses one that holds data. It runs
358
+ // before the write's transaction, which holds the shared lock a declaration would wait for.
359
+ async #ensureStreamHeads(options: CallOptions | undefined): Promise<void> {
360
+ if (this.#validated !== null) {
361
+ return
362
+ }
363
+
364
+ const manifest = await this.#readManifest((statement, values) =>
365
+ this.#read(statement, values),
366
+ )
367
+ checkSupported(manifest)
368
+ if (!declaresStreamHeads(manifest)) {
369
+ await this.initialize(options)
370
+ }
371
+ }
372
+
373
+ // The commit-time marker check, under the manifest's shared lock held to COMMIT, so a
374
+ // declaration can't slip between the check and the write. A manifest that changed since this
375
+ // writer validated it is validated again.
376
+ async #checkManifest(run: Statement): Promise<void> {
377
+ await run(ENGINE_LOCK_SHARED, [featuresLockKey(this.#tables)])
378
+ const manifest = await this.#readManifest(run)
379
+ if (manifest !== null && manifest.version === this.#validated) {
380
+ return
381
+ }
382
+
383
+ this.#validated = null
384
+ checkWritable(manifest)
385
+ this.#validated = manifest!.version
386
+ }
387
+
388
+ // The commit-time rebuild-lock assertion, under the rebuild fence's shared lock held to COMMIT:
389
+ // a lock row, an expired one included, or an odd generation refuses the commit. A schema older
390
+ // than the fence has no lock table, and nobody can have taken the lock.
391
+ async #assertNoRebuildLock(run: Statement): Promise<void> {
392
+ await run(ENGINE_LOCK_SHARED, [rebuildLockKey(this.#tables)])
393
+ const lock = rebuildLockTable(this.#tables)
394
+ if (!this.#rebuildReady) {
395
+ if ((await run('SELECT 1 WHERE to_regclass($1) IS NOT NULL', [lock])).length === 0) {
396
+ return
397
+ }
398
+
399
+ this.#rebuildReady = true
400
+ }
401
+
402
+ const generation = rebuildGenerationTable(this.#tables)
403
+ const held = await run(
404
+ `SELECT 1 WHERE EXISTS (SELECT 1 FROM ${lock} WHERE id = 0) ` +
405
+ `OR EXISTS (SELECT 1 FROM ${generation} WHERE id = 0 AND value % 2 <> 0)`,
406
+ )
407
+ if (held.length > 0) {
408
+ throw new RebuildInProgressError()
409
+ }
410
+ }
411
+
412
+ // The stream's head statement: a create inserts its head row, an append moves it from the
413
+ // sequence before the commit's first. It passes when it wrote one row.
414
+ async #moveHead(
415
+ run: Statement,
416
+ entityId: string,
417
+ entityType: string,
418
+ first: bigint,
419
+ last: bigint,
420
+ ): Promise<boolean> {
421
+ const heads = streamHeadsTable(this.#tables)
422
+ const rows =
423
+ first <= 1n
424
+ ? await run(
425
+ `INSERT INTO ${heads} (entity_id, entity_type, head) VALUES ($1, $2, $3::bigint) ` +
426
+ 'ON CONFLICT (entity_id) DO NOTHING RETURNING 1',
427
+ [entityId, entityType, last.toString()],
428
+ )
429
+ : await run(
430
+ `UPDATE ${heads} SET head = $3::bigint WHERE entity_id = $1 AND head = $2::bigint ` +
431
+ 'RETURNING 1',
432
+ [entityId, (first - 1n).toString(), last.toString()],
433
+ )
434
+
435
+ return rows.length === 1
436
+ }
437
+
438
+ // Classifies a failed head statement under the stream's lock, as Go and Rust do: a stored head
439
+ // below the head the log derives is drift, an equal one leaves a gap or a conflict, and one
440
+ // above it is drift unless deriving again catches up with it.
441
+ async #probe(run: Statement, entityId: string, first: bigint): Promise<EvtError> {
442
+ const derived = await this.#derivedHead(run, entityId)
443
+ const stored = await this.#storedHead(run, entityId)
444
+ if (stored < derived) {
445
+ return drift(entityId, stored, derived)
446
+ }
447
+ if (stored > derived) {
448
+ const again = await this.#derivedHead(run, entityId)
449
+ return again < stored ? drift(entityId, stored, again) : conflict(entityId)
450
+ }
451
+ if (stored < first - 1n) {
452
+ return new SequenceGapError(entityId, stored, first)
453
+ }
454
+
455
+ return conflict(entityId)
456
+ }
457
+
458
+ // The stream's stored head, 0 when it has no head row.
459
+ async #storedHead(run: Statement, entityId: string): Promise<bigint> {
460
+ const rows = await run(
461
+ `SELECT head::text AS head FROM ${streamHeadsTable(this.#tables)} WHERE entity_id = $1`,
462
+ [entityId],
463
+ )
464
+
465
+ return rows[0] === undefined ? 0n : BigInt(text(rows[0], 'head'))
466
+ }
467
+
468
+ // The head the stream's log derives: the higher of its last event and its snapshot's event
469
+ // sequence.
470
+ async #derivedHead(run: Statement, entityId: string): Promise<bigint> {
471
+ const {events, snapshots} = this.#tables
472
+ const rows = await run(
473
+ 'SELECT GREATEST(' +
474
+ `COALESCE((SELECT MAX(sequence) FROM ${events} WHERE entity_id = $1), 0), ` +
475
+ `COALESCE((SELECT MAX(event_sequence) FROM ${snapshots} WHERE entity_id = $1), 0)` +
476
+ ')::text AS head',
477
+ [entityId],
478
+ )
479
+
480
+ return BigInt(text(rows[0]!, 'head'))
481
+ }
482
+
483
+ // Inserts one event row, and reports whether it was written: false when its sequence exists.
484
+ async #insertEvent(run: Statement, event: SerializedEvent): Promise<boolean> {
485
+ const rows = await run(
486
+ `INSERT INTO ${this.#tables.events} ` +
487
+ '(entity_id, sequence, event_id, event_type, version, entity_type, payload, metadata) ' +
488
+ "VALUES ($1, $2::bigint, $3, $4, $5::bigint, $6, decode($7, 'hex'), $8::text::jsonb) " +
489
+ 'ON CONFLICT (entity_id, sequence) DO NOTHING RETURNING 1',
490
+ [
491
+ event.entityId,
492
+ event.sequence.toString(),
493
+ event.id,
494
+ event.type,
495
+ event.version.toString(),
496
+ event.entityType,
497
+ hex(event.payload),
498
+ encodeMetadata(event.metadata),
499
+ ],
500
+ )
501
+
502
+ return rows.length === 1
503
+ }
504
+
505
+ // Writes a snapshot unless a newer one is stored, and reports whether it wrote.
506
+ async #upsertSnapshot(run: Statement, snapshot: SerializedSnapshot): Promise<boolean> {
507
+ const table = this.#tables.snapshots
508
+ const rows = await run(
509
+ `INSERT INTO ${table} ` +
510
+ '(entity_id, entity_type, sequence, event_sequence, payload, updated_at) ' +
511
+ "VALUES ($1, $2, $3::bigint, $4::bigint, decode($5, 'hex'), now()) " +
512
+ 'ON CONFLICT (entity_id) DO UPDATE SET entity_type = EXCLUDED.entity_type, ' +
513
+ 'sequence = EXCLUDED.sequence, event_sequence = EXCLUDED.event_sequence, ' +
514
+ 'payload = EXCLUDED.payload, updated_at = now() ' +
515
+ `WHERE EXCLUDED.event_sequence >= ${table}.event_sequence RETURNING 1`,
516
+ [
517
+ snapshot.entityId,
518
+ snapshot.entityType,
519
+ snapshot.sequence.toString(),
520
+ snapshot.eventSequence.toString(),
521
+ hex(snapshot.payload),
522
+ ],
523
+ )
524
+
525
+ return rows.length === 1
526
+ }
527
+
528
+ // The manifest row, or null when the store has none. A store older than the marker has no
529
+ // marker table: the probe doesn't name it, since a failed statement would abort the transaction.
530
+ async #readManifest(run: Statement): Promise<FeatureManifest | null> {
531
+ const table = featuresTable(this.#tables)
532
+ if ((await run('SELECT 1 WHERE to_regclass($1) IS NOT NULL', [table])).length === 0) {
533
+ return null
534
+ }
535
+
536
+ const rows = await run(
537
+ 'SELECT version::text AS version, features, history_dependent, retention ' +
538
+ `FROM ${table} WHERE id = 0`,
539
+ )
540
+ const row = rows[0]
541
+ if (row === undefined) {
542
+ return null
543
+ }
544
+
545
+ return decodeManifest(BigInt(text(row, 'version')), {
546
+ features: text(row, 'features'),
547
+ historyDependent: text(row, 'history_dependent'),
548
+ retention: text(row, 'retention'),
549
+ })
550
+ }
551
+
552
+ // Writes the next manifest over the stored one, which the caller read under the exclusive lock.
553
+ async #writeManifest(
554
+ run: Statement,
555
+ stored: FeatureManifest | null,
556
+ next: FeatureManifest,
557
+ ): Promise<void> {
558
+ const table = featuresTable(this.#tables)
559
+ const texts = encodeManifest(next)
560
+ const {features, historyDependent, retention} = texts
561
+ const values = [next.version.toString(), features, historyDependent, retention]
562
+ const rows =
563
+ stored === null
564
+ ? await run(
565
+ `INSERT INTO ${table} (id, version, features, history_dependent, retention) ` +
566
+ 'VALUES (0, $1::bigint, $2, $3, $4) RETURNING 1',
567
+ values,
568
+ )
569
+ : await run(
570
+ `UPDATE ${table} SET version = $1::bigint, features = $2, history_dependent = $3, ` +
571
+ 'retention = $4 WHERE id = 0 AND version = $5::bigint RETURNING 1',
572
+ [...values, stored.version.toString()],
573
+ )
574
+ if (rows.length !== 1) {
575
+ // Impossible while the exclusive lock is held: refuse rather than guess.
576
+ throw new EvtError(
577
+ 'invalidRecord',
578
+ 'the feature manifest moved during its declaration despite the exclusive lock',
579
+ )
580
+ }
581
+ }
582
+
583
+ // One statement outside a transaction.
584
+ async #read(statement: string, values?: (string | null)[]): Promise<Row[]> {
585
+ const client = this.#client
586
+ if (client.connect !== undefined) {
587
+ return rowsOf(await client.query(statement, values))
588
+ }
589
+
590
+ return serialize(client, async () => rowsOf(await client.query(statement, values)))
591
+ }
592
+
593
+ // Runs `work` in one READ COMMITTED transaction on a connection of its own, and commits it. A
594
+ // failure, an abort included, rolls it back. A serialization failure or a deadlock is transient.
595
+ async #transaction<T>(
596
+ options: CallOptions | undefined,
597
+ work: (run: Statement) => Promise<T>,
598
+ ): Promise<T> {
599
+ const signal = options?.signal
600
+ signal?.throwIfAborted()
601
+
602
+ const client = this.#client
603
+ if (client.connect === undefined) {
604
+ return serialize(client, () => inTransaction(client, signal, work))
605
+ }
606
+
607
+ const connection = await client.connect()
608
+ if (typeof connection?.query !== 'function' || typeof connection.release !== 'function') {
609
+ if (typeof connection?.release === 'function') {
610
+ connection.release()
611
+ }
612
+ throw new EvtError(
613
+ 'invalidArgument',
614
+ "the PostgreSQL client's connect() lent no connection: pass a pool, such as pg's Pool",
615
+ )
616
+ }
617
+
618
+ let broken: Error | undefined
619
+ try {
620
+ return await inTransaction(connection, signal, work, (error) => {
621
+ broken = error
622
+ })
623
+ } finally {
624
+ connection.release(broken)
625
+ }
626
+ }
627
+
628
+ }
629
+
630
+ // The operations queued on each single-connection client, shared by every repository on it: two
631
+ // transactions on one session would share its locks, and one's ROLLBACK would undo the other.
632
+ const queues = new WeakMap<PgQueryable, Promise<unknown>>()
633
+
634
+ // Runs `task` on a single-connection client after every task queued on it before.
635
+ function serialize<T>(client: PgQueryable, task: () => Promise<T>): Promise<T> {
636
+ const result = (queues.get(client) ?? Promise.resolve()).then(task, task)
637
+ queues.set(client, result.catch(() => undefined))
638
+
639
+ return result
640
+ }
641
+
642
+ // One transaction on one connection. `broken` hears of a connection that failed to roll back.
643
+ async function inTransaction<T>(
644
+ connection: PgQueryable | PgConnection,
645
+ signal: AbortSignal | undefined,
646
+ work: (run: Statement) => Promise<T>,
647
+ broken?: (error: Error) => void,
648
+ ): Promise<T> {
649
+ const run: Statement = async (statement, values) => {
650
+ signal?.throwIfAborted()
651
+
652
+ return rowsOf(await connection.query(statement, values))
653
+ }
654
+
655
+ let result: T
656
+ try {
657
+ await connection.query('START TRANSACTION ISOLATION LEVEL READ COMMITTED')
658
+ result = await work(run)
659
+ signal?.throwIfAborted()
660
+ await connection.query('COMMIT')
661
+ } catch (error) {
662
+ try {
663
+ await connection.query('ROLLBACK')
664
+ } catch (rollback) {
665
+ broken?.(rollback instanceof Error ? rollback : new Error(String(rollback)))
666
+ }
667
+
668
+ throw transient(error)
669
+ }
670
+
671
+ // An abort while the COMMIT was in flight rejects too, though the commit may have applied, as
672
+ // CallOptions promises: there is nothing left to roll back.
673
+ signal?.throwIfAborted()
674
+
675
+ return result
676
+ }
677
+
678
+ // A driver error PostgreSQL raised for a rollback a retry may not hit again, as a TransientError.
679
+ function transient(error: unknown): unknown {
680
+ const code = typeof error === 'object' && error !== null ? (error as {code?: unknown}).code : null
681
+ if (typeof code === 'string' && TRANSIENT_STATES.has(code)) {
682
+ return new TransientError(`PostgreSQL rolled the transaction back (${code})`, {cause: error})
683
+ }
684
+
685
+ return error
686
+ }
687
+
688
+ // The statements of a DDL script, one at a time, since a driver's extended protocol runs one per
689
+ // call. Each ends at a semicolon that ends its line and comes before a blank line, a CREATE, a DO
690
+ // or the end, which no statement inside the DO block's body is followed by.
691
+ function statements(ddl: string): string[] {
692
+ return ddl
693
+ .split(/;\n(?=\n|CREATE |DO |$)/)
694
+ .map((statement) => statement.trim())
695
+ .filter((statement) => statement !== '')
696
+ }
697
+
698
+ function rowsOf(result: {rows?: readonly unknown[]} | undefined): Row[] {
699
+ const rows = result?.rows
700
+ if (!Array.isArray(rows)) {
701
+ throw new EvtError('invalidRecord', 'the PostgreSQL client returned a result without rows')
702
+ }
703
+
704
+ return rows as Row[]
705
+ }
706
+
707
+ // A text column of a row.
708
+ function text(row: Row, column: string): string {
709
+ const value = row[column]
710
+ if (typeof value !== 'string') {
711
+ throw new EvtError('invalidRecord', `the PostgreSQL row has no text column ${column}`)
712
+ }
713
+
714
+ return value
715
+ }
716
+
717
+ function eventFromRow(row: Row): SerializedEvent {
718
+ const metadata = text(row, 'metadata')
719
+
720
+ return {
721
+ id: text(row, 'event_id'),
722
+ sequence: BigInt(text(row, 'sequence')),
723
+ type: text(row, 'event_type'),
724
+ version: Number(text(row, 'version')),
725
+ entityId: text(row, 'entity_id'),
726
+ entityType: text(row, 'entity_type'),
727
+ payload: unhex(text(row, 'payload')),
728
+ // A row Go wrote with null metadata reads as the default, as Go and Rust read it.
729
+ metadata:
730
+ metadata.trim() === '' || metadata === 'null' ? emptyMetadata() : decodeMetadata(metadata),
731
+ }
732
+ }
733
+
734
+ function snapshotFromRow(row: Row): SerializedSnapshot {
735
+ return {
736
+ entityType: text(row, 'entity_type'),
737
+ entityId: text(row, 'entity_id'),
738
+ sequence: BigInt(text(row, 'sequence')),
739
+ eventSequence: BigInt(text(row, 'event_sequence')),
740
+ payload: unhex(text(row, 'payload')),
741
+ }
742
+ }
743
+
744
+ // Bytes as lowercase hex, which decode(…, 'hex') reads. A null payload is stored empty.
745
+ function hex(bytes: Uint8Array | null): string {
746
+ let out = ''
747
+ for (const byte of bytes ?? []) {
748
+ out += byte.toString(16).padStart(2, '0')
749
+ }
750
+
751
+ return out
752
+ }
753
+
754
+ function unhex(digits: string): Uint8Array {
755
+ const bytes = new Uint8Array(digits.length / 2)
756
+ for (let i = 0; i < bytes.length; i++) {
757
+ bytes[i] = Number.parseInt(digits.slice(2 * i, 2 * i + 2), 16)
758
+ }
759
+
760
+ return bytes
761
+ }
762
+
763
+ function conflict(entityId: string): ConflictError {
764
+ return new ConflictError(
765
+ entityId,
766
+ 'conditional check failed: the sequence or snapshot changed since it was read',
767
+ )
768
+ }
769
+
770
+ // A stored head of 0 is an absent head.
771
+ function drift(entityId: string, stored: bigint, derived: bigint): StreamHeadDriftError {
772
+ return new StreamHeadDriftError(entityId, stored > 0n ? stored : null, derived)
773
+ }