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