@rotorsoft/act-pg 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -175,13 +175,18 @@ var PostgresStore = class {
175
175
  error text,
176
176
  leased_by text,
177
177
  leased_until timestamptz,
178
- priority int NOT NULL DEFAULT 0
178
+ priority int NOT NULL DEFAULT 0,
179
+ lane text NOT NULL DEFAULT 'default'
179
180
  ) TABLESPACE pg_default;`
180
181
  );
181
182
  await client.query(
182
183
  `ALTER TABLE ${this._fqs}
183
184
  ADD COLUMN IF NOT EXISTS priority int NOT NULL DEFAULT 0;`
184
185
  );
186
+ await client.query(
187
+ `ALTER TABLE ${this._fqs}
188
+ ADD COLUMN IF NOT EXISTS lane text NOT NULL DEFAULT 'default';`
189
+ );
185
190
  await client.query(
186
191
  `DROP INDEX IF EXISTS "${this.config.schema}"."${this.config.table}_streams_fetch_ix"`
187
192
  );
@@ -189,6 +194,10 @@ var PostgresStore = class {
189
194
  `CREATE INDEX IF NOT EXISTS "${this.config.table}_streams_claim_ix"
190
195
  ON ${this._fqs} (blocked, priority DESC, at);`
191
196
  );
197
+ await client.query(
198
+ `CREATE INDEX IF NOT EXISTS "${this.config.table}_streams_lane_ix"
199
+ ON ${this._fqs} (lane);`
200
+ );
192
201
  await client.query("COMMIT");
193
202
  logger.info(
194
203
  `Seeded schema "${this.config.schema}" with table "${this.config.table}"`
@@ -386,17 +395,20 @@ var PostgresStore = class {
386
395
  * @param millis - Lease duration in milliseconds
387
396
  * @returns Leased streams with metadata
388
397
  */
389
- async claim(lagging, leading, by, millis) {
398
+ async claim(lagging, leading, by, millis, lane) {
390
399
  const client = await this._pool.connect();
391
400
  try {
392
401
  await client.query("BEGIN");
402
+ const laneClause = lane !== void 0 ? `AND s.lane = $5` : "";
403
+ const params = lane !== void 0 ? [lagging, leading, by, millis, lane] : [lagging, leading, by, millis];
393
404
  const { rows } = await client.query(
394
405
  `
395
406
  WITH
396
407
  available AS (
397
- SELECT stream, source, at, priority
408
+ SELECT stream, source, at, priority, lane
398
409
  FROM ${this._fqs} s
399
410
  WHERE blocked = false
411
+ ${laneClause}
400
412
  AND (leased_by IS NULL OR leased_until <= NOW())
401
413
  AND (s.at < 0 OR EXISTS (
402
414
  SELECT 1 FROM ${this._fqt} e
@@ -412,19 +424,19 @@ var PostgresStore = class {
412
424
  -- ORDER BY collapses to plain at ASC so existing workloads
413
425
  -- see no behavior change.
414
426
  lag AS (
415
- SELECT stream, source, at, TRUE AS lagging
427
+ SELECT stream, source, at, lane, TRUE AS lagging
416
428
  FROM available
417
429
  ORDER BY priority DESC, at ASC
418
430
  LIMIT $1
419
431
  ),
420
432
  lead AS (
421
- SELECT stream, source, at, FALSE AS lagging
433
+ SELECT stream, source, at, lane, FALSE AS lagging
422
434
  FROM available
423
435
  ORDER BY at DESC
424
436
  LIMIT $2
425
437
  ),
426
438
  combined AS (
427
- SELECT DISTINCT ON (stream) stream, source, at, lagging
439
+ SELECT DISTINCT ON (stream) stream, source, at, lane, lagging
428
440
  FROM (SELECT * FROM lag UNION ALL SELECT * FROM lead) t
429
441
  ORDER BY stream, at
430
442
  )
@@ -435,18 +447,19 @@ var PostgresStore = class {
435
447
  retry = s.retry + 1
436
448
  FROM combined c
437
449
  WHERE s.stream = c.stream
438
- RETURNING s.stream, s.source, s.at, s.retry, c.lagging
450
+ RETURNING s.stream, s.source, s.at, s.retry, c.lagging, s.lane
439
451
  `,
440
- [lagging, leading, by, millis]
452
+ params
441
453
  );
442
454
  await client.query("COMMIT");
443
- return rows.map(({ stream, source, at, retry, lagging: lagging2 }) => ({
455
+ return rows.map(({ stream, source, at, retry, lagging: lagging2, lane: lane2 }) => ({
444
456
  stream,
445
457
  source: source ?? void 0,
446
458
  at,
447
459
  by,
448
460
  retry,
449
- lagging: lagging2
461
+ lagging: lagging2,
462
+ lane: lane2
450
463
  }));
451
464
  } catch (error) {
452
465
  await client.query("ROLLBACK").catch(() => {
@@ -472,10 +485,11 @@ var PostgresStore = class {
472
485
  if (streams.length) {
473
486
  const { rowCount: inserted } = await client.query(
474
487
  `
475
- INSERT INTO ${this._fqs} (stream, source, priority)
488
+ INSERT INTO ${this._fqs} (stream, source, priority, lane)
476
489
  SELECT s->>'stream',
477
490
  s->>'source',
478
- COALESCE((s->>'priority')::int, 0)
491
+ COALESCE((s->>'priority')::int, 0),
492
+ COALESCE(s->>'lane', 'default')
479
493
  FROM jsonb_array_elements($1::jsonb) AS s
480
494
  ON CONFLICT (stream) DO NOTHING
481
495
  `,
@@ -492,6 +506,16 @@ var PostgresStore = class {
492
506
  `,
493
507
  [JSON.stringify(streams)]
494
508
  );
509
+ await client.query(
510
+ `
511
+ UPDATE ${this._fqs} t
512
+ SET lane = COALESCE(s->>'lane', 'default')
513
+ FROM jsonb_array_elements($1::jsonb) AS s
514
+ WHERE t.stream = s->>'stream'
515
+ AND t.lane <> COALESCE(s->>'lane', 'default')
516
+ `,
517
+ [JSON.stringify(streams)]
518
+ );
495
519
  }
496
520
  const { rows } = await client.query(
497
521
  `SELECT COALESCE(MAX(at), -1) AS max FROM ${this._fqs}`
@@ -531,7 +555,7 @@ var PostgresStore = class {
531
555
  leased_until = NULL
532
556
  FROM input i
533
557
  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
558
+ RETURNING s.stream, s.source, s.at, i.by, s.retry, i.lagging, s.lane
535
559
  `,
536
560
  [JSON.stringify(leases)]
537
561
  );
@@ -542,7 +566,8 @@ var PostgresStore = class {
542
566
  at: row.at,
543
567
  by: row.by,
544
568
  retry: row.retry,
545
- lagging: row.lagging
569
+ lagging: row.lagging,
570
+ lane: row.lane
546
571
  }));
547
572
  } catch (error) {
548
573
  await client.query("ROLLBACK").catch(() => {
@@ -572,7 +597,7 @@ var PostgresStore = class {
572
597
  SET blocked = true, error = i.error
573
598
  FROM input i
574
599
  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
600
+ RETURNING s.stream, s.source, s.at, i.by, s.retry, s.error, i.lagging, s.lane
576
601
  `,
577
602
  [JSON.stringify(leases)]
578
603
  );
@@ -584,7 +609,8 @@ var PostgresStore = class {
584
609
  by: row.by,
585
610
  retry: row.retry,
586
611
  lagging: row.lagging,
587
- error: row.error
612
+ error: row.error,
613
+ lane: row.lane
588
614
  }));
589
615
  } catch (error) {
590
616
  await client.query("ROLLBACK").catch(() => {
@@ -627,6 +653,10 @@ var PostgresStore = class {
627
653
  values.push(filter.blocked);
628
654
  conditions.push(`blocked = $${start + values.length - 1}`);
629
655
  }
656
+ if (filter.lane !== void 0) {
657
+ values.push(filter.lane);
658
+ conditions.push(`lane = $${start + values.length - 1}`);
659
+ }
630
660
  return {
631
661
  clause: conditions.length ? conditions.join(" AND ") : "TRUE",
632
662
  values
@@ -739,11 +769,15 @@ var PostgresStore = class {
739
769
  values.push(query.blocked);
740
770
  conditions.push(`blocked = $${values.length}`);
741
771
  }
772
+ if (query?.lane !== void 0) {
773
+ values.push(query.lane);
774
+ conditions.push(`lane = $${values.length}`);
775
+ }
742
776
  if (query?.after !== void 0) {
743
777
  values.push(query.after);
744
778
  conditions.push(`stream > $${values.length}`);
745
779
  }
746
- let sql = `SELECT stream, source, at, retry, blocked, error, leased_by, leased_until, priority FROM ${this._fqs}`;
780
+ let sql = `SELECT stream, source, at, retry, blocked, error, leased_by, leased_until, priority, lane FROM ${this._fqs}`;
747
781
  if (conditions.length) sql += " WHERE " + conditions.join(" AND ");
748
782
  values.push(limit);
749
783
  sql += ` ORDER BY stream LIMIT $${values.length}`;
@@ -766,7 +800,8 @@ var PostgresStore = class {
766
800
  error: row.error ?? "",
767
801
  priority: row.priority,
768
802
  leased_by: row.leased_by ?? void 0,
769
- leased_until: row.leased_until ?? void 0
803
+ leased_until: row.leased_until ?? void 0,
804
+ lane: row.lane
770
805
  });
771
806
  count++;
772
807
  }
@@ -775,6 +810,168 @@ var PostgresStore = class {
775
810
  client.release();
776
811
  }
777
812
  }
813
+ /**
814
+ * Per-stream aggregated stats — see {@link Store.query_stats}.
815
+ *
816
+ * Two code paths chosen by the requested stats:
817
+ *
818
+ * - **Heads-only path** (no `count`, no `names`): one or two
819
+ * `SELECT DISTINCT ON (stream) ... ORDER BY stream, version DESC|ASC`
820
+ * queries, executed in parallel when `tail: true`. The
821
+ * `(stream, version)` unique index gives index-only access — K rows
822
+ * touched per query (K = matched streams), not N (events).
823
+ * Ordering by `version` (not `id`) is equivalent within a stream
824
+ * (versions are monotonic per stream and events are committed
825
+ * sequentially) and is the column actually indexed.
826
+ *
827
+ * - **Full-scan path** (`count` or `names` set): one CTE materializes
828
+ * the filtered events, then `GROUP BY stream, name` →
829
+ * `jsonb_object_agg(name, n)` for the `names` map plus per-stream
830
+ * `COUNT(*)` for `count`. Heads (and `tails` when requested) come
831
+ * from `DISTINCT ON` over the same CTE — they ride free on the
832
+ * already-paid scan.
833
+ *
834
+ * The stream universe is derived from the events table: filter form
835
+ * matches event-bearing streams (not subscription rows). When the
836
+ * filter sets `source` or `blocked`, the events table is joined
837
+ * against the streams subscription table since those concepts only
838
+ * exist for subscribed streams.
839
+ */
840
+ async query_stats(input, options) {
841
+ const exclude = options?.exclude ?? [];
842
+ const wantTail = options?.tail ?? false;
843
+ const wantCount = options?.count ?? false;
844
+ const wantNames = options?.names ?? false;
845
+ const before = options?.before;
846
+ const fullScan = wantCount || wantNames;
847
+ if (Array.isArray(input) && input.length === 0) {
848
+ return /* @__PURE__ */ new Map();
849
+ }
850
+ const where = [];
851
+ const params = [];
852
+ if (Array.isArray(input)) {
853
+ params.push(input);
854
+ where.push(`e.stream = ANY($${params.length})`);
855
+ } else if (input.stream !== void 0) {
856
+ params.push(input.stream);
857
+ where.push(
858
+ input.stream_exact ? `e.stream = $${params.length}` : `e.stream ~ $${params.length}`
859
+ );
860
+ }
861
+ if (exclude.length) {
862
+ params.push(exclude);
863
+ where.push(`e.name <> ALL($${params.length})`);
864
+ }
865
+ if (before !== void 0) {
866
+ params.push(before);
867
+ where.push(`e.id < $${params.length}`);
868
+ }
869
+ const fromClause = `${this._fqt} e`;
870
+ const whereClause = `WHERE ${where.length ? where.join(" AND ") : "TRUE"}`;
871
+ return fullScan ? this._queryStatsFullScan(
872
+ fromClause,
873
+ whereClause,
874
+ params,
875
+ wantTail,
876
+ wantCount,
877
+ wantNames
878
+ ) : this._queryStatsHeadsOnly(fromClause, whereClause, params, wantTail);
879
+ }
880
+ /**
881
+ * Cheap path: index-only DISTINCT ON for the head per stream, plus an
882
+ * optional second query (in parallel) for the tail. K rows touched
883
+ * per query, not N events.
884
+ */
885
+ async _queryStatsHeadsOnly(fromClause, whereClause, params, wantTail) {
886
+ const cols = `e.id, e.stream, e.version, e.name, e.data, e.created, e.meta`;
887
+ const headSql = `SELECT DISTINCT ON (e.stream) ${cols} FROM ${fromClause} ${whereClause} ORDER BY e.stream, e.version DESC`;
888
+ const tailSql = wantTail ? `SELECT DISTINCT ON (e.stream) ${cols} FROM ${fromClause} ${whereClause} ORDER BY e.stream, e.version ASC` : null;
889
+ const [headRes, tailRes] = await Promise.all([
890
+ this._pool.query(headSql, params),
891
+ tailSql ? this._pool.query(tailSql, params) : Promise.resolve(null)
892
+ ]);
893
+ const out = /* @__PURE__ */ new Map();
894
+ for (const row of headRes.rows) {
895
+ out.set(row.stream, { head: row });
896
+ }
897
+ if (tailRes) {
898
+ for (const row of tailRes.rows) {
899
+ out.get(row.stream).tail = row;
900
+ }
901
+ }
902
+ return out;
903
+ }
904
+ /**
905
+ * Full-scan path: one CTE-based query computes the per-stream
906
+ * `COUNT(*)` and `jsonb_object_agg(name, n)` map alongside the head
907
+ * (and tail when requested). All extras share the single events scan.
908
+ */
909
+ async _queryStatsFullScan(fromClause, whereClause, params, wantTail, wantCount, wantNames) {
910
+ const tailCte = wantTail ? `, tails AS (SELECT DISTINCT ON (stream) * FROM ef ORDER BY stream, version ASC)` : "";
911
+ const tailJoin = wantTail ? `LEFT JOIN tails t ON t.stream = h.stream` : "";
912
+ const tailCols = wantTail ? `, t.id AS t_id, t.stream AS t_stream, t.version AS t_version,
913
+ t.name AS t_name, t.data AS t_data, t.created AS t_created, t.meta AS t_meta` : "";
914
+ const sql = `
915
+ WITH ef AS (
916
+ SELECT e.id, e.stream, e.version, e.name, e.data, e.created, e.meta
917
+ FROM ${fromClause}
918
+ ${whereClause}
919
+ ),
920
+ agg AS (
921
+ SELECT stream,
922
+ SUM(n)::int AS cnt,
923
+ jsonb_object_agg(name, n) AS names
924
+ FROM (
925
+ SELECT stream, name, COUNT(*)::int AS n
926
+ FROM ef
927
+ GROUP BY stream, name
928
+ ) t
929
+ GROUP BY stream
930
+ ),
931
+ heads AS (
932
+ SELECT DISTINCT ON (stream) * FROM ef ORDER BY stream, version DESC
933
+ )
934
+ ${tailCte}
935
+ SELECT
936
+ h.id, h.stream, h.version, h.name, h.data, h.created, h.meta,
937
+ a.cnt AS agg_count,
938
+ a.names AS agg_names
939
+ ${tailCols}
940
+ FROM heads h
941
+ LEFT JOIN agg a ON a.stream = h.stream
942
+ ${tailJoin}
943
+ `;
944
+ const res = await this._pool.query(sql, params);
945
+ const out = /* @__PURE__ */ new Map();
946
+ for (const row of res.rows) {
947
+ const stats = {
948
+ head: {
949
+ id: row.id,
950
+ stream: row.stream,
951
+ version: row.version,
952
+ name: row.name,
953
+ data: row.data,
954
+ created: row.created,
955
+ meta: row.meta
956
+ }
957
+ };
958
+ if (wantTail && row.t_id !== void 0 && row.t_id !== null) {
959
+ stats.tail = {
960
+ id: row.t_id,
961
+ stream: row.t_stream,
962
+ version: row.t_version,
963
+ name: row.t_name,
964
+ data: row.t_data,
965
+ created: row.t_created,
966
+ meta: row.t_meta
967
+ };
968
+ }
969
+ if (wantCount) stats.count = row.agg_count;
970
+ if (wantNames) stats.names = row.agg_names;
971
+ out.set(row.stream, stats);
972
+ }
973
+ return out;
974
+ }
778
975
  /**
779
976
  * Implementation of the optional `Store.notify` hook. Bound onto
780
977
  * `this.notify` in the constructor when `config.notify === true`,