@rotorsoft/act-pg 1.12.0 → 1.13.1

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 CHANGED
@@ -413,16 +413,13 @@ var PostgresStore = class {
413
413
  async commit(stream, msgs, meta, expectedVersion) {
414
414
  if (msgs.length === 0) return [];
415
415
  const client = await this._client("commit");
416
- let version = -1;
417
416
  try {
418
- await client.query("BEGIN");
419
417
  const last = await client.query(
420
- `SELECT version
421
- FROM ${this._fqt}
422
- WHERE stream=$1 ORDER BY version DESC LIMIT 1`,
418
+ `SELECT version FROM ${this._fqt}
419
+ WHERE stream=$1 ORDER BY version DESC LIMIT 1`,
423
420
  [stream]
424
421
  );
425
- version = last.rowCount ? last.rows[0].version : -1;
422
+ let version = last.rows.at(0)?.version ?? -1;
426
423
  if (typeof expectedVersion === "number" && version !== expectedVersion)
427
424
  throw new ConcurrencyError(
428
425
  stream,
@@ -430,52 +427,69 @@ var PostgresStore = class {
430
427
  msgs,
431
428
  expectedVersion
432
429
  );
433
- const committed = [];
430
+ const base_version = version;
431
+ const names = [];
432
+ const datas = [];
433
+ const piis = [];
434
+ const versions = [];
434
435
  for (const { name, data, pii } of msgs) {
435
436
  version++;
436
- const sql = `
437
+ names.push(name);
438
+ datas.push(JSON.stringify(data));
439
+ piis.push(
440
+ this._resolve_pii_key && pii != null ? JSON.stringify(await encrypt(pii, this._resolve_pii_key)) : pii != null ? JSON.stringify(pii) : null
441
+ );
442
+ versions.push(version);
443
+ }
444
+ const insert_select = msgs.length === 1 ? `SELECT $1, $2::jsonb, $3::jsonb, $5, $4::int, $6 FROM l` : `SELECT u.name, u.data::jsonb, u.pii::jsonb, $5, u.version, $6
445
+ FROM l, unnest($1::text[], $2::text[], $3::text[], $4::int[])
446
+ WITH ORDINALITY AS u(name, data, pii, version, ord)
447
+ ORDER BY u.ord`;
448
+ const notify_ctes = this.config.notify ? `,
449
+ payload AS (
450
+ SELECT json_build_object(
451
+ 'stream', $5::text,
452
+ 'events', json_agg(json_build_object('id', ins.id, 'name', ins.name) ORDER BY ins.version),
453
+ 'by', $9::text
454
+ )::text AS p
455
+ FROM ins
456
+ ),
457
+ n AS (
458
+ SELECT pg_notify($8, payload.p) FROM payload
459
+ WHERE octet_length(payload.p) < $10
460
+ )` : "";
461
+ const final_select = this.config.notify ? "SELECT ins.* FROM ins LEFT JOIN n ON true ORDER BY ins.version" : "SELECT * FROM ins ORDER BY version";
462
+ const sql = `WITH l AS (SELECT pg_advisory_xact_lock(hashtext($7))),
463
+ ins AS (
437
464
  INSERT INTO ${this._fqt}(name, data, pii, stream, version, meta)
438
- VALUES($1, $2, $3, $4, $5, $6) RETURNING *`;
439
- const pii_for_write = this._resolve_pii_key && pii != null ? JSON.stringify(await encrypt(pii, this._resolve_pii_key)) : pii ?? null;
440
- const vals = [name, data, pii_for_write, stream, version, meta];
441
- try {
442
- const { rows } = await client.query(sql, vals);
443
- const row = rows.at(0);
444
- if (this._resolve_pii_key && typeof row.pii === "string") {
445
- const decrypted = await decrypt(row.pii, this._resolve_pii_key);
446
- row.pii = decrypted;
447
- }
448
- committed.push(row);
449
- } catch (error) {
450
- if (error?.code === PG_UNIQUE_VIOLATION) {
451
- throw new ConcurrencyError(
452
- stream,
453
- version - 1,
454
- msgs,
455
- expectedVersion ?? -1
456
- );
465
+ ${insert_select}
466
+ RETURNING *
467
+ )${notify_ctes}
468
+ ${final_select}`;
469
+ const base_params = msgs.length === 1 ? [names[0], datas[0], piis[0], versions[0], stream, meta, this._fqt] : [names, datas, piis, versions, stream, meta, this._fqt];
470
+ const params = this.config.notify ? [...base_params, this._channel, this._by, NOTIFY_MAX_PAYLOAD_BYTES] : base_params;
471
+ try {
472
+ const { rows } = await client.query(sql, params);
473
+ if (this._resolve_pii_key) {
474
+ for (const row of rows) {
475
+ if (typeof row.pii === "string") {
476
+ const decrypted = await decrypt(row.pii, this._resolve_pii_key);
477
+ row.pii = decrypted;
478
+ }
457
479
  }
458
- throw error;
459
480
  }
481
+ return rows;
482
+ } catch (error) {
483
+ if (error?.code === PG_UNIQUE_VIOLATION) {
484
+ throw new ConcurrencyError(
485
+ stream,
486
+ base_version,
487
+ msgs,
488
+ expectedVersion ?? -1
489
+ );
490
+ }
491
+ throw error;
460
492
  }
461
- if (this.config.notify) {
462
- const payload = JSON.stringify({
463
- stream,
464
- events: committed.map((c) => ({ id: c.id, name: c.name })),
465
- by: this._by
466
- });
467
- if (Buffer.byteLength(payload, "utf8") < NOTIFY_MAX_PAYLOAD_BYTES)
468
- await client.query(`SELECT pg_notify($1, $2)`, [
469
- this._channel,
470
- payload
471
- ]);
472
- }
473
- await client.query("COMMIT");
474
- return committed;
475
- } catch (error) {
476
- await client.query("ROLLBACK").catch(() => {
477
- });
478
- throw error;
479
493
  } finally {
480
494
  client.release();
481
495
  }
@@ -1200,20 +1214,30 @@ var PostgresStore = class {
1200
1214
  }
1201
1215
  /**
1202
1216
  * Atomically truncates streams and seeds each with a snapshot or tombstone.
1203
- * @param targets - Streams to truncate with optional snapshot state and meta.
1217
+ * Windowed targets (`before` set) prune the prefix below the closest safe
1218
+ * `__snapshot__` instead — no seed, subscriptions untouched, no-op when no
1219
+ * snapshot qualifies.
1220
+ * @param targets - Streams to truncate with optional snapshot state and meta,
1221
+ * or a `before`/`max_id` boundary for a windowed prefix delete.
1204
1222
  * @returns Map keyed by stream name, each entry with `deleted` count and `committed` event.
1205
1223
  */
1206
1224
  async truncate(targets) {
1207
1225
  if (!targets.length) return /* @__PURE__ */ new Map();
1208
- const streams = targets.map((t) => t.stream);
1226
+ const full = targets.filter((t) => t.before === void 0);
1227
+ const windowed = targets.filter((t) => t.before !== void 0);
1209
1228
  const client = await this._client("truncate");
1210
1229
  try {
1211
1230
  await client.query("BEGIN");
1212
- await client.query(`DELETE FROM ${this._fqs} WHERE stream = ANY($1)`, [
1213
- streams
1231
+ await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [
1232
+ this._fqt
1214
1233
  ]);
1215
1234
  const result = /* @__PURE__ */ new Map();
1216
- for (const { stream, snapshot, meta } of targets) {
1235
+ if (full.length) {
1236
+ await client.query(`DELETE FROM ${this._fqs} WHERE stream = ANY($1)`, [
1237
+ full.map((t) => t.stream)
1238
+ ]);
1239
+ }
1240
+ for (const { stream, snapshot, meta } of full) {
1217
1241
  const { rowCount } = await client.query(
1218
1242
  `DELETE FROM ${this._fqt} WHERE stream = $1`,
1219
1243
  [stream]
@@ -1234,6 +1258,27 @@ var PostgresStore = class {
1234
1258
  committed: rows[0]
1235
1259
  });
1236
1260
  }
1261
+ for (const { stream, before, max_id } of windowed) {
1262
+ const { rows } = await client.query(
1263
+ `SELECT id, stream, version, name, data, created, meta
1264
+ FROM ${this._fqt}
1265
+ WHERE stream = $1 AND name = $2 AND created < $3
1266
+ AND ($4::int IS NULL OR id <= $4)
1267
+ ORDER BY id DESC LIMIT 1`,
1268
+ [stream, SNAP_EVENT, before, max_id ?? null]
1269
+ );
1270
+ if (!rows.length) continue;
1271
+ const boundary = rows[0];
1272
+ const { rowCount } = await client.query(
1273
+ `DELETE FROM ${this._fqt} WHERE stream = $1 AND id < $2`,
1274
+ [stream, boundary.id]
1275
+ );
1276
+ result.set(stream, {
1277
+ deleted: rowCount ?? 0,
1278
+ committed: boundary,
1279
+ before
1280
+ });
1281
+ }
1237
1282
  await client.query("COMMIT");
1238
1283
  return result;
1239
1284
  } catch (error) {