@rotorsoft/act-pg 1.4.0 → 1.4.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/README.md +7 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/postgres-store.d.ts +24 -7
- package/dist/@types/postgres-store.d.ts.map +1 -1
- package/dist/index.cjs +107 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +107 -74
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -27,10 +27,10 @@ types.setTypeParser(
|
|
|
27
27
|
var SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
28
28
|
var PG_UNIQUE_VIOLATION = "23505";
|
|
29
29
|
var NOTIFY_CHANNEL_PREFIX = "act_commit";
|
|
30
|
-
function
|
|
30
|
+
function notify_channel(schema, table) {
|
|
31
31
|
return `${NOTIFY_CHANNEL_PREFIX}_${schema}_${table}`;
|
|
32
32
|
}
|
|
33
|
-
function
|
|
33
|
+
function assert_safe_identifier(value, label) {
|
|
34
34
|
if (!SAFE_IDENTIFIER.test(value))
|
|
35
35
|
throw new Error(`Unsafe SQL identifier for ${label}: "${value}"`);
|
|
36
36
|
}
|
|
@@ -63,14 +63,14 @@ var PostgresStore = class {
|
|
|
63
63
|
*/
|
|
64
64
|
_channel;
|
|
65
65
|
/** Active LISTEN client (one per `notify()` subscription). */
|
|
66
|
-
|
|
66
|
+
_listen_client;
|
|
67
67
|
/**
|
|
68
68
|
* Notification listener attached to the active LISTEN client. Tracked
|
|
69
69
|
* separately so the re-subscribe / dispose paths can detach it before
|
|
70
70
|
* destroying the client — without this, a pool that reused the
|
|
71
71
|
* connection would re-fire the stale handler.
|
|
72
72
|
*/
|
|
73
|
-
|
|
73
|
+
_listen_handler;
|
|
74
74
|
/**
|
|
75
75
|
* Cross-process commit subscription. **Present only when
|
|
76
76
|
* `config.notify === true`** — the orchestrator's auto-wire path
|
|
@@ -88,15 +88,15 @@ var PostgresStore = class {
|
|
|
88
88
|
*/
|
|
89
89
|
constructor(config = {}) {
|
|
90
90
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
assert_safe_identifier(this.config.schema, "schema");
|
|
92
|
+
assert_safe_identifier(this.config.table, "table");
|
|
93
93
|
const { schema: _, table: __, ...poolConfig } = this.config;
|
|
94
94
|
this._pool = new Pool(poolConfig);
|
|
95
95
|
this._fqt = `"${this.config.schema}"."${this.config.table}"`;
|
|
96
96
|
this._fqs = `"${this.config.schema}"."${this.config.table}_streams"`;
|
|
97
|
-
this._channel =
|
|
97
|
+
this._channel = notify_channel(this.config.schema, this.config.table);
|
|
98
98
|
if (this.config.notify) {
|
|
99
|
-
this.notify = this.
|
|
99
|
+
this.notify = this._subscribe_notifications.bind(this);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
/**
|
|
@@ -105,7 +105,7 @@ var PostgresStore = class {
|
|
|
105
105
|
* @returns Promise that resolves when all connections are closed
|
|
106
106
|
*/
|
|
107
107
|
async dispose() {
|
|
108
|
-
await this.
|
|
108
|
+
await this._teardown_listen();
|
|
109
109
|
await this._pool.end();
|
|
110
110
|
}
|
|
111
111
|
/**
|
|
@@ -115,16 +115,16 @@ var PostgresStore = class {
|
|
|
115
115
|
* destroying belt-and-braces guards against any future change in
|
|
116
116
|
* pg-pool semantics that could re-issue a half-clean client).
|
|
117
117
|
*/
|
|
118
|
-
async
|
|
119
|
-
if (!this.
|
|
120
|
-
this.
|
|
121
|
-
this.
|
|
118
|
+
async _teardown_listen() {
|
|
119
|
+
if (!this._listen_client) return;
|
|
120
|
+
this._listen_client.removeListener("notification", this._listen_handler);
|
|
121
|
+
this._listen_handler = void 0;
|
|
122
122
|
try {
|
|
123
|
-
await this.
|
|
123
|
+
await this._listen_client.query(`UNLISTEN ${this._channel}`);
|
|
124
124
|
} catch {
|
|
125
125
|
}
|
|
126
|
-
this.
|
|
127
|
-
this.
|
|
126
|
+
this._listen_client.release(true);
|
|
127
|
+
this._listen_client = void 0;
|
|
128
128
|
}
|
|
129
129
|
/**
|
|
130
130
|
* Seed the database with required tables, indexes, and schema for event storage.
|
|
@@ -146,9 +146,13 @@ var PostgresStore = class {
|
|
|
146
146
|
stream varchar(100) COLLATE pg_catalog."default" NOT NULL,
|
|
147
147
|
version int NOT NULL,
|
|
148
148
|
created timestamptz NOT NULL DEFAULT now(),
|
|
149
|
-
meta jsonb
|
|
149
|
+
meta jsonb,
|
|
150
|
+
pii jsonb
|
|
150
151
|
) TABLESPACE pg_default;`
|
|
151
152
|
);
|
|
153
|
+
await client.query(
|
|
154
|
+
`ALTER TABLE ${this._fqt} ADD COLUMN IF NOT EXISTS pii jsonb;`
|
|
155
|
+
);
|
|
152
156
|
await client.query(
|
|
153
157
|
`CREATE UNIQUE INDEX IF NOT EXISTS "${this.config.table}_stream_ix"
|
|
154
158
|
ON ${this._fqt} (stream COLLATE pg_catalog."default", version);`
|
|
@@ -339,12 +343,12 @@ var PostgresStore = class {
|
|
|
339
343
|
expectedVersion
|
|
340
344
|
);
|
|
341
345
|
const committed = [];
|
|
342
|
-
for (const { name, data } of msgs) {
|
|
346
|
+
for (const { name, data, pii } of msgs) {
|
|
343
347
|
version++;
|
|
344
348
|
const sql = `
|
|
345
|
-
INSERT INTO ${this._fqt}(name, data, stream, version, meta)
|
|
346
|
-
VALUES($1, $2, $3, $4, $5) RETURNING *`;
|
|
347
|
-
const vals = [name, data, stream, version, meta];
|
|
349
|
+
INSERT INTO ${this._fqt}(name, data, pii, stream, version, meta)
|
|
350
|
+
VALUES($1, $2, $3, $4, $5, $6) RETURNING *`;
|
|
351
|
+
const vals = [name, data, pii ?? null, stream, version, meta];
|
|
348
352
|
try {
|
|
349
353
|
const { rows } = await client.query(sql, vals);
|
|
350
354
|
committed.push(rows.at(0));
|
|
@@ -399,7 +403,7 @@ var PostgresStore = class {
|
|
|
399
403
|
const client = await this._pool.connect();
|
|
400
404
|
try {
|
|
401
405
|
await client.query("BEGIN");
|
|
402
|
-
const
|
|
406
|
+
const lane_clause = lane !== void 0 ? `AND s.lane = $5` : "";
|
|
403
407
|
const params = lane !== void 0 ? [lagging, leading, by, millis, lane] : [lagging, leading, by, millis];
|
|
404
408
|
const { rows } = await client.query(
|
|
405
409
|
`
|
|
@@ -408,7 +412,7 @@ var PostgresStore = class {
|
|
|
408
412
|
SELECT stream, source, at, priority, lane
|
|
409
413
|
FROM ${this._fqs} s
|
|
410
414
|
WHERE blocked = false
|
|
411
|
-
${
|
|
415
|
+
${lane_clause}
|
|
412
416
|
AND (leased_by IS NULL OR leased_until <= NOW())
|
|
413
417
|
AND (s.at < 0 OR EXISTS (
|
|
414
418
|
SELECT 1 FROM ${this._fqt} e
|
|
@@ -633,7 +637,7 @@ var PostgresStore = class {
|
|
|
633
637
|
* `WHERE` — callers compose it with any other predicates they need.
|
|
634
638
|
* Returns an always-true clause (`true`) when the filter is empty.
|
|
635
639
|
*/
|
|
636
|
-
|
|
640
|
+
_filter_clause(filter, start) {
|
|
637
641
|
const conditions = [];
|
|
638
642
|
const values = [];
|
|
639
643
|
if (filter.stream !== void 0) {
|
|
@@ -663,19 +667,19 @@ var PostgresStore = class {
|
|
|
663
667
|
};
|
|
664
668
|
}
|
|
665
669
|
async reset(input) {
|
|
666
|
-
const
|
|
670
|
+
const set_clause = `SET at = -1, retry = 0, blocked = false, error = NULL,
|
|
667
671
|
leased_by = NULL, leased_until = NULL`;
|
|
668
672
|
if (Array.isArray(input)) {
|
|
669
673
|
if (!input.length) return 0;
|
|
670
674
|
const { rowCount: rowCount2 } = await this._pool.query(
|
|
671
|
-
`UPDATE ${this._fqs} ${
|
|
675
|
+
`UPDATE ${this._fqs} ${set_clause} WHERE stream = ANY($1)`,
|
|
672
676
|
[input]
|
|
673
677
|
);
|
|
674
678
|
return rowCount2 ?? 0;
|
|
675
679
|
}
|
|
676
|
-
const { clause, values } = this.
|
|
680
|
+
const { clause, values } = this._filter_clause(input, 1);
|
|
677
681
|
const { rowCount } = await this._pool.query(
|
|
678
|
-
`UPDATE ${this._fqs} ${
|
|
682
|
+
`UPDATE ${this._fqs} ${set_clause} WHERE ${clause}`,
|
|
679
683
|
values
|
|
680
684
|
);
|
|
681
685
|
return rowCount ?? 0;
|
|
@@ -696,23 +700,23 @@ var PostgresStore = class {
|
|
|
696
700
|
* @returns Count of streams that were actually flipped (were blocked).
|
|
697
701
|
*/
|
|
698
702
|
async unblock(input) {
|
|
699
|
-
const
|
|
703
|
+
const set_clause = `SET retry = -1, blocked = false, error = NULL,
|
|
700
704
|
leased_by = NULL, leased_until = NULL`;
|
|
701
705
|
if (Array.isArray(input)) {
|
|
702
706
|
if (!input.length) return 0;
|
|
703
707
|
const { rowCount: rowCount2 } = await this._pool.query(
|
|
704
|
-
`UPDATE ${this._fqs} ${
|
|
708
|
+
`UPDATE ${this._fqs} ${set_clause}
|
|
705
709
|
WHERE stream = ANY($1) AND blocked = true`,
|
|
706
710
|
[input]
|
|
707
711
|
);
|
|
708
712
|
return rowCount2 ?? 0;
|
|
709
713
|
}
|
|
710
|
-
const { clause, values } = this.
|
|
714
|
+
const { clause, values } = this._filter_clause(
|
|
711
715
|
{ ...input, blocked: true },
|
|
712
716
|
1
|
|
713
717
|
);
|
|
714
718
|
const { rowCount } = await this._pool.query(
|
|
715
|
-
`UPDATE ${this._fqs} ${
|
|
719
|
+
`UPDATE ${this._fqs} ${set_clause} WHERE ${clause}`,
|
|
716
720
|
values
|
|
717
721
|
);
|
|
718
722
|
return rowCount ?? 0;
|
|
@@ -732,7 +736,7 @@ var PostgresStore = class {
|
|
|
732
736
|
* @returns Count of streams whose priority changed.
|
|
733
737
|
*/
|
|
734
738
|
async prioritize(filter, priority) {
|
|
735
|
-
const { clause, values } = this.
|
|
739
|
+
const { clause, values } = this._filter_clause(filter, 2);
|
|
736
740
|
const sql = `UPDATE ${this._fqs} SET priority = $1
|
|
737
741
|
WHERE priority <> $1 AND ${clause}`;
|
|
738
742
|
const { rowCount } = await this._pool.query(sql, [priority, ...values]);
|
|
@@ -839,11 +843,11 @@ var PostgresStore = class {
|
|
|
839
843
|
*/
|
|
840
844
|
async query_stats(input, options) {
|
|
841
845
|
const exclude = options?.exclude ?? [];
|
|
842
|
-
const
|
|
843
|
-
const
|
|
844
|
-
const
|
|
846
|
+
const want_tail = options?.tail ?? false;
|
|
847
|
+
const want_count = options?.count ?? false;
|
|
848
|
+
const want_names = options?.names ?? false;
|
|
845
849
|
const before = options?.before;
|
|
846
|
-
const
|
|
850
|
+
const full_scan = want_count || want_names;
|
|
847
851
|
if (Array.isArray(input) && input.length === 0) {
|
|
848
852
|
return /* @__PURE__ */ new Map();
|
|
849
853
|
}
|
|
@@ -866,29 +870,34 @@ var PostgresStore = class {
|
|
|
866
870
|
params.push(before);
|
|
867
871
|
where.push(`e.id < $${params.length}`);
|
|
868
872
|
}
|
|
869
|
-
const
|
|
870
|
-
const
|
|
871
|
-
return
|
|
872
|
-
|
|
873
|
-
|
|
873
|
+
const from_clause = `${this._fqt} e`;
|
|
874
|
+
const where_clause = `WHERE ${where.length ? where.join(" AND ") : "TRUE"}`;
|
|
875
|
+
return full_scan ? this._query_stats_full_scan(
|
|
876
|
+
from_clause,
|
|
877
|
+
where_clause,
|
|
878
|
+
params,
|
|
879
|
+
want_tail,
|
|
880
|
+
want_count,
|
|
881
|
+
want_names
|
|
882
|
+
) : this._query_stats_heads_only(
|
|
883
|
+
from_clause,
|
|
884
|
+
where_clause,
|
|
874
885
|
params,
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
wantNames
|
|
878
|
-
) : this._queryStatsHeadsOnly(fromClause, whereClause, params, wantTail);
|
|
886
|
+
want_tail
|
|
887
|
+
);
|
|
879
888
|
}
|
|
880
889
|
/**
|
|
881
890
|
* Cheap path: index-only DISTINCT ON for the head per stream, plus an
|
|
882
891
|
* optional second query (in parallel) for the tail. K rows touched
|
|
883
892
|
* per query, not N events.
|
|
884
893
|
*/
|
|
885
|
-
async
|
|
894
|
+
async _query_stats_heads_only(from_clause, where_clause, params, want_tail) {
|
|
886
895
|
const cols = `e.id, e.stream, e.version, e.name, e.data, e.created, e.meta`;
|
|
887
|
-
const
|
|
888
|
-
const
|
|
896
|
+
const head_sql = `SELECT DISTINCT ON (e.stream) ${cols} FROM ${from_clause} ${where_clause} ORDER BY e.stream, e.version DESC`;
|
|
897
|
+
const tail_sql = want_tail ? `SELECT DISTINCT ON (e.stream) ${cols} FROM ${from_clause} ${where_clause} ORDER BY e.stream, e.version ASC` : null;
|
|
889
898
|
const [headRes, tailRes] = await Promise.all([
|
|
890
|
-
this._pool.query(
|
|
891
|
-
|
|
899
|
+
this._pool.query(head_sql, params),
|
|
900
|
+
tail_sql ? this._pool.query(tail_sql, params) : Promise.resolve(null)
|
|
892
901
|
]);
|
|
893
902
|
const out = /* @__PURE__ */ new Map();
|
|
894
903
|
for (const row of headRes.rows) {
|
|
@@ -906,16 +915,16 @@ var PostgresStore = class {
|
|
|
906
915
|
* `COUNT(*)` and `jsonb_object_agg(name, n)` map alongside the head
|
|
907
916
|
* (and tail when requested). All extras share the single events scan.
|
|
908
917
|
*/
|
|
909
|
-
async
|
|
910
|
-
const
|
|
911
|
-
const
|
|
912
|
-
const
|
|
918
|
+
async _query_stats_full_scan(from_clause, where_clause, params, want_tail, want_count, want_names) {
|
|
919
|
+
const tail_cte = want_tail ? `, tails AS (SELECT DISTINCT ON (stream) * FROM ef ORDER BY stream, version ASC)` : "";
|
|
920
|
+
const tail_join = want_tail ? `LEFT JOIN tails t ON t.stream = h.stream` : "";
|
|
921
|
+
const tail_cols = want_tail ? `, t.id AS t_id, t.stream AS t_stream, t.version AS t_version,
|
|
913
922
|
t.name AS t_name, t.data AS t_data, t.created AS t_created, t.meta AS t_meta` : "";
|
|
914
923
|
const sql = `
|
|
915
924
|
WITH ef AS (
|
|
916
925
|
SELECT e.id, e.stream, e.version, e.name, e.data, e.created, e.meta
|
|
917
|
-
FROM ${
|
|
918
|
-
${
|
|
926
|
+
FROM ${from_clause}
|
|
927
|
+
${where_clause}
|
|
919
928
|
),
|
|
920
929
|
agg AS (
|
|
921
930
|
SELECT stream,
|
|
@@ -931,15 +940,15 @@ var PostgresStore = class {
|
|
|
931
940
|
heads AS (
|
|
932
941
|
SELECT DISTINCT ON (stream) * FROM ef ORDER BY stream, version DESC
|
|
933
942
|
)
|
|
934
|
-
${
|
|
943
|
+
${tail_cte}
|
|
935
944
|
SELECT
|
|
936
945
|
h.id, h.stream, h.version, h.name, h.data, h.created, h.meta,
|
|
937
946
|
a.cnt AS agg_count,
|
|
938
947
|
a.names AS agg_names
|
|
939
|
-
${
|
|
948
|
+
${tail_cols}
|
|
940
949
|
FROM heads h
|
|
941
950
|
LEFT JOIN agg a ON a.stream = h.stream
|
|
942
|
-
${
|
|
951
|
+
${tail_join}
|
|
943
952
|
`;
|
|
944
953
|
const res = await this._pool.query(sql, params);
|
|
945
954
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -955,7 +964,7 @@ var PostgresStore = class {
|
|
|
955
964
|
meta: row.meta
|
|
956
965
|
}
|
|
957
966
|
};
|
|
958
|
-
if (
|
|
967
|
+
if (want_tail && row.t_id !== void 0 && row.t_id !== null) {
|
|
959
968
|
stats.tail = {
|
|
960
969
|
id: row.t_id,
|
|
961
970
|
stream: row.t_stream,
|
|
@@ -966,8 +975,8 @@ var PostgresStore = class {
|
|
|
966
975
|
meta: row.t_meta
|
|
967
976
|
};
|
|
968
977
|
}
|
|
969
|
-
if (
|
|
970
|
-
if (
|
|
978
|
+
if (want_count) stats.count = row.agg_count;
|
|
979
|
+
if (want_names) stats.names = row.agg_names;
|
|
971
980
|
out.set(row.stream, stats);
|
|
972
981
|
}
|
|
973
982
|
return out;
|
|
@@ -993,10 +1002,10 @@ var PostgresStore = class {
|
|
|
993
1002
|
* @param handler Called for each cross-process commit notification.
|
|
994
1003
|
* @returns Disposer that releases the LISTEN client.
|
|
995
1004
|
*/
|
|
996
|
-
async
|
|
997
|
-
await this.
|
|
1005
|
+
async _subscribe_notifications(handler) {
|
|
1006
|
+
await this._teardown_listen();
|
|
998
1007
|
const client = await this._pool.connect();
|
|
999
|
-
const
|
|
1008
|
+
const on_notification = (msg) => {
|
|
1000
1009
|
if (msg.channel !== this._channel) return;
|
|
1001
1010
|
if (!msg.payload) return;
|
|
1002
1011
|
let parsed;
|
|
@@ -1033,19 +1042,19 @@ var PostgresStore = class {
|
|
|
1033
1042
|
logger.error(err, "act_commit: handler threw, listener preserved");
|
|
1034
1043
|
}
|
|
1035
1044
|
};
|
|
1036
|
-
client.on("notification",
|
|
1045
|
+
client.on("notification", on_notification);
|
|
1037
1046
|
try {
|
|
1038
1047
|
await client.query(`LISTEN ${this._channel}`);
|
|
1039
1048
|
} catch (err) {
|
|
1040
|
-
client.removeListener("notification",
|
|
1049
|
+
client.removeListener("notification", on_notification);
|
|
1041
1050
|
client.release(true);
|
|
1042
1051
|
throw err;
|
|
1043
1052
|
}
|
|
1044
|
-
this.
|
|
1045
|
-
this.
|
|
1053
|
+
this._listen_client = client;
|
|
1054
|
+
this._listen_handler = on_notification;
|
|
1046
1055
|
return async () => {
|
|
1047
|
-
if (this.
|
|
1048
|
-
await this.
|
|
1056
|
+
if (this._listen_client !== client) return;
|
|
1057
|
+
await this._teardown_listen();
|
|
1049
1058
|
};
|
|
1050
1059
|
}
|
|
1051
1060
|
/**
|
|
@@ -1116,11 +1125,12 @@ var PostgresStore = class {
|
|
|
1116
1125
|
await client.query(`TRUNCATE TABLE ${this._fqs}`);
|
|
1117
1126
|
await driver(async (event) => {
|
|
1118
1127
|
const { rows } = await client.query(
|
|
1119
|
-
`INSERT INTO ${this._fqt}(name, data, stream, version, created, meta)
|
|
1120
|
-
VALUES($1, $2, $3, $4, $5, $6) RETURNING id`,
|
|
1128
|
+
`INSERT INTO ${this._fqt}(name, data, pii, stream, version, created, meta)
|
|
1129
|
+
VALUES($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
|
1121
1130
|
[
|
|
1122
1131
|
event.name,
|
|
1123
1132
|
event.data,
|
|
1133
|
+
event.pii ?? null,
|
|
1124
1134
|
event.stream,
|
|
1125
1135
|
event.version,
|
|
1126
1136
|
event.created,
|
|
@@ -1138,6 +1148,29 @@ var PostgresStore = class {
|
|
|
1138
1148
|
client.release();
|
|
1139
1149
|
}
|
|
1140
1150
|
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Wipe the sensitive-data payload for every event on the stream — the
|
|
1153
|
+
* physical-erasure side of the sensitive-data epic (#566). Sets
|
|
1154
|
+
* `events.pii` to `NULL` for the stream's events; `events.data` and
|
|
1155
|
+
* the rest of the row are never touched.
|
|
1156
|
+
*
|
|
1157
|
+
* Row-level locks (no table lock), bounded by events-per-stream.
|
|
1158
|
+
* Idempotent — a second call on an already-wiped stream returns `0`.
|
|
1159
|
+
*
|
|
1160
|
+
* Disk reclamation is autovacuum-driven; for strict-deletion
|
|
1161
|
+
* jurisdictions the production checklist documents `VACUUM FULL` as
|
|
1162
|
+
* the operator step.
|
|
1163
|
+
*
|
|
1164
|
+
* @param stream Target stream
|
|
1165
|
+
* @returns Count of events whose `pii` was set to `NULL`
|
|
1166
|
+
*/
|
|
1167
|
+
async forget_pii(stream) {
|
|
1168
|
+
const r = await this._pool.query(
|
|
1169
|
+
`UPDATE ${this._fqt} SET pii = NULL WHERE stream = $1 AND pii IS NOT NULL`,
|
|
1170
|
+
[stream]
|
|
1171
|
+
);
|
|
1172
|
+
return r.rowCount ?? 0;
|
|
1173
|
+
}
|
|
1141
1174
|
};
|
|
1142
1175
|
export {
|
|
1143
1176
|
PostgresStore
|