@rotorsoft/act-pg 0.18.5 → 0.19.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/dist/index.js DELETED
@@ -1,650 +0,0 @@
1
- // src/PostgresStore.ts
2
- import {
3
- ConcurrencyError,
4
- log,
5
- SNAP_EVENT,
6
- TOMBSTONE_EVENT
7
- } from "@rotorsoft/act";
8
- import pg from "pg";
9
-
10
- // src/utils.ts
11
- var ISO_8601 = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])(\.\d+)?(Z|[+-][0-2][0-9]:[0-5][0-9])?$/;
12
- var dateReviver = (_key, value) => {
13
- if (typeof value === "string" && ISO_8601.test(value)) {
14
- return new Date(value);
15
- }
16
- return value;
17
- };
18
-
19
- // src/PostgresStore.ts
20
- var logger = log();
21
- var { Pool, types } = pg;
22
- types.setTypeParser(
23
- types.builtins.JSONB,
24
- (val) => JSON.parse(val, dateReviver)
25
- );
26
- var SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
27
- var PG_UNIQUE_VIOLATION = "23505";
28
- function assertSafeIdentifier(value, label) {
29
- if (!SAFE_IDENTIFIER.test(value))
30
- throw new Error(`Unsafe SQL identifier for ${label}: "${value}"`);
31
- }
32
- var DEFAULT_CONFIG = {
33
- host: "localhost",
34
- port: 5432,
35
- database: "postgres",
36
- user: "postgres",
37
- password: "postgres",
38
- schema: "public",
39
- table: "events"
40
- };
41
- var PostgresStore = class {
42
- _pool;
43
- config;
44
- _fqt;
45
- _fqs;
46
- /**
47
- * Create a new PostgresStore instance.
48
- * @param config Partial configuration (host, port, user, password, schema, table, etc.)
49
- */
50
- constructor(config = {}) {
51
- this.config = { ...DEFAULT_CONFIG, ...config };
52
- assertSafeIdentifier(this.config.schema, "schema");
53
- assertSafeIdentifier(this.config.table, "table");
54
- const { schema: _, table: __, ...poolConfig } = this.config;
55
- this._pool = new Pool(poolConfig);
56
- this._fqt = `"${this.config.schema}"."${this.config.table}"`;
57
- this._fqs = `"${this.config.schema}"."${this.config.table}_streams"`;
58
- }
59
- /**
60
- * Dispose of the store and close all database connections.
61
- * @returns Promise that resolves when all connections are closed
62
- */
63
- async dispose() {
64
- await this._pool.end();
65
- }
66
- /**
67
- * Seed the database with required tables, indexes, and schema for event storage.
68
- * @returns Promise that resolves when seeding is complete
69
- * @throws Error if seeding fails
70
- */
71
- async seed() {
72
- const client = await this._pool.connect();
73
- try {
74
- await client.query("BEGIN");
75
- await client.query(
76
- `CREATE SCHEMA IF NOT EXISTS "${this.config.schema}";`
77
- );
78
- await client.query(
79
- `CREATE TABLE IF NOT EXISTS ${this._fqt} (
80
- id serial PRIMARY KEY,
81
- name varchar(100) COLLATE pg_catalog."default" NOT NULL,
82
- data jsonb,
83
- stream varchar(100) COLLATE pg_catalog."default" NOT NULL,
84
- version int NOT NULL,
85
- created timestamptz NOT NULL DEFAULT now(),
86
- meta jsonb
87
- ) TABLESPACE pg_default;`
88
- );
89
- await client.query(
90
- `CREATE UNIQUE INDEX IF NOT EXISTS "${this.config.table}_stream_ix"
91
- ON ${this._fqt} (stream COLLATE pg_catalog."default", version);`
92
- );
93
- await client.query(
94
- `CREATE INDEX IF NOT EXISTS "${this.config.table}_name_ix"
95
- ON ${this._fqt} (name COLLATE pg_catalog."default");`
96
- );
97
- await client.query(
98
- `CREATE INDEX IF NOT EXISTS "${this.config.table}_created_id_ix"
99
- ON ${this._fqt} (created, id);`
100
- );
101
- await client.query(
102
- `CREATE INDEX IF NOT EXISTS "${this.config.table}_correlation_ix"
103
- ON ${this._fqt} ((meta ->> 'correlation') COLLATE pg_catalog."default");`
104
- );
105
- await client.query(
106
- `CREATE TABLE IF NOT EXISTS ${this._fqs} (
107
- stream varchar(100) COLLATE pg_catalog."default" PRIMARY KEY,
108
- source varchar(100) COLLATE pg_catalog."default",
109
- at int NOT NULL DEFAULT -1,
110
- retry smallint NOT NULL DEFAULT 0,
111
- blocked boolean NOT NULL DEFAULT false,
112
- error text,
113
- leased_by text,
114
- leased_until timestamptz
115
- ) TABLESPACE pg_default;`
116
- );
117
- await client.query(
118
- `CREATE INDEX IF NOT EXISTS "${this.config.table}_streams_fetch_ix"
119
- ON ${this._fqs} (blocked, at);`
120
- );
121
- await client.query("COMMIT");
122
- logger.info(
123
- `Seeded schema "${this.config.schema}" with table "${this.config.table}"`
124
- );
125
- } catch (error) {
126
- await client.query("ROLLBACK");
127
- logger.error(error);
128
- throw error;
129
- } finally {
130
- client.release();
131
- }
132
- }
133
- /**
134
- * Drop all tables and schema created by the store (for testing or cleanup).
135
- * @returns Promise that resolves when the schema is dropped
136
- */
137
- async drop() {
138
- await this._pool.query(
139
- `
140
- DO $$
141
- BEGIN
142
- IF EXISTS (SELECT 1 FROM information_schema.schemata
143
- WHERE schema_name = '${this.config.schema}'
144
- ) THEN
145
- EXECUTE 'DROP TABLE IF EXISTS ${this._fqt}';
146
- EXECUTE 'DROP TABLE IF EXISTS ${this._fqs}';
147
- IF '${this.config.schema}' <> 'public' THEN
148
- EXECUTE 'DROP SCHEMA "${this.config.schema}" CASCADE';
149
- END IF;
150
- END IF;
151
- END
152
- $$;
153
- `
154
- );
155
- }
156
- /**
157
- * Query events from the store, optionally filtered by stream, event name, time, etc.
158
- *
159
- * @param callback Function called for each event found
160
- * @param query (Optional) Query filter (stream, names, before, after, etc.)
161
- * @returns The number of events found
162
- *
163
- * @example
164
- * await store.query((event) => console.log(event), { stream: "A" });
165
- */
166
- async query(callback, query) {
167
- const {
168
- stream,
169
- names,
170
- before,
171
- after,
172
- limit,
173
- created_before,
174
- created_after,
175
- backward,
176
- correlation,
177
- with_snaps = false
178
- } = query || {};
179
- let sql = `SELECT * FROM ${this._fqt}`;
180
- const conditions = [];
181
- const values = [];
182
- if (query) {
183
- if (typeof after !== "undefined") {
184
- values.push(after);
185
- conditions.push(`id>$${values.length}`);
186
- } else {
187
- conditions.push("id>-1");
188
- }
189
- if (stream) {
190
- values.push(stream);
191
- conditions.push(
192
- query.stream_exact ? `stream = $${values.length}` : `stream ~ $${values.length}`
193
- );
194
- }
195
- if (names?.length) {
196
- values.push(names);
197
- conditions.push(`name = ANY($${values.length})`);
198
- }
199
- if (before) {
200
- values.push(before);
201
- conditions.push(`id<$${values.length}`);
202
- }
203
- if (created_after) {
204
- values.push(created_after.toISOString());
205
- conditions.push(`created>$${values.length}`);
206
- }
207
- if (created_before) {
208
- values.push(created_before.toISOString());
209
- conditions.push(`created<$${values.length}`);
210
- }
211
- if (correlation) {
212
- values.push(correlation);
213
- conditions.push(`meta->>'correlation'=$${values.length}`);
214
- }
215
- if (!with_snaps) {
216
- conditions.push(`name <> '${SNAP_EVENT}'`);
217
- }
218
- }
219
- if (conditions.length) {
220
- sql += " WHERE " + conditions.join(" AND ");
221
- }
222
- sql += ` ORDER BY id ${backward ? "DESC" : "ASC"}`;
223
- if (limit) {
224
- values.push(limit);
225
- sql += ` LIMIT $${values.length}`;
226
- }
227
- const result = await this._pool.query(sql, values);
228
- for (const row of result.rows) callback(row);
229
- return result.rowCount ?? 0;
230
- }
231
- /**
232
- * Commit new events to the store for a given stream, with concurrency control.
233
- *
234
- * @param stream The stream name
235
- * @param msgs Array of messages (event name and data)
236
- * @param meta Event metadata (correlation, causation, etc.)
237
- * @param expectedVersion (Optional) Expected stream version for concurrency control
238
- * @returns Array of committed events
239
- * @throws ConcurrencyError if the expected version does not match
240
- */
241
- async commit(stream, msgs, meta, expectedVersion) {
242
- if (msgs.length === 0) return [];
243
- const client = await this._pool.connect();
244
- let version = -1;
245
- try {
246
- await client.query("BEGIN");
247
- const last = await client.query(
248
- `SELECT version
249
- FROM ${this._fqt}
250
- WHERE stream=$1 ORDER BY version DESC LIMIT 1`,
251
- [stream]
252
- );
253
- version = last.rowCount ? last.rows[0].version : -1;
254
- if (typeof expectedVersion === "number" && version !== expectedVersion)
255
- throw new ConcurrencyError(
256
- stream,
257
- version,
258
- msgs,
259
- expectedVersion
260
- );
261
- const committed = [];
262
- for (const { name, data } of msgs) {
263
- version++;
264
- const sql = `
265
- INSERT INTO ${this._fqt}(name, data, stream, version, meta)
266
- VALUES($1, $2, $3, $4, $5) RETURNING *`;
267
- const vals = [name, data, stream, version, meta];
268
- try {
269
- const { rows } = await client.query(sql, vals);
270
- committed.push(rows.at(0));
271
- } catch (error) {
272
- if (error?.code === PG_UNIQUE_VIOLATION) {
273
- throw new ConcurrencyError(
274
- stream,
275
- version - 1,
276
- msgs,
277
- expectedVersion ?? -1
278
- );
279
- }
280
- throw error;
281
- }
282
- }
283
- await client.query(
284
- `
285
- NOTIFY "${this.config.table}", '${JSON.stringify({
286
- operation: "INSERT",
287
- id: committed[0].name,
288
- position: committed[0].id
289
- })}';
290
- COMMIT;
291
- `
292
- ).catch((error) => {
293
- logger.error(error);
294
- throw new ConcurrencyError(
295
- stream,
296
- version,
297
- msgs,
298
- expectedVersion || -1
299
- );
300
- });
301
- return committed;
302
- } catch (error) {
303
- await client.query("ROLLBACK").catch(() => {
304
- });
305
- throw error;
306
- } finally {
307
- client.release();
308
- }
309
- }
310
- /**
311
- * Atomically discovers and leases streams for reaction processing.
312
- *
313
- * Uses `FOR UPDATE SKIP LOCKED` to implement zero-contention competing consumers:
314
- * - Workers never block each other — locked rows are silently skipped
315
- * - Discovery and locking happen in a single atomic transaction
316
- * - No wasted polls — every returned stream is exclusively owned
317
- *
318
- * @param lagging - Max streams from lagging frontier (ascending watermark)
319
- * @param leading - Max streams from leading frontier (descending watermark)
320
- * @param by - Lease holder identifier (UUID)
321
- * @param millis - Lease duration in milliseconds
322
- * @returns Leased streams with metadata
323
- */
324
- async claim(lagging, leading, by, millis) {
325
- const client = await this._pool.connect();
326
- try {
327
- await client.query("BEGIN");
328
- const { rows } = await client.query(
329
- `
330
- WITH
331
- available AS (
332
- SELECT stream, source, at
333
- FROM ${this._fqs} s
334
- WHERE blocked = false
335
- AND (leased_by IS NULL OR leased_until <= NOW())
336
- AND (s.at < 0 OR EXISTS (
337
- SELECT 1 FROM ${this._fqt} e
338
- WHERE e.id > s.at
339
- AND e.name <> '${SNAP_EVENT}'
340
- AND (s.source IS NULL OR e.stream = COALESCE(s.source, s.stream))
341
- LIMIT 1
342
- ))
343
- FOR UPDATE SKIP LOCKED
344
- ),
345
- lag AS (
346
- SELECT stream, source, at, TRUE AS lagging
347
- FROM available
348
- ORDER BY at ASC
349
- LIMIT $1
350
- ),
351
- lead AS (
352
- SELECT stream, source, at, FALSE AS lagging
353
- FROM available
354
- ORDER BY at DESC
355
- LIMIT $2
356
- ),
357
- combined AS (
358
- SELECT DISTINCT ON (stream) stream, source, at, lagging
359
- FROM (SELECT * FROM lag UNION ALL SELECT * FROM lead) t
360
- ORDER BY stream, at
361
- )
362
- UPDATE ${this._fqs} s
363
- SET
364
- leased_by = $3,
365
- leased_until = NOW() + ($4::integer || ' milliseconds')::interval,
366
- retry = s.retry + 1
367
- FROM combined c
368
- WHERE s.stream = c.stream
369
- RETURNING s.stream, s.source, s.at, s.retry, c.lagging
370
- `,
371
- [lagging, leading, by, millis]
372
- );
373
- await client.query("COMMIT");
374
- return rows.map(({ stream, source, at, retry, lagging: lagging2 }) => ({
375
- stream,
376
- source: source ?? void 0,
377
- at,
378
- by,
379
- retry,
380
- lagging: lagging2
381
- }));
382
- } catch (error) {
383
- await client.query("ROLLBACK").catch(() => {
384
- });
385
- logger.error(error);
386
- return [];
387
- } finally {
388
- client.release();
389
- }
390
- }
391
- /**
392
- * Registers streams for event processing.
393
- * Upserts stream entries so they become visible to claim().
394
- * Also returns the current max watermark across all subscriptions.
395
- * @param streams - Streams to register with optional source.
396
- * @returns subscribed count and current max watermark.
397
- */
398
- async subscribe(streams) {
399
- const client = await this._pool.connect();
400
- try {
401
- await client.query("BEGIN");
402
- let subscribed = 0;
403
- if (streams.length) {
404
- const { rowCount } = await client.query(
405
- `
406
- INSERT INTO ${this._fqs} (stream, source)
407
- SELECT s->>'stream', s->>'source'
408
- FROM jsonb_array_elements($1::jsonb) AS s
409
- ON CONFLICT (stream) DO NOTHING
410
- `,
411
- [JSON.stringify(streams)]
412
- );
413
- subscribed = rowCount ?? 0;
414
- }
415
- const { rows } = await client.query(
416
- `SELECT COALESCE(MAX(at), -1) AS max FROM ${this._fqs}`
417
- );
418
- await client.query("COMMIT");
419
- return { subscribed, watermark: rows[0]?.max ?? -1 };
420
- } catch (error) {
421
- await client.query("ROLLBACK").catch(() => {
422
- });
423
- logger.error(error);
424
- return { subscribed: 0, watermark: -1 };
425
- } finally {
426
- client.release();
427
- }
428
- }
429
- /**
430
- * Acknowledge and release leases after processing, updating stream positions.
431
- *
432
- * @param leases - Leases to acknowledge, including last processed watermark and lease holder.
433
- * @returns Acked leases.
434
- */
435
- async ack(leases) {
436
- const client = await this._pool.connect();
437
- try {
438
- await client.query("BEGIN");
439
- const { rows } = await client.query(
440
- `
441
- WITH input AS (
442
- SELECT * FROM jsonb_to_recordset($1::jsonb)
443
- AS x(stream text, by text, at int, lagging boolean)
444
- )
445
- UPDATE ${this._fqs} AS s
446
- SET
447
- at = i.at,
448
- retry = -1,
449
- leased_by = NULL,
450
- leased_until = NULL
451
- FROM input i
452
- WHERE s.stream = i.stream AND s.leased_by = i.by
453
- RETURNING s.stream, s.source, s.at, i.by, s.retry, i.lagging
454
- `,
455
- [JSON.stringify(leases)]
456
- );
457
- await client.query("COMMIT");
458
- return rows.map((row) => ({
459
- stream: row.stream,
460
- source: row.source ?? void 0,
461
- at: row.at,
462
- by: row.by,
463
- retry: row.retry,
464
- lagging: row.lagging
465
- }));
466
- } catch (error) {
467
- await client.query("ROLLBACK").catch(() => {
468
- });
469
- logger.error(error);
470
- return [];
471
- } finally {
472
- client.release();
473
- }
474
- }
475
- /**
476
- * Block a stream for processing after failing to process and reaching max retries with blocking enabled.
477
- * @param leases - Leases to block, including lease holder and last error message.
478
- * @returns Blocked leases.
479
- */
480
- async block(leases) {
481
- const client = await this._pool.connect();
482
- try {
483
- await client.query("BEGIN");
484
- const { rows } = await client.query(
485
- `
486
- WITH input AS (
487
- SELECT * FROM jsonb_to_recordset($1::jsonb)
488
- AS x(stream text, by text, error text, lagging boolean)
489
- )
490
- UPDATE ${this._fqs} AS s
491
- SET blocked = true, error = i.error
492
- FROM input i
493
- WHERE s.stream = i.stream AND s.leased_by = i.by AND s.blocked = false
494
- RETURNING s.stream, s.source, s.at, i.by, s.retry, s.error, i.lagging
495
- `,
496
- [JSON.stringify(leases)]
497
- );
498
- await client.query("COMMIT");
499
- return rows.map((row) => ({
500
- stream: row.stream,
501
- source: row.source ?? void 0,
502
- at: row.at,
503
- by: row.by,
504
- retry: row.retry,
505
- lagging: row.lagging,
506
- error: row.error
507
- }));
508
- } catch (error) {
509
- await client.query("ROLLBACK").catch(() => {
510
- });
511
- logger.error(error);
512
- return [];
513
- } finally {
514
- client.release();
515
- }
516
- }
517
- /**
518
- * Reset watermarks for the given streams to -1, clearing retry, blocked,
519
- * error, and lease state so they can be replayed from the beginning.
520
- * @param streams - Stream names to reset.
521
- * @returns Count of streams that were actually reset.
522
- */
523
- async reset(streams) {
524
- if (!streams.length) return 0;
525
- const { rowCount } = await this._pool.query(
526
- `UPDATE ${this._fqs}
527
- SET at = -1, retry = 0, blocked = false, error = NULL,
528
- leased_by = NULL, leased_until = NULL
529
- WHERE stream = ANY($1)`,
530
- [streams]
531
- );
532
- return rowCount ?? 0;
533
- }
534
- /**
535
- * Streams subscription positions to a callback, ordered by stream name,
536
- * along with the highest event id in the store.
537
- *
538
- * Filters (`stream`, `source`, `blocked`, `after`, `limit`) are applied
539
- * server-side. `stream`/`source` are regex by default (`~`), or exact
540
- * with `*_exact: true` — same convention as {@link Store.query}.
541
- *
542
- * @returns `maxEventId` and the `count` of positions emitted.
543
- */
544
- async query_streams(callback, query) {
545
- const limit = query?.limit ?? 100;
546
- const conditions = [];
547
- const values = [];
548
- if (query?.stream !== void 0) {
549
- values.push(query.stream);
550
- conditions.push(
551
- query.stream_exact ? `stream = $${values.length}` : `stream ~ $${values.length}`
552
- );
553
- }
554
- if (query?.source !== void 0) {
555
- conditions.push(`source IS NOT NULL`);
556
- values.push(query.source);
557
- conditions.push(
558
- query.source_exact ? `source = $${values.length}` : `source ~ $${values.length}`
559
- );
560
- }
561
- if (query?.blocked !== void 0) {
562
- values.push(query.blocked);
563
- conditions.push(`blocked = $${values.length}`);
564
- }
565
- if (query?.after !== void 0) {
566
- values.push(query.after);
567
- conditions.push(`stream > $${values.length}`);
568
- }
569
- let sql = `SELECT stream, source, at, retry, blocked, error, leased_by, leased_until FROM ${this._fqs}`;
570
- if (conditions.length) sql += " WHERE " + conditions.join(" AND ");
571
- values.push(limit);
572
- sql += ` ORDER BY stream LIMIT $${values.length}`;
573
- const client = await this._pool.connect();
574
- try {
575
- const [streamsResult, maxResult] = await Promise.all([
576
- client.query(sql, values),
577
- client.query(
578
- `SELECT COALESCE(MAX(id), -1) AS m FROM ${this._fqt}`
579
- )
580
- ]);
581
- let count = 0;
582
- for (const row of streamsResult.rows) {
583
- callback({
584
- stream: row.stream,
585
- source: row.source ?? void 0,
586
- at: row.at,
587
- retry: row.retry,
588
- blocked: row.blocked,
589
- error: row.error ?? "",
590
- leased_by: row.leased_by ?? void 0,
591
- leased_until: row.leased_until ?? void 0
592
- });
593
- count++;
594
- }
595
- return { maxEventId: Number(maxResult.rows[0].m), count };
596
- } finally {
597
- client.release();
598
- }
599
- }
600
- /**
601
- * Atomically truncates streams and seeds each with a snapshot or tombstone.
602
- * @param targets - Streams to truncate with optional snapshot state and meta.
603
- * @returns Map keyed by stream name, each entry with `deleted` count and `committed` event.
604
- */
605
- async truncate(targets) {
606
- if (!targets.length) return /* @__PURE__ */ new Map();
607
- const streams = targets.map((t) => t.stream);
608
- const client = await this._pool.connect();
609
- try {
610
- await client.query("BEGIN");
611
- await client.query(`DELETE FROM ${this._fqs} WHERE stream = ANY($1)`, [
612
- streams
613
- ]);
614
- const result = /* @__PURE__ */ new Map();
615
- for (const { stream, snapshot, meta } of targets) {
616
- const { rowCount } = await client.query(
617
- `DELETE FROM ${this._fqt} WHERE stream = $1`,
618
- [stream]
619
- );
620
- const name = snapshot !== void 0 ? SNAP_EVENT : TOMBSTONE_EVENT;
621
- const { rows } = await client.query(
622
- `INSERT INTO ${this._fqt}(name, data, stream, version, created, meta)
623
- VALUES($1, $2, $3, 0, now(), $4) RETURNING *`,
624
- [
625
- name,
626
- snapshot ?? {},
627
- stream,
628
- meta ?? { correlation: "", causation: {} }
629
- ]
630
- );
631
- result.set(stream, {
632
- deleted: rowCount ?? 0,
633
- committed: rows[0]
634
- });
635
- }
636
- await client.query("COMMIT");
637
- return result;
638
- } catch (error) {
639
- await client.query("ROLLBACK").catch(() => {
640
- });
641
- throw error;
642
- } finally {
643
- client.release();
644
- }
645
- }
646
- };
647
- export {
648
- PostgresStore
649
- };
650
- //# sourceMappingURL=index.js.map