@rotorsoft/act-pg 0.22.0 → 0.24.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.
@@ -1,4 +1,4 @@
1
- import type { BlockedLease, Committed, EventMeta, Lease, Message, NotifyDisposer, PrioritizeFilter, Query, QueryStreams, QueryStreamsResult, Schema, Schemas, Store, StoreNotification, StreamPosition } from "@rotorsoft/act";
1
+ import type { BlockedLease, Committed, EventMeta, Lease, Message, NotifyDisposer, Query, QueryStatsOptions, QueryStreams, QueryStreamsResult, Schema, Schemas, Store, StoreNotification, StreamFilter, StreamPosition, StreamStats } from "@rotorsoft/act";
2
2
  import pg from "pg";
3
3
  type Config = Readonly<{
4
4
  schema: string;
@@ -275,7 +275,30 @@ export declare class PostgresStore implements Store {
275
275
  * @param streams - Stream names to reset.
276
276
  * @returns Count of streams that were actually reset.
277
277
  */
278
- reset(streams: string[]): Promise<number>;
278
+ /**
279
+ * Translate a {@link StreamFilter} to a `WHERE` clause fragment and
280
+ * the corresponding parameter values. The fragment never starts with
281
+ * `WHERE` — callers compose it with any other predicates they need.
282
+ * Returns an always-true clause (`true`) when the filter is empty.
283
+ */
284
+ private _filterClause;
285
+ reset(input: string[] | StreamFilter): Promise<number>;
286
+ /**
287
+ * Clear blocked flag (and retry / error / lease state) on streams
288
+ * without touching the `at` watermark. `blocked = true` is always
289
+ * applied, so the return count reflects only streams that were
290
+ * actually flipped — already-unblocked rows, unknown streams, and
291
+ * filter matches that aren't blocked are silently skipped.
292
+ *
293
+ * `retry = -1` matches the InMemoryStore convention: claim() bumps
294
+ * retry on every acquisition, so storing -1 means the first claim
295
+ * after unblock returns retry=0 ("first attempt"). Storing 0 would
296
+ * mis-report the post-recovery attempt as a continuation of the
297
+ * failed sequence. See {@link Store.unblock}.
298
+ *
299
+ * @returns Count of streams that were actually flipped (were blocked).
300
+ */
301
+ unblock(input: string[] | StreamFilter): Promise<number>;
279
302
  /**
280
303
  * Bulk-update priority of streams matching `filter` (ACT-102).
281
304
  *
@@ -290,7 +313,7 @@ export declare class PostgresStore implements Store {
290
313
  *
291
314
  * @returns Count of streams whose priority changed.
292
315
  */
293
- prioritize(filter: PrioritizeFilter, priority: number): Promise<number>;
316
+ prioritize(filter: StreamFilter, priority: number): Promise<number>;
294
317
  /**
295
318
  * Streams subscription positions to a callback, ordered by stream name,
296
319
  * along with the highest event id in the store.
@@ -302,6 +325,46 @@ export declare class PostgresStore implements Store {
302
325
  * @returns `maxEventId` and the `count` of positions emitted.
303
326
  */
304
327
  query_streams(callback: (position: StreamPosition) => void, query?: QueryStreams): Promise<QueryStreamsResult>;
328
+ /**
329
+ * Per-stream aggregated stats — see {@link Store.query_stats}.
330
+ *
331
+ * Two code paths chosen by the requested stats:
332
+ *
333
+ * - **Heads-only path** (no `count`, no `names`): one or two
334
+ * `SELECT DISTINCT ON (stream) ... ORDER BY stream, version DESC|ASC`
335
+ * queries, executed in parallel when `tail: true`. The
336
+ * `(stream, version)` unique index gives index-only access — K rows
337
+ * touched per query (K = matched streams), not N (events).
338
+ * Ordering by `version` (not `id`) is equivalent within a stream
339
+ * (versions are monotonic per stream and events are committed
340
+ * sequentially) and is the column actually indexed.
341
+ *
342
+ * - **Full-scan path** (`count` or `names` set): one CTE materializes
343
+ * the filtered events, then `GROUP BY stream, name` →
344
+ * `jsonb_object_agg(name, n)` for the `names` map plus per-stream
345
+ * `COUNT(*)` for `count`. Heads (and `tails` when requested) come
346
+ * from `DISTINCT ON` over the same CTE — they ride free on the
347
+ * already-paid scan.
348
+ *
349
+ * The stream universe is derived from the events table: filter form
350
+ * matches event-bearing streams (not subscription rows). When the
351
+ * filter sets `source` or `blocked`, the events table is joined
352
+ * against the streams subscription table since those concepts only
353
+ * exist for subscribed streams.
354
+ */
355
+ query_stats<E extends Schemas>(input: string[] | Pick<StreamFilter, "stream" | "stream_exact">, options?: QueryStatsOptions<E>): Promise<Map<string, StreamStats<E>>>;
356
+ /**
357
+ * Cheap path: index-only DISTINCT ON for the head per stream, plus an
358
+ * optional second query (in parallel) for the tail. K rows touched
359
+ * per query, not N events.
360
+ */
361
+ private _queryStatsHeadsOnly;
362
+ /**
363
+ * Full-scan path: one CTE-based query computes the per-stream
364
+ * `COUNT(*)` and `jsonb_object_agg(name, n)` map alongside the head
365
+ * (and tail when requested). All extras share the single events scan.
366
+ */
367
+ private _queryStatsFullScan;
305
368
  /**
306
369
  * Implementation of the optional `Store.notify` hook. Bound onto
307
370
  * `this.notify` in the constructor when `config.notify === true`,
@@ -1 +1 @@
1
- {"version":3,"file":"postgres-store.d.ts","sourceRoot":"","sources":["../../src/postgres-store.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"postgres-store.d.ts","sourceRoot":"","sources":["../../src/postgres-store.ts"],"names":[],"mappings":"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"}
package/dist/index.cjs CHANGED
@@ -632,14 +632,89 @@ var PostgresStore = class {
632
632
  * @param streams - Stream names to reset.
633
633
  * @returns Count of streams that were actually reset.
634
634
  */
635
- async reset(streams) {
636
- if (!streams.length) return 0;
635
+ /**
636
+ * Translate a {@link StreamFilter} to a `WHERE` clause fragment and
637
+ * the corresponding parameter values. The fragment never starts with
638
+ * `WHERE` — callers compose it with any other predicates they need.
639
+ * Returns an always-true clause (`true`) when the filter is empty.
640
+ */
641
+ _filterClause(filter, start) {
642
+ const conditions = [];
643
+ const values = [];
644
+ if (filter.stream !== void 0) {
645
+ values.push(filter.stream);
646
+ conditions.push(
647
+ filter.stream_exact ? `stream = $${start + values.length - 1}` : `stream ~ $${start + values.length - 1}`
648
+ );
649
+ }
650
+ if (filter.source !== void 0) {
651
+ conditions.push(`source IS NOT NULL`);
652
+ values.push(filter.source);
653
+ conditions.push(
654
+ filter.source_exact ? `source = $${start + values.length - 1}` : `source ~ $${start + values.length - 1}`
655
+ );
656
+ }
657
+ if (filter.blocked !== void 0) {
658
+ values.push(filter.blocked);
659
+ conditions.push(`blocked = $${start + values.length - 1}`);
660
+ }
661
+ return {
662
+ clause: conditions.length ? conditions.join(" AND ") : "TRUE",
663
+ values
664
+ };
665
+ }
666
+ async reset(input) {
667
+ const setClause = `SET at = -1, retry = 0, blocked = false, error = NULL,
668
+ leased_by = NULL, leased_until = NULL`;
669
+ if (Array.isArray(input)) {
670
+ if (!input.length) return 0;
671
+ const { rowCount: rowCount2 } = await this._pool.query(
672
+ `UPDATE ${this._fqs} ${setClause} WHERE stream = ANY($1)`,
673
+ [input]
674
+ );
675
+ return rowCount2 ?? 0;
676
+ }
677
+ const { clause, values } = this._filterClause(input, 1);
678
+ const { rowCount } = await this._pool.query(
679
+ `UPDATE ${this._fqs} ${setClause} WHERE ${clause}`,
680
+ values
681
+ );
682
+ return rowCount ?? 0;
683
+ }
684
+ /**
685
+ * Clear blocked flag (and retry / error / lease state) on streams
686
+ * without touching the `at` watermark. `blocked = true` is always
687
+ * applied, so the return count reflects only streams that were
688
+ * actually flipped — already-unblocked rows, unknown streams, and
689
+ * filter matches that aren't blocked are silently skipped.
690
+ *
691
+ * `retry = -1` matches the InMemoryStore convention: claim() bumps
692
+ * retry on every acquisition, so storing -1 means the first claim
693
+ * after unblock returns retry=0 ("first attempt"). Storing 0 would
694
+ * mis-report the post-recovery attempt as a continuation of the
695
+ * failed sequence. See {@link Store.unblock}.
696
+ *
697
+ * @returns Count of streams that were actually flipped (were blocked).
698
+ */
699
+ async unblock(input) {
700
+ const setClause = `SET retry = -1, blocked = false, error = NULL,
701
+ leased_by = NULL, leased_until = NULL`;
702
+ if (Array.isArray(input)) {
703
+ if (!input.length) return 0;
704
+ const { rowCount: rowCount2 } = await this._pool.query(
705
+ `UPDATE ${this._fqs} ${setClause}
706
+ WHERE stream = ANY($1) AND blocked = true`,
707
+ [input]
708
+ );
709
+ return rowCount2 ?? 0;
710
+ }
711
+ const { clause, values } = this._filterClause(
712
+ { ...input, blocked: true },
713
+ 1
714
+ );
637
715
  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]
716
+ `UPDATE ${this._fqs} ${setClause} WHERE ${clause}`,
717
+ values
643
718
  );
644
719
  return rowCount ?? 0;
645
720
  }
@@ -658,27 +733,10 @@ var PostgresStore = class {
658
733
  * @returns Count of streams whose priority changed.
659
734
  */
660
735
  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);
736
+ const { clause, values } = this._filterClause(filter, 2);
737
+ const sql = `UPDATE ${this._fqs} SET priority = $1
738
+ WHERE priority <> $1 AND ${clause}`;
739
+ const { rowCount } = await this._pool.query(sql, [priority, ...values]);
682
740
  return rowCount ?? 0;
683
741
  }
684
742
  /**
@@ -748,6 +806,168 @@ var PostgresStore = class {
748
806
  client.release();
749
807
  }
750
808
  }
809
+ /**
810
+ * Per-stream aggregated stats — see {@link Store.query_stats}.
811
+ *
812
+ * Two code paths chosen by the requested stats:
813
+ *
814
+ * - **Heads-only path** (no `count`, no `names`): one or two
815
+ * `SELECT DISTINCT ON (stream) ... ORDER BY stream, version DESC|ASC`
816
+ * queries, executed in parallel when `tail: true`. The
817
+ * `(stream, version)` unique index gives index-only access — K rows
818
+ * touched per query (K = matched streams), not N (events).
819
+ * Ordering by `version` (not `id`) is equivalent within a stream
820
+ * (versions are monotonic per stream and events are committed
821
+ * sequentially) and is the column actually indexed.
822
+ *
823
+ * - **Full-scan path** (`count` or `names` set): one CTE materializes
824
+ * the filtered events, then `GROUP BY stream, name` →
825
+ * `jsonb_object_agg(name, n)` for the `names` map plus per-stream
826
+ * `COUNT(*)` for `count`. Heads (and `tails` when requested) come
827
+ * from `DISTINCT ON` over the same CTE — they ride free on the
828
+ * already-paid scan.
829
+ *
830
+ * The stream universe is derived from the events table: filter form
831
+ * matches event-bearing streams (not subscription rows). When the
832
+ * filter sets `source` or `blocked`, the events table is joined
833
+ * against the streams subscription table since those concepts only
834
+ * exist for subscribed streams.
835
+ */
836
+ async query_stats(input, options) {
837
+ const exclude = options?.exclude ?? [];
838
+ const wantTail = options?.tail ?? false;
839
+ const wantCount = options?.count ?? false;
840
+ const wantNames = options?.names ?? false;
841
+ const before = options?.before;
842
+ const fullScan = wantCount || wantNames;
843
+ if (Array.isArray(input) && input.length === 0) {
844
+ return /* @__PURE__ */ new Map();
845
+ }
846
+ const where = [];
847
+ const params = [];
848
+ if (Array.isArray(input)) {
849
+ params.push(input);
850
+ where.push(`e.stream = ANY($${params.length})`);
851
+ } else if (input.stream !== void 0) {
852
+ params.push(input.stream);
853
+ where.push(
854
+ input.stream_exact ? `e.stream = $${params.length}` : `e.stream ~ $${params.length}`
855
+ );
856
+ }
857
+ if (exclude.length) {
858
+ params.push(exclude);
859
+ where.push(`e.name <> ALL($${params.length})`);
860
+ }
861
+ if (before !== void 0) {
862
+ params.push(before);
863
+ where.push(`e.id < $${params.length}`);
864
+ }
865
+ const fromClause = `${this._fqt} e`;
866
+ const whereClause = `WHERE ${where.length ? where.join(" AND ") : "TRUE"}`;
867
+ return fullScan ? this._queryStatsFullScan(
868
+ fromClause,
869
+ whereClause,
870
+ params,
871
+ wantTail,
872
+ wantCount,
873
+ wantNames
874
+ ) : this._queryStatsHeadsOnly(fromClause, whereClause, params, wantTail);
875
+ }
876
+ /**
877
+ * Cheap path: index-only DISTINCT ON for the head per stream, plus an
878
+ * optional second query (in parallel) for the tail. K rows touched
879
+ * per query, not N events.
880
+ */
881
+ async _queryStatsHeadsOnly(fromClause, whereClause, params, wantTail) {
882
+ const cols = `e.id, e.stream, e.version, e.name, e.data, e.created, e.meta`;
883
+ const headSql = `SELECT DISTINCT ON (e.stream) ${cols} FROM ${fromClause} ${whereClause} ORDER BY e.stream, e.version DESC`;
884
+ const tailSql = wantTail ? `SELECT DISTINCT ON (e.stream) ${cols} FROM ${fromClause} ${whereClause} ORDER BY e.stream, e.version ASC` : null;
885
+ const [headRes, tailRes] = await Promise.all([
886
+ this._pool.query(headSql, params),
887
+ tailSql ? this._pool.query(tailSql, params) : Promise.resolve(null)
888
+ ]);
889
+ const out = /* @__PURE__ */ new Map();
890
+ for (const row of headRes.rows) {
891
+ out.set(row.stream, { head: row });
892
+ }
893
+ if (tailRes) {
894
+ for (const row of tailRes.rows) {
895
+ out.get(row.stream).tail = row;
896
+ }
897
+ }
898
+ return out;
899
+ }
900
+ /**
901
+ * Full-scan path: one CTE-based query computes the per-stream
902
+ * `COUNT(*)` and `jsonb_object_agg(name, n)` map alongside the head
903
+ * (and tail when requested). All extras share the single events scan.
904
+ */
905
+ async _queryStatsFullScan(fromClause, whereClause, params, wantTail, wantCount, wantNames) {
906
+ const tailCte = wantTail ? `, tails AS (SELECT DISTINCT ON (stream) * FROM ef ORDER BY stream, version ASC)` : "";
907
+ const tailJoin = wantTail ? `LEFT JOIN tails t ON t.stream = h.stream` : "";
908
+ const tailCols = wantTail ? `, t.id AS t_id, t.stream AS t_stream, t.version AS t_version,
909
+ t.name AS t_name, t.data AS t_data, t.created AS t_created, t.meta AS t_meta` : "";
910
+ const sql = `
911
+ WITH ef AS (
912
+ SELECT e.id, e.stream, e.version, e.name, e.data, e.created, e.meta
913
+ FROM ${fromClause}
914
+ ${whereClause}
915
+ ),
916
+ agg AS (
917
+ SELECT stream,
918
+ SUM(n)::int AS cnt,
919
+ jsonb_object_agg(name, n) AS names
920
+ FROM (
921
+ SELECT stream, name, COUNT(*)::int AS n
922
+ FROM ef
923
+ GROUP BY stream, name
924
+ ) t
925
+ GROUP BY stream
926
+ ),
927
+ heads AS (
928
+ SELECT DISTINCT ON (stream) * FROM ef ORDER BY stream, version DESC
929
+ )
930
+ ${tailCte}
931
+ SELECT
932
+ h.id, h.stream, h.version, h.name, h.data, h.created, h.meta,
933
+ a.cnt AS agg_count,
934
+ a.names AS agg_names
935
+ ${tailCols}
936
+ FROM heads h
937
+ LEFT JOIN agg a ON a.stream = h.stream
938
+ ${tailJoin}
939
+ `;
940
+ const res = await this._pool.query(sql, params);
941
+ const out = /* @__PURE__ */ new Map();
942
+ for (const row of res.rows) {
943
+ const stats = {
944
+ head: {
945
+ id: row.id,
946
+ stream: row.stream,
947
+ version: row.version,
948
+ name: row.name,
949
+ data: row.data,
950
+ created: row.created,
951
+ meta: row.meta
952
+ }
953
+ };
954
+ if (wantTail && row.t_id !== void 0 && row.t_id !== null) {
955
+ stats.tail = {
956
+ id: row.t_id,
957
+ stream: row.t_stream,
958
+ version: row.t_version,
959
+ name: row.t_name,
960
+ data: row.t_data,
961
+ created: row.t_created,
962
+ meta: row.t_meta
963
+ };
964
+ }
965
+ if (wantCount) stats.count = row.agg_count;
966
+ if (wantNames) stats.names = row.agg_names;
967
+ out.set(row.stream, stats);
968
+ }
969
+ return out;
970
+ }
751
971
  /**
752
972
  * Implementation of the optional `Store.notify` hook. Bound onto
753
973
  * `this.notify` in the constructor when `config.notify === true`,