@rotorsoft/act-pg 0.20.1 → 0.21.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.cjs ADDED
@@ -0,0 +1,878 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ PostgresStore: () => PostgresStore
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+
37
+ // src/postgres-store.ts
38
+ var import_node_crypto = require("crypto");
39
+ var import_act = require("@rotorsoft/act");
40
+ var import_pg = __toESM(require("pg"), 1);
41
+
42
+ // src/utils.ts
43
+ 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])?$/;
44
+ var dateReviver = (_key, value) => {
45
+ if (typeof value === "string" && ISO_8601.test(value)) {
46
+ return new Date(value);
47
+ }
48
+ return value;
49
+ };
50
+
51
+ // src/postgres-store.ts
52
+ var logger = (0, import_act.log)();
53
+ var { Pool, types } = import_pg.default;
54
+ types.setTypeParser(
55
+ types.builtins.JSONB,
56
+ (val) => JSON.parse(val, dateReviver)
57
+ );
58
+ var SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
59
+ var PG_UNIQUE_VIOLATION = "23505";
60
+ var NOTIFY_CHANNEL_PREFIX = "act_commit";
61
+ function notifyChannel(schema, table) {
62
+ return `${NOTIFY_CHANNEL_PREFIX}_${schema}_${table}`;
63
+ }
64
+ function assertSafeIdentifier(value, label) {
65
+ if (!SAFE_IDENTIFIER.test(value))
66
+ throw new Error(`Unsafe SQL identifier for ${label}: "${value}"`);
67
+ }
68
+ var DEFAULT_CONFIG = {
69
+ host: "localhost",
70
+ port: 5432,
71
+ database: "postgres",
72
+ user: "postgres",
73
+ password: "postgres",
74
+ schema: "public",
75
+ table: "events",
76
+ notify: false
77
+ };
78
+ var PostgresStore = class {
79
+ _pool;
80
+ config;
81
+ _fqt;
82
+ _fqs;
83
+ /**
84
+ * Per-instance writer identifier embedded in every NOTIFY payload. The
85
+ * `notify()` LISTEN handler skips payloads where `by === this._by`,
86
+ * giving the `"notified"` lifecycle event a clean cross-process
87
+ * semantic — local commits never echo back through this channel.
88
+ */
89
+ _by = (0, import_node_crypto.randomUUID)();
90
+ /**
91
+ * Effective NOTIFY channel for this store. Computed from `(schema,
92
+ * table)` at construction so multiple stores in the same database
93
+ * stay isolated.
94
+ */
95
+ _channel;
96
+ /** Active LISTEN client (one per `notify()` subscription). */
97
+ _listenClient;
98
+ /**
99
+ * Notification listener attached to the active LISTEN client. Tracked
100
+ * separately so the re-subscribe / dispose paths can detach it before
101
+ * destroying the client — without this, a pool that reused the
102
+ * connection would re-fire the stale handler.
103
+ */
104
+ _listenHandler;
105
+ /**
106
+ * Cross-process commit subscription. **Present only when
107
+ * `config.notify === true`** — the orchestrator's auto-wire path
108
+ * checks `if (store.notify)`, so omitting the method keeps
109
+ * single-instance deployments free of any LISTEN/NOTIFY overhead
110
+ * (no dedicated client, no per-commit `pg_notify`).
111
+ *
112
+ * @see {@link Config.notify} for the rationale and the multi-process
113
+ * contract.
114
+ */
115
+ notify;
116
+ /**
117
+ * Create a new PostgresStore instance.
118
+ * @param config Partial configuration (host, port, user, password, schema, table, etc.)
119
+ */
120
+ constructor(config = {}) {
121
+ this.config = { ...DEFAULT_CONFIG, ...config };
122
+ assertSafeIdentifier(this.config.schema, "schema");
123
+ assertSafeIdentifier(this.config.table, "table");
124
+ const { schema: _, table: __, ...poolConfig } = this.config;
125
+ this._pool = new Pool(poolConfig);
126
+ this._fqt = `"${this.config.schema}"."${this.config.table}"`;
127
+ this._fqs = `"${this.config.schema}"."${this.config.table}_streams"`;
128
+ this._channel = notifyChannel(this.config.schema, this.config.table);
129
+ if (this.config.notify) {
130
+ this.notify = this._subscribeNotifications.bind(this);
131
+ }
132
+ }
133
+ /**
134
+ * Dispose of the store and close all database connections.
135
+ * Releases any active LISTEN client first so the pool can drain cleanly.
136
+ * @returns Promise that resolves when all connections are closed
137
+ */
138
+ async dispose() {
139
+ await this._teardownListen();
140
+ await this._pool.end();
141
+ }
142
+ /**
143
+ * Tear down the active LISTEN subscription if any: detach the
144
+ * notification listener, run UNLISTEN, and destroy the dedicated
145
+ * client (do not return it to the pool — its listener is removed but
146
+ * destroying belt-and-braces guards against any future change in
147
+ * pg-pool semantics that could re-issue a half-clean client).
148
+ */
149
+ async _teardownListen() {
150
+ if (!this._listenClient) return;
151
+ this._listenClient.removeListener("notification", this._listenHandler);
152
+ this._listenHandler = void 0;
153
+ try {
154
+ await this._listenClient.query(`UNLISTEN ${this._channel}`);
155
+ } catch {
156
+ }
157
+ this._listenClient.release(true);
158
+ this._listenClient = void 0;
159
+ }
160
+ /**
161
+ * Seed the database with required tables, indexes, and schema for event storage.
162
+ * @returns Promise that resolves when seeding is complete
163
+ * @throws Error if seeding fails
164
+ */
165
+ async seed() {
166
+ const client = await this._pool.connect();
167
+ try {
168
+ await client.query("BEGIN");
169
+ await client.query(
170
+ `CREATE SCHEMA IF NOT EXISTS "${this.config.schema}";`
171
+ );
172
+ await client.query(
173
+ `CREATE TABLE IF NOT EXISTS ${this._fqt} (
174
+ id serial PRIMARY KEY,
175
+ name varchar(100) COLLATE pg_catalog."default" NOT NULL,
176
+ data jsonb,
177
+ stream varchar(100) COLLATE pg_catalog."default" NOT NULL,
178
+ version int NOT NULL,
179
+ created timestamptz NOT NULL DEFAULT now(),
180
+ meta jsonb
181
+ ) TABLESPACE pg_default;`
182
+ );
183
+ await client.query(
184
+ `CREATE UNIQUE INDEX IF NOT EXISTS "${this.config.table}_stream_ix"
185
+ ON ${this._fqt} (stream COLLATE pg_catalog."default", version);`
186
+ );
187
+ await client.query(
188
+ `CREATE INDEX IF NOT EXISTS "${this.config.table}_name_ix"
189
+ ON ${this._fqt} (name COLLATE pg_catalog."default");`
190
+ );
191
+ await client.query(
192
+ `CREATE INDEX IF NOT EXISTS "${this.config.table}_created_id_ix"
193
+ ON ${this._fqt} (created, id);`
194
+ );
195
+ await client.query(
196
+ `CREATE INDEX IF NOT EXISTS "${this.config.table}_correlation_ix"
197
+ ON ${this._fqt} ((meta ->> 'correlation') COLLATE pg_catalog."default");`
198
+ );
199
+ await client.query(
200
+ `CREATE TABLE IF NOT EXISTS ${this._fqs} (
201
+ stream varchar(100) COLLATE pg_catalog."default" PRIMARY KEY,
202
+ source varchar(100) COLLATE pg_catalog."default",
203
+ at int NOT NULL DEFAULT -1,
204
+ retry smallint NOT NULL DEFAULT 0,
205
+ blocked boolean NOT NULL DEFAULT false,
206
+ error text,
207
+ leased_by text,
208
+ leased_until timestamptz,
209
+ priority int NOT NULL DEFAULT 0
210
+ ) TABLESPACE pg_default;`
211
+ );
212
+ await client.query(
213
+ `ALTER TABLE ${this._fqs}
214
+ ADD COLUMN IF NOT EXISTS priority int NOT NULL DEFAULT 0;`
215
+ );
216
+ await client.query(
217
+ `DROP INDEX IF EXISTS "${this.config.schema}"."${this.config.table}_streams_fetch_ix"`
218
+ );
219
+ await client.query(
220
+ `CREATE INDEX IF NOT EXISTS "${this.config.table}_streams_claim_ix"
221
+ ON ${this._fqs} (blocked, priority DESC, at);`
222
+ );
223
+ await client.query("COMMIT");
224
+ logger.info(
225
+ `Seeded schema "${this.config.schema}" with table "${this.config.table}"`
226
+ );
227
+ } catch (error) {
228
+ await client.query("ROLLBACK");
229
+ logger.error(error);
230
+ throw error;
231
+ } finally {
232
+ client.release();
233
+ }
234
+ }
235
+ /**
236
+ * Drop all tables and schema created by the store (for testing or cleanup).
237
+ * @returns Promise that resolves when the schema is dropped
238
+ */
239
+ async drop() {
240
+ await this._pool.query(
241
+ `
242
+ DO $$
243
+ BEGIN
244
+ IF EXISTS (SELECT 1 FROM information_schema.schemata
245
+ WHERE schema_name = '${this.config.schema}'
246
+ ) THEN
247
+ EXECUTE 'DROP TABLE IF EXISTS ${this._fqt}';
248
+ EXECUTE 'DROP TABLE IF EXISTS ${this._fqs}';
249
+ IF '${this.config.schema}' <> 'public' THEN
250
+ EXECUTE 'DROP SCHEMA "${this.config.schema}" CASCADE';
251
+ END IF;
252
+ END IF;
253
+ END
254
+ $$;
255
+ `
256
+ );
257
+ }
258
+ /**
259
+ * Query events from the store, optionally filtered by stream, event name, time, etc.
260
+ *
261
+ * @param callback Function called for each event found
262
+ * @param query (Optional) Query filter (stream, names, before, after, etc.)
263
+ * @returns The number of events found
264
+ *
265
+ * @example
266
+ * await store.query((event) => console.log(event), { stream: "A" });
267
+ */
268
+ async query(callback, query) {
269
+ const {
270
+ stream,
271
+ names,
272
+ before,
273
+ after,
274
+ limit,
275
+ created_before,
276
+ created_after,
277
+ backward,
278
+ correlation,
279
+ with_snaps = false
280
+ } = query || {};
281
+ let sql = `SELECT * FROM ${this._fqt}`;
282
+ const conditions = [];
283
+ const values = [];
284
+ if (query) {
285
+ if (typeof after !== "undefined") {
286
+ values.push(after);
287
+ conditions.push(`id>$${values.length}`);
288
+ } else {
289
+ conditions.push("id>-1");
290
+ }
291
+ if (stream) {
292
+ values.push(stream);
293
+ conditions.push(
294
+ query.stream_exact ? `stream = $${values.length}` : `stream ~ $${values.length}`
295
+ );
296
+ }
297
+ if (names?.length) {
298
+ values.push(names);
299
+ conditions.push(`name = ANY($${values.length})`);
300
+ }
301
+ if (before) {
302
+ values.push(before);
303
+ conditions.push(`id<$${values.length}`);
304
+ }
305
+ if (created_after) {
306
+ values.push(created_after.toISOString());
307
+ conditions.push(`created>$${values.length}`);
308
+ }
309
+ if (created_before) {
310
+ values.push(created_before.toISOString());
311
+ conditions.push(`created<$${values.length}`);
312
+ }
313
+ if (correlation) {
314
+ values.push(correlation);
315
+ conditions.push(`meta->>'correlation'=$${values.length}`);
316
+ }
317
+ if (!with_snaps) {
318
+ conditions.push(`name <> '${import_act.SNAP_EVENT}'`);
319
+ }
320
+ }
321
+ if (conditions.length) {
322
+ sql += " WHERE " + conditions.join(" AND ");
323
+ }
324
+ sql += ` ORDER BY id ${backward ? "DESC" : "ASC"}`;
325
+ if (limit) {
326
+ values.push(limit);
327
+ sql += ` LIMIT $${values.length}`;
328
+ }
329
+ const result = await this._pool.query(sql, values);
330
+ for (const row of result.rows) callback(row);
331
+ return result.rowCount ?? 0;
332
+ }
333
+ /**
334
+ * Commit new events to the store for a given stream, with concurrency control.
335
+ *
336
+ * @param stream The stream name
337
+ * @param msgs Array of messages (event name and data)
338
+ * @param meta Event metadata (correlation, causation, etc.)
339
+ * @param expectedVersion (Optional) Expected stream version for concurrency control
340
+ * @returns Array of committed events
341
+ * @throws ConcurrencyError if the expected version does not match
342
+ */
343
+ async commit(stream, msgs, meta, expectedVersion) {
344
+ if (msgs.length === 0) return [];
345
+ const client = await this._pool.connect();
346
+ let version = -1;
347
+ try {
348
+ await client.query("BEGIN");
349
+ const last = await client.query(
350
+ `SELECT version
351
+ FROM ${this._fqt}
352
+ WHERE stream=$1 ORDER BY version DESC LIMIT 1`,
353
+ [stream]
354
+ );
355
+ version = last.rowCount ? last.rows[0].version : -1;
356
+ if (typeof expectedVersion === "number" && version !== expectedVersion)
357
+ throw new import_act.ConcurrencyError(
358
+ stream,
359
+ version,
360
+ msgs,
361
+ expectedVersion
362
+ );
363
+ const committed = [];
364
+ for (const { name, data } of msgs) {
365
+ version++;
366
+ const sql = `
367
+ INSERT INTO ${this._fqt}(name, data, stream, version, meta)
368
+ VALUES($1, $2, $3, $4, $5) RETURNING *`;
369
+ const vals = [name, data, stream, version, meta];
370
+ try {
371
+ const { rows } = await client.query(sql, vals);
372
+ committed.push(rows.at(0));
373
+ } catch (error) {
374
+ if (error?.code === PG_UNIQUE_VIOLATION) {
375
+ throw new import_act.ConcurrencyError(
376
+ stream,
377
+ version - 1,
378
+ msgs,
379
+ expectedVersion ?? -1
380
+ );
381
+ }
382
+ throw error;
383
+ }
384
+ }
385
+ if (this.config.notify) {
386
+ const payload = JSON.stringify({
387
+ stream,
388
+ events: committed.map((c) => ({ id: c.id, name: c.name })),
389
+ by: this._by
390
+ });
391
+ await client.query(`SELECT pg_notify($1, $2)`, [
392
+ this._channel,
393
+ payload
394
+ ]);
395
+ }
396
+ await client.query("COMMIT");
397
+ return committed;
398
+ } catch (error) {
399
+ await client.query("ROLLBACK").catch(() => {
400
+ });
401
+ throw error;
402
+ } finally {
403
+ client.release();
404
+ }
405
+ }
406
+ /**
407
+ * Atomically discovers and leases streams for reaction processing.
408
+ *
409
+ * Uses `FOR UPDATE SKIP LOCKED` to implement zero-contention competing consumers:
410
+ * - Workers never block each other — locked rows are silently skipped
411
+ * - Discovery and locking happen in a single atomic transaction
412
+ * - No wasted polls — every returned stream is exclusively owned
413
+ *
414
+ * @param lagging - Max streams from lagging frontier (ascending watermark)
415
+ * @param leading - Max streams from leading frontier (descending watermark)
416
+ * @param by - Lease holder identifier (UUID)
417
+ * @param millis - Lease duration in milliseconds
418
+ * @returns Leased streams with metadata
419
+ */
420
+ async claim(lagging, leading, by, millis) {
421
+ const client = await this._pool.connect();
422
+ try {
423
+ await client.query("BEGIN");
424
+ const { rows } = await client.query(
425
+ `
426
+ WITH
427
+ available AS (
428
+ SELECT stream, source, at, priority
429
+ FROM ${this._fqs} s
430
+ WHERE blocked = false
431
+ AND (leased_by IS NULL OR leased_until <= NOW())
432
+ AND (s.at < 0 OR EXISTS (
433
+ SELECT 1 FROM ${this._fqt} e
434
+ WHERE e.id > s.at
435
+ AND e.name <> '${import_act.SNAP_EVENT}'
436
+ AND (s.source IS NULL OR e.stream = COALESCE(s.source, s.stream))
437
+ LIMIT 1
438
+ ))
439
+ FOR UPDATE SKIP LOCKED
440
+ ),
441
+ -- Priority lanes (ACT-102): higher priority first, then
442
+ -- lagging-watermark order. With everyone at priority=0 the
443
+ -- ORDER BY collapses to plain at ASC so existing workloads
444
+ -- see no behavior change.
445
+ lag AS (
446
+ SELECT stream, source, at, TRUE AS lagging
447
+ FROM available
448
+ ORDER BY priority DESC, at ASC
449
+ LIMIT $1
450
+ ),
451
+ lead AS (
452
+ SELECT stream, source, at, FALSE AS lagging
453
+ FROM available
454
+ ORDER BY at DESC
455
+ LIMIT $2
456
+ ),
457
+ combined AS (
458
+ SELECT DISTINCT ON (stream) stream, source, at, lagging
459
+ FROM (SELECT * FROM lag UNION ALL SELECT * FROM lead) t
460
+ ORDER BY stream, at
461
+ )
462
+ UPDATE ${this._fqs} s
463
+ SET
464
+ leased_by = $3,
465
+ leased_until = NOW() + ($4::integer || ' milliseconds')::interval,
466
+ retry = s.retry + 1
467
+ FROM combined c
468
+ WHERE s.stream = c.stream
469
+ RETURNING s.stream, s.source, s.at, s.retry, c.lagging
470
+ `,
471
+ [lagging, leading, by, millis]
472
+ );
473
+ await client.query("COMMIT");
474
+ return rows.map(({ stream, source, at, retry, lagging: lagging2 }) => ({
475
+ stream,
476
+ source: source ?? void 0,
477
+ at,
478
+ by,
479
+ retry,
480
+ lagging: lagging2
481
+ }));
482
+ } catch (error) {
483
+ await client.query("ROLLBACK").catch(() => {
484
+ });
485
+ logger.error(error);
486
+ return [];
487
+ } finally {
488
+ client.release();
489
+ }
490
+ }
491
+ /**
492
+ * Registers streams for event processing.
493
+ * Upserts stream entries so they become visible to claim().
494
+ * Also returns the current max watermark across all subscriptions.
495
+ * @param streams - Streams to register with optional source.
496
+ * @returns subscribed count and current max watermark.
497
+ */
498
+ async subscribe(streams) {
499
+ const client = await this._pool.connect();
500
+ try {
501
+ await client.query("BEGIN");
502
+ let subscribed = 0;
503
+ if (streams.length) {
504
+ const { rowCount: inserted } = await client.query(
505
+ `
506
+ INSERT INTO ${this._fqs} (stream, source, priority)
507
+ SELECT s->>'stream',
508
+ s->>'source',
509
+ COALESCE((s->>'priority')::int, 0)
510
+ FROM jsonb_array_elements($1::jsonb) AS s
511
+ ON CONFLICT (stream) DO NOTHING
512
+ `,
513
+ [JSON.stringify(streams)]
514
+ );
515
+ subscribed = inserted ?? 0;
516
+ await client.query(
517
+ `
518
+ UPDATE ${this._fqs} t
519
+ SET priority = COALESCE((s->>'priority')::int, 0)
520
+ FROM jsonb_array_elements($1::jsonb) AS s
521
+ WHERE t.stream = s->>'stream'
522
+ AND COALESCE((s->>'priority')::int, 0) > t.priority
523
+ `,
524
+ [JSON.stringify(streams)]
525
+ );
526
+ }
527
+ const { rows } = await client.query(
528
+ `SELECT COALESCE(MAX(at), -1) AS max FROM ${this._fqs}`
529
+ );
530
+ await client.query("COMMIT");
531
+ return { subscribed, watermark: rows[0]?.max ?? -1 };
532
+ } catch (error) {
533
+ await client.query("ROLLBACK").catch(() => {
534
+ });
535
+ logger.error(error);
536
+ return { subscribed: 0, watermark: -1 };
537
+ } finally {
538
+ client.release();
539
+ }
540
+ }
541
+ /**
542
+ * Acknowledge and release leases after processing, updating stream positions.
543
+ *
544
+ * @param leases - Leases to acknowledge, including last processed watermark and lease holder.
545
+ * @returns Acked leases.
546
+ */
547
+ async ack(leases) {
548
+ const client = await this._pool.connect();
549
+ try {
550
+ await client.query("BEGIN");
551
+ const { rows } = await client.query(
552
+ `
553
+ WITH input AS (
554
+ SELECT * FROM jsonb_to_recordset($1::jsonb)
555
+ AS x(stream text, by text, at int, lagging boolean)
556
+ )
557
+ UPDATE ${this._fqs} AS s
558
+ SET
559
+ at = i.at,
560
+ retry = -1,
561
+ leased_by = NULL,
562
+ leased_until = NULL
563
+ FROM input i
564
+ WHERE s.stream = i.stream AND s.leased_by = i.by
565
+ RETURNING s.stream, s.source, s.at, i.by, s.retry, i.lagging
566
+ `,
567
+ [JSON.stringify(leases)]
568
+ );
569
+ await client.query("COMMIT");
570
+ return rows.map((row) => ({
571
+ stream: row.stream,
572
+ source: row.source ?? void 0,
573
+ at: row.at,
574
+ by: row.by,
575
+ retry: row.retry,
576
+ lagging: row.lagging
577
+ }));
578
+ } catch (error) {
579
+ await client.query("ROLLBACK").catch(() => {
580
+ });
581
+ logger.error(error);
582
+ return [];
583
+ } finally {
584
+ client.release();
585
+ }
586
+ }
587
+ /**
588
+ * Block a stream for processing after failing to process and reaching max retries with blocking enabled.
589
+ * @param leases - Leases to block, including lease holder and last error message.
590
+ * @returns Blocked leases.
591
+ */
592
+ async block(leases) {
593
+ const client = await this._pool.connect();
594
+ try {
595
+ await client.query("BEGIN");
596
+ const { rows } = await client.query(
597
+ `
598
+ WITH input AS (
599
+ SELECT * FROM jsonb_to_recordset($1::jsonb)
600
+ AS x(stream text, by text, error text, lagging boolean)
601
+ )
602
+ UPDATE ${this._fqs} AS s
603
+ SET blocked = true, error = i.error
604
+ FROM input i
605
+ WHERE s.stream = i.stream AND s.leased_by = i.by AND s.blocked = false
606
+ RETURNING s.stream, s.source, s.at, i.by, s.retry, s.error, i.lagging
607
+ `,
608
+ [JSON.stringify(leases)]
609
+ );
610
+ await client.query("COMMIT");
611
+ return rows.map((row) => ({
612
+ stream: row.stream,
613
+ source: row.source ?? void 0,
614
+ at: row.at,
615
+ by: row.by,
616
+ retry: row.retry,
617
+ lagging: row.lagging,
618
+ error: row.error
619
+ }));
620
+ } catch (error) {
621
+ await client.query("ROLLBACK").catch(() => {
622
+ });
623
+ logger.error(error);
624
+ return [];
625
+ } finally {
626
+ client.release();
627
+ }
628
+ }
629
+ /**
630
+ * Reset watermarks for the given streams to -1, clearing retry, blocked,
631
+ * error, and lease state so they can be replayed from the beginning.
632
+ * @param streams - Stream names to reset.
633
+ * @returns Count of streams that were actually reset.
634
+ */
635
+ async reset(streams) {
636
+ if (!streams.length) return 0;
637
+ const { rowCount } = await this._pool.query(
638
+ `UPDATE ${this._fqs}
639
+ SET at = -1, retry = 0, blocked = false, error = NULL,
640
+ leased_by = NULL, leased_until = NULL
641
+ WHERE stream = ANY($1)`,
642
+ [streams]
643
+ );
644
+ return rowCount ?? 0;
645
+ }
646
+ /**
647
+ * Bulk-update priority of streams matching `filter` (ACT-102).
648
+ *
649
+ * Filter semantics mirror {@link query_streams}: regex on `stream` /
650
+ * `source` by default, exact match with the `_exact` flags,
651
+ * `blocked` restricts to blocked or unblocked rows. Empty filter
652
+ * (`{}`) updates every registered stream.
653
+ *
654
+ * Unlike {@link subscribe} (which keeps `max()` of registered
655
+ * priorities), this sets the priority outright — operator override
656
+ * for the build-time scheduling policy.
657
+ *
658
+ * @returns Count of streams whose priority changed.
659
+ */
660
+ async prioritize(filter, priority) {
661
+ const conditions = ["priority <> $1"];
662
+ const values = [priority];
663
+ if (filter.stream !== void 0) {
664
+ values.push(filter.stream);
665
+ conditions.push(
666
+ filter.stream_exact ? `stream = $${values.length}` : `stream ~ $${values.length}`
667
+ );
668
+ }
669
+ if (filter.source !== void 0) {
670
+ conditions.push(`source IS NOT NULL`);
671
+ values.push(filter.source);
672
+ conditions.push(
673
+ filter.source_exact ? `source = $${values.length}` : `source ~ $${values.length}`
674
+ );
675
+ }
676
+ if (filter.blocked !== void 0) {
677
+ values.push(filter.blocked);
678
+ conditions.push(`blocked = $${values.length}`);
679
+ }
680
+ const sql = `UPDATE ${this._fqs} SET priority = $1 WHERE ${conditions.join(" AND ")}`;
681
+ const { rowCount } = await this._pool.query(sql, values);
682
+ return rowCount ?? 0;
683
+ }
684
+ /**
685
+ * Streams subscription positions to a callback, ordered by stream name,
686
+ * along with the highest event id in the store.
687
+ *
688
+ * Filters (`stream`, `source`, `blocked`, `after`, `limit`) are applied
689
+ * server-side. `stream`/`source` are regex by default (`~`), or exact
690
+ * with `*_exact: true` — same convention as {@link Store.query}.
691
+ *
692
+ * @returns `maxEventId` and the `count` of positions emitted.
693
+ */
694
+ async query_streams(callback, query) {
695
+ const limit = query?.limit ?? 100;
696
+ const conditions = [];
697
+ const values = [];
698
+ if (query?.stream !== void 0) {
699
+ values.push(query.stream);
700
+ conditions.push(
701
+ query.stream_exact ? `stream = $${values.length}` : `stream ~ $${values.length}`
702
+ );
703
+ }
704
+ if (query?.source !== void 0) {
705
+ conditions.push(`source IS NOT NULL`);
706
+ values.push(query.source);
707
+ conditions.push(
708
+ query.source_exact ? `source = $${values.length}` : `source ~ $${values.length}`
709
+ );
710
+ }
711
+ if (query?.blocked !== void 0) {
712
+ values.push(query.blocked);
713
+ conditions.push(`blocked = $${values.length}`);
714
+ }
715
+ if (query?.after !== void 0) {
716
+ values.push(query.after);
717
+ conditions.push(`stream > $${values.length}`);
718
+ }
719
+ let sql = `SELECT stream, source, at, retry, blocked, error, leased_by, leased_until, priority FROM ${this._fqs}`;
720
+ if (conditions.length) sql += " WHERE " + conditions.join(" AND ");
721
+ values.push(limit);
722
+ sql += ` ORDER BY stream LIMIT $${values.length}`;
723
+ const client = await this._pool.connect();
724
+ try {
725
+ const [streamsResult, maxResult] = await Promise.all([
726
+ client.query(sql, values),
727
+ client.query(
728
+ `SELECT COALESCE(MAX(id), -1) AS m FROM ${this._fqt}`
729
+ )
730
+ ]);
731
+ let count = 0;
732
+ for (const row of streamsResult.rows) {
733
+ callback({
734
+ stream: row.stream,
735
+ source: row.source ?? void 0,
736
+ at: row.at,
737
+ retry: row.retry,
738
+ blocked: row.blocked,
739
+ error: row.error ?? "",
740
+ priority: row.priority,
741
+ leased_by: row.leased_by ?? void 0,
742
+ leased_until: row.leased_until ?? void 0
743
+ });
744
+ count++;
745
+ }
746
+ return { maxEventId: Number(maxResult.rows[0].m), count };
747
+ } finally {
748
+ client.release();
749
+ }
750
+ }
751
+ /**
752
+ * Implementation of the optional `Store.notify` hook. Bound onto
753
+ * `this.notify` in the constructor when `config.notify === true`,
754
+ * left detached otherwise — see {@link Config.notify}.
755
+ *
756
+ * Checks out a dedicated long-lived client from the pool, runs
757
+ * `LISTEN act_commit_<schema>_<table>`, and parses each incoming
758
+ * notification payload. The handler is invoked exactly once per
759
+ * **remote** commit — payloads originating from this same store
760
+ * instance (matched by the per-instance `_by` UUID) are silently
761
+ * skipped, giving callers a clean cross-process semantic.
762
+ *
763
+ * Multiple subscriptions on the same store instance are not supported —
764
+ * this method releases any prior LISTEN client before opening a new one.
765
+ * The returned disposer cleanly UNLISTENs and releases the dedicated
766
+ * client; pool disposal also tears the subscription down as a safety
767
+ * net.
768
+ *
769
+ * @param handler Called for each cross-process commit notification.
770
+ * @returns Disposer that releases the LISTEN client.
771
+ */
772
+ async _subscribeNotifications(handler) {
773
+ await this._teardownListen();
774
+ const client = await this._pool.connect();
775
+ const onNotification = (msg) => {
776
+ if (msg.channel !== this._channel) return;
777
+ if (!msg.payload) return;
778
+ let parsed;
779
+ try {
780
+ parsed = JSON.parse(msg.payload);
781
+ } catch (err) {
782
+ logger.error(
783
+ { err, payload: msg.payload },
784
+ "act_commit: malformed payload, skipping"
785
+ );
786
+ return;
787
+ }
788
+ if (parsed.by === this._by) return;
789
+ if (typeof parsed.stream !== "string" || !Array.isArray(parsed.events)) {
790
+ logger.error(
791
+ { payload: msg.payload },
792
+ "act_commit: payload missing required fields, skipping"
793
+ );
794
+ return;
795
+ }
796
+ const events = [];
797
+ for (const raw of parsed.events) {
798
+ if (raw && typeof raw === "object" && typeof raw.id === "number" && typeof raw.name === "string") {
799
+ events.push({
800
+ id: raw.id,
801
+ name: raw.name
802
+ });
803
+ }
804
+ }
805
+ if (events.length === 0) return;
806
+ try {
807
+ handler({ stream: parsed.stream, events });
808
+ } catch (err) {
809
+ logger.error(err, "act_commit: handler threw, listener preserved");
810
+ }
811
+ };
812
+ client.on("notification", onNotification);
813
+ try {
814
+ await client.query(`LISTEN ${this._channel}`);
815
+ } catch (err) {
816
+ client.removeListener("notification", onNotification);
817
+ client.release(true);
818
+ throw err;
819
+ }
820
+ this._listenClient = client;
821
+ this._listenHandler = onNotification;
822
+ return async () => {
823
+ if (this._listenClient !== client) return;
824
+ await this._teardownListen();
825
+ };
826
+ }
827
+ /**
828
+ * Atomically truncates streams and seeds each with a snapshot or tombstone.
829
+ * @param targets - Streams to truncate with optional snapshot state and meta.
830
+ * @returns Map keyed by stream name, each entry with `deleted` count and `committed` event.
831
+ */
832
+ async truncate(targets) {
833
+ if (!targets.length) return /* @__PURE__ */ new Map();
834
+ const streams = targets.map((t) => t.stream);
835
+ const client = await this._pool.connect();
836
+ try {
837
+ await client.query("BEGIN");
838
+ await client.query(`DELETE FROM ${this._fqs} WHERE stream = ANY($1)`, [
839
+ streams
840
+ ]);
841
+ const result = /* @__PURE__ */ new Map();
842
+ for (const { stream, snapshot, meta } of targets) {
843
+ const { rowCount } = await client.query(
844
+ `DELETE FROM ${this._fqt} WHERE stream = $1`,
845
+ [stream]
846
+ );
847
+ const name = snapshot !== void 0 ? import_act.SNAP_EVENT : import_act.TOMBSTONE_EVENT;
848
+ const { rows } = await client.query(
849
+ `INSERT INTO ${this._fqt}(name, data, stream, version, created, meta)
850
+ VALUES($1, $2, $3, 0, now(), $4) RETURNING *`,
851
+ [
852
+ name,
853
+ snapshot ?? {},
854
+ stream,
855
+ meta ?? { correlation: "", causation: {} }
856
+ ]
857
+ );
858
+ result.set(stream, {
859
+ deleted: rowCount ?? 0,
860
+ committed: rows[0]
861
+ });
862
+ }
863
+ await client.query("COMMIT");
864
+ return result;
865
+ } catch (error) {
866
+ await client.query("ROLLBACK").catch(() => {
867
+ });
868
+ throw error;
869
+ } finally {
870
+ client.release();
871
+ }
872
+ }
873
+ };
874
+ // Annotate the CommonJS export names for ESM import in node:
875
+ 0 && (module.exports = {
876
+ PostgresStore
877
+ });
878
+ //# sourceMappingURL=index.cjs.map