@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/.tsbuildinfo +1 -0
- package/dist/@types/index.d.ts +7 -0
- package/dist/@types/index.d.ts.map +1 -0
- package/dist/@types/postgres-store.d.ts +342 -0
- package/dist/@types/postgres-store.d.ts.map +1 -0
- package/dist/@types/utils.d.ts +2 -0
- package/dist/@types/utils.d.ts.map +1 -0
- package/dist/index.cjs +878 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +846 -0
- package/dist/index.js.map +1 -0
- package/package.json +5 -1
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
|