pipework 0.284.0 → 0.286.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/CHANGELOG.md +11 -0
- package/README.md +6 -0
- package/REFERENCE.md +6 -0
- package/dist/core/config/load.d.ts +1 -1
- package/dist/core/config/load.d.ts.map +1 -1
- package/dist/core/config/load.js +6 -1
- package/dist/core/config/load.js.map +1 -1
- package/dist/core/config/namespace.d.ts +1 -0
- package/dist/core/config/namespace.d.ts.map +1 -1
- package/dist/core/config/schema.d.ts +3 -1
- package/dist/core/config/schema.d.ts.map +1 -1
- package/dist/core/config/schema.js +4 -1
- package/dist/core/config/schema.js.map +1 -1
- package/dist/core/config/statement-observer.d.ts +45 -9
- package/dist/core/config/statement-observer.d.ts.map +1 -1
- package/dist/core/config/statement-observer.js +26 -1
- package/dist/core/config/statement-observer.js.map +1 -1
- package/dist/data/db/expressions.d.ts +10 -0
- package/dist/data/db/expressions.d.ts.map +1 -1
- package/dist/data/db/expressions.js +14 -0
- package/dist/data/db/expressions.js.map +1 -1
- package/dist/data/db/filter.d.ts +4 -2
- package/dist/data/db/filter.d.ts.map +1 -1
- package/dist/data/db/filter.js +4 -2
- package/dist/data/db/filter.js.map +1 -1
- package/dist/data/db/namespace.d.ts +3 -1
- package/dist/data/db/namespace.d.ts.map +1 -1
- package/dist/data/db/namespace.js +1 -1
- package/dist/data/db/namespace.js.map +1 -1
- package/dist/data/db/query-watch.d.ts.map +1 -1
- package/dist/data/db/query-watch.js +23 -6
- package/dist/data/db/query-watch.js.map +1 -1
- package/dist/data/jsonb/namespace.d.ts +3 -1
- package/dist/data/jsonb/namespace.d.ts.map +1 -1
- package/dist/data/jsonb/namespace.js +3 -1
- package/dist/data/jsonb/namespace.js.map +1 -1
- package/dist/data/jsonb/query.d.ts +8 -0
- package/dist/data/jsonb/query.d.ts.map +1 -1
- package/dist/data/jsonb/query.js +19 -3
- package/dist/data/jsonb/query.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/infra/alerts/index.d.ts +1 -0
- package/dist/infra/alerts/index.d.ts.map +1 -1
- package/dist/infra/alerts/namespace.d.ts +3 -0
- package/dist/infra/alerts/namespace.d.ts.map +1 -1
- package/dist/infra/alerts/namespace.js +3 -0
- package/dist/infra/alerts/namespace.js.map +1 -1
- package/dist/infra/alerts/statement-sources.d.ts +27 -0
- package/dist/infra/alerts/statement-sources.d.ts.map +1 -0
- package/dist/infra/alerts/statement-sources.js +84 -0
- package/dist/infra/alerts/statement-sources.js.map +1 -0
- package/package.json +1 -1
- package/src/core/config/load.ts +7 -2
- package/src/core/config/schema.ts +5 -2
- package/src/core/config/statement-observer.ts +65 -9
- package/src/data/db/expressions.ts +15 -0
- package/src/data/db/filter.ts +4 -2
- package/src/data/db/namespace.ts +1 -1
- package/src/data/db/query-watch.ts +25 -6
- package/src/data/jsonb/namespace.ts +3 -1
- package/src/data/jsonb/query.ts +22 -3
- package/src/index.ts +1 -1
- package/src/infra/alerts/index.ts +1 -0
- package/src/infra/alerts/namespace.ts +3 -0
- package/src/infra/alerts/statement-sources.ts +94 -0
|
@@ -8,6 +8,7 @@ import { recordFault, recordFaultFor, useFaultTarget, faultsSource } from './fau
|
|
|
8
8
|
import { recordBeatFor } from './beat.js';
|
|
9
9
|
import { deliveryFailedSource, webhookFailedSource, creditsSource } from './subsystem-sources.js';
|
|
10
10
|
import { emailSource, storageSource, accessLinksSource, lockoutsSource } from './infra-sources.js';
|
|
11
|
+
import { statementTimingSource } from './statement-sources.js';
|
|
11
12
|
import { schedulesSource, stepsSource, pipelinesSource, tracesSource } from './async-sources.js';
|
|
12
13
|
/** Alerts namespace — raise and resolve keyed conditions, grouped, delivered to sinks through an outbox, traced. */
|
|
13
14
|
export const alerts = {
|
|
@@ -74,6 +75,8 @@ export const alerts = {
|
|
|
74
75
|
traces: tracesSource,
|
|
75
76
|
/** Raises `fault:<kind>` per kind of swallowed failure recorded by `recordFault` since the last tick (the pipework subsystems record audit-write, quota-record, credits-callback, trace-steps-lost, queue-* and scheduler faults). `kinds` limits it. */
|
|
76
77
|
faults: faultsSource,
|
|
78
|
+
/** Raises `statement-slow:<label>` while the p90 of a `pipe.labelled` label's last `window` settled statements is over that label's budget; give the returned source to `observability.statementTiming` too. */
|
|
79
|
+
statementTiming: statementTimingSource,
|
|
77
80
|
/** Raises `loop-stalled:<name>` while a background loop (scheduler, reaper, claim loop, support drain) has not run or succeeded within its allowance; the tick itself is covered by `pipework alerts stale`. */
|
|
78
81
|
loopStalled: loopStalledSource,
|
|
79
82
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"namespace.js","sourceRoot":"","sources":["../../../src/infra/alerts/namespace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AACtC,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AACpD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,SAAS,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAChM,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AACvF,OAAO,EAAE,aAAa,EAAE,MAAM,WAAW,CAAA;AACzC,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AACjG,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAClG,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAEhG,oHAAoH;AACpH,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,gCAAgC;IAChC,MAAM,EAAE,YAAY;IACpB,0HAA0H;IAC1H,IAAI,EAAE,UAAU;IAChB,wLAAwL;IACxL,YAAY;IACZ,oHAAoH;IACpH,aAAa,EAAE,iBAAiB;IAChC,2DAA2D;IAC3D,OAAO,EAAE,WAAW;IACpB,+DAA+D;IAC/D,MAAM,EAAE,UAAU;IAClB,kJAAkJ;IAClJ,SAAS;IACT,yRAAyR;IACzR,WAAW;IACX,oJAAoJ;IACpJ,cAAc;IACd,6QAA6Q;IAC7Q,cAAc;IACd,6NAA6N;IAC7N,UAAU,EAAE,aAAa;IACzB,MAAM,EAAE;QACN,4EAA4E;QAC5E,UAAU,EAAE,gBAAgB;QAC5B,iFAAiF;QACjF,WAAW,EAAE,iBAAiB;QAC9B,mGAAmG;QACnG,OAAO,EAAE,aAAa;QACtB,yGAAyG;QACzG,cAAc,EAAE,oBAAoB;QACpC,yDAAyD;QACzD,MAAM,EAAE,YAAY;QACpB,6IAA6I;QAC7I,SAAS,EAAE,eAAe;QAC1B,2HAA2H;QAC3H,KAAK,EAAE,WAAW;QAClB,iIAAiI;QACjI,OAAO,EAAE,aAAa;QACtB,0IAA0I;QAC1I,cAAc,EAAE,oBAAoB;QACpC,yJAAyJ;QACzJ,aAAa,EAAE,mBAAmB;QAClC,gIAAgI;QAChI,OAAO,EAAE,aAAa;QACtB,4JAA4J;QAC5J,KAAK,EAAE,WAAW;QAClB,+EAA+E;QAC/E,OAAO,EAAE,aAAa;QACtB,gGAAgG;QAChG,WAAW,EAAE,iBAAiB;QAC9B,oHAAoH;QACpH,QAAQ,EAAE,cAAc;QACxB,kIAAkI;QAClI,SAAS,EAAE,eAAe;QAC1B,iKAAiK;QACjK,KAAK,EAAE,WAAW;QAClB,qLAAqL;QACrL,SAAS,EAAE,eAAe;QAC1B,qKAAqK;QACrK,MAAM,EAAE,YAAY;QACpB,yPAAyP;QACzP,MAAM,EAAE,YAAY;QACpB,gNAAgN;QAChN,WAAW,EAAE,iBAAiB;KAC/B;CACO,CAAA"}
|
|
1
|
+
{"version":3,"file":"namespace.js","sourceRoot":"","sources":["../../../src/infra/alerts/namespace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AACtC,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AACpD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,SAAS,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAChM,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AACvF,OAAO,EAAE,aAAa,EAAE,MAAM,WAAW,CAAA;AACzC,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AACjG,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAClG,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAA;AAC9D,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAEhG,oHAAoH;AACpH,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,gCAAgC;IAChC,MAAM,EAAE,YAAY;IACpB,0HAA0H;IAC1H,IAAI,EAAE,UAAU;IAChB,wLAAwL;IACxL,YAAY;IACZ,oHAAoH;IACpH,aAAa,EAAE,iBAAiB;IAChC,2DAA2D;IAC3D,OAAO,EAAE,WAAW;IACpB,+DAA+D;IAC/D,MAAM,EAAE,UAAU;IAClB,kJAAkJ;IAClJ,SAAS;IACT,yRAAyR;IACzR,WAAW;IACX,oJAAoJ;IACpJ,cAAc;IACd,6QAA6Q;IAC7Q,cAAc;IACd,6NAA6N;IAC7N,UAAU,EAAE,aAAa;IACzB,MAAM,EAAE;QACN,4EAA4E;QAC5E,UAAU,EAAE,gBAAgB;QAC5B,iFAAiF;QACjF,WAAW,EAAE,iBAAiB;QAC9B,mGAAmG;QACnG,OAAO,EAAE,aAAa;QACtB,yGAAyG;QACzG,cAAc,EAAE,oBAAoB;QACpC,yDAAyD;QACzD,MAAM,EAAE,YAAY;QACpB,6IAA6I;QAC7I,SAAS,EAAE,eAAe;QAC1B,2HAA2H;QAC3H,KAAK,EAAE,WAAW;QAClB,iIAAiI;QACjI,OAAO,EAAE,aAAa;QACtB,0IAA0I;QAC1I,cAAc,EAAE,oBAAoB;QACpC,yJAAyJ;QACzJ,aAAa,EAAE,mBAAmB;QAClC,gIAAgI;QAChI,OAAO,EAAE,aAAa;QACtB,4JAA4J;QAC5J,KAAK,EAAE,WAAW;QAClB,+EAA+E;QAC/E,OAAO,EAAE,aAAa;QACtB,gGAAgG;QAChG,WAAW,EAAE,iBAAiB;QAC9B,oHAAoH;QACpH,QAAQ,EAAE,cAAc;QACxB,kIAAkI;QAClI,SAAS,EAAE,eAAe;QAC1B,iKAAiK;QACjK,KAAK,EAAE,WAAW;QAClB,qLAAqL;QACrL,SAAS,EAAE,eAAe;QAC1B,qKAAqK;QACrK,MAAM,EAAE,YAAY;QACpB,yPAAyP;QACzP,MAAM,EAAE,YAAY;QACpB,gNAAgN;QAChN,eAAe,EAAE,qBAAqB;QACtC,gNAAgN;QAChN,WAAW,EAAE,iBAAiB;KAC/B;CACO,CAAA"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { StatementSettledFeed } from '../../core/config/statement-observer.js';
|
|
2
|
+
import type { AlertSeverity } from './handle.js';
|
|
3
|
+
import type { AlertSource } from './sources.js';
|
|
4
|
+
export interface StatementTimingOptions {
|
|
5
|
+
/** Budget in ms per label: `{ 'gate-read': 1000 }`. A label not named here is never recorded. At least one. */
|
|
6
|
+
readonly budgets: Readonly<Record<string, number>>;
|
|
7
|
+
/** N: statements per label the p90 is taken over. The source stays quiet for a label until N have settled. */
|
|
8
|
+
readonly window: number;
|
|
9
|
+
/** Default `warning`, as the backlog source. */
|
|
10
|
+
readonly severity?: AlertSeverity;
|
|
11
|
+
readonly owner?: string;
|
|
12
|
+
readonly runbook?: string;
|
|
13
|
+
}
|
|
14
|
+
/** What `alerts.source.statementTiming` returns: an `AlertSource` that is also passed as `observability.statementTiming`. */
|
|
15
|
+
export type StatementTimingSource = AlertSource & StatementSettledFeed;
|
|
16
|
+
/**
|
|
17
|
+
* Raises `statement-slow:<label>` while the p90 duration of a label's last `window` settled statements is over
|
|
18
|
+
* that label's budget; resolves when it is back under. Statements are fed in-process by
|
|
19
|
+
* `observability.statementTiming` (pass the returned source there as well as to `alerts.create({ sources })`;
|
|
20
|
+
* the app's own `onStatementSettled` still fires). Only the labels in `budgets` are recorded, each in a ring of
|
|
21
|
+
* `window` numbers, so memory is bounded by `window` times the labels named; unlabelled statements and other
|
|
22
|
+
* labels are ignored. Failed statements count, with the time they took. The window is this process's: a tick sees
|
|
23
|
+
* only statements that settled in its own process, and a label that stops being called keeps its last window.
|
|
24
|
+
* Context carries `label`, `p90Ms`, `window` and `budgetMs`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function statementTimingSource(options: StatementTimingOptions): StatementTimingSource;
|
|
27
|
+
//# sourceMappingURL=statement-sources.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"statement-sources.d.ts","sourceRoot":"","sources":["../../../src/infra/alerts/statement-sources.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAyB,oBAAoB,EAAE,MAAM,yCAAyC,CAAA;AAC1G,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAChD,OAAO,KAAK,EAAkB,WAAW,EAAE,MAAM,cAAc,CAAA;AAE/D,MAAM,WAAW,sBAAsB;IACrC,+GAA+G;IAC/G,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IAClD,8GAA8G;IAC9G,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,gDAAgD;IAChD,QAAQ,CAAC,QAAQ,CAAC,EAAE,aAAa,CAAA;IACjC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED,6HAA6H;AAC7H,MAAM,MAAM,qBAAqB,GAAG,WAAW,GAAG,oBAAoB,CAAA;AAUtE;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAuD5F"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// An alert source over settled statements: per `pipe.labelled` label, the p90 of its last N durations against a budget.
|
|
2
|
+
/** Nearest rank: the smallest sample that at least 90% of the samples are at or under. */
|
|
3
|
+
function p90(samples) {
|
|
4
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
5
|
+
return sorted[Math.ceil(sorted.length * 0.9) - 1];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Raises `statement-slow:<label>` while the p90 duration of a label's last `window` settled statements is over
|
|
9
|
+
* that label's budget; resolves when it is back under. Statements are fed in-process by
|
|
10
|
+
* `observability.statementTiming` (pass the returned source there as well as to `alerts.create({ sources })`;
|
|
11
|
+
* the app's own `onStatementSettled` still fires). Only the labels in `budgets` are recorded, each in a ring of
|
|
12
|
+
* `window` numbers, so memory is bounded by `window` times the labels named; unlabelled statements and other
|
|
13
|
+
* labels are ignored. Failed statements count, with the time they took. The window is this process's: a tick sees
|
|
14
|
+
* only statements that settled in its own process, and a label that stops being called keeps its last window.
|
|
15
|
+
* Context carries `label`, `p90Ms`, `window` and `budgetMs`.
|
|
16
|
+
*/
|
|
17
|
+
export function statementTimingSource(options) {
|
|
18
|
+
const who = 'alerts.source.statementTiming';
|
|
19
|
+
if (!Number.isInteger(options.window) || options.window < 1) {
|
|
20
|
+
throw new Error(`[pipework] ${who} window must be a positive integer number of statements, got ${String(options.window)}.`);
|
|
21
|
+
}
|
|
22
|
+
const entries = Object.entries(options.budgets);
|
|
23
|
+
if (entries.length === 0)
|
|
24
|
+
throw new Error(`[pipework] ${who} needs budgets: at least one label and its budget in ms.`);
|
|
25
|
+
const budgets = new Map();
|
|
26
|
+
for (const [label, budget] of entries) {
|
|
27
|
+
if (label === '')
|
|
28
|
+
throw new Error(`[pipework] ${who} budgets: a label must not be empty.`);
|
|
29
|
+
if (typeof budget !== 'number' || !Number.isFinite(budget) || budget <= 0) {
|
|
30
|
+
throw new Error(`[pipework] ${who} budget for "${label}" must be a positive number of milliseconds, got ${String(budget)}.`);
|
|
31
|
+
}
|
|
32
|
+
budgets.set(label, budget);
|
|
33
|
+
}
|
|
34
|
+
const size = options.window;
|
|
35
|
+
const severity = options.severity ?? 'warning';
|
|
36
|
+
const rings = new Map();
|
|
37
|
+
let attached = false;
|
|
38
|
+
const prefix = 'statement-slow:';
|
|
39
|
+
return {
|
|
40
|
+
name: 'statement-slow',
|
|
41
|
+
owns: key => key.startsWith(prefix),
|
|
42
|
+
observe(event) {
|
|
43
|
+
if (event.label === null || !budgets.has(event.label))
|
|
44
|
+
return;
|
|
45
|
+
let ring = rings.get(event.label);
|
|
46
|
+
if (ring === undefined) {
|
|
47
|
+
ring = { samples: [], next: 0 };
|
|
48
|
+
rings.set(event.label, ring);
|
|
49
|
+
}
|
|
50
|
+
if (ring.samples.length < size)
|
|
51
|
+
ring.samples.push(event.durationMs);
|
|
52
|
+
else {
|
|
53
|
+
ring.samples[ring.next] = event.durationMs;
|
|
54
|
+
ring.next = (ring.next + 1) % size;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
attach() { attached = true; },
|
|
58
|
+
evaluate() {
|
|
59
|
+
if (!attached) {
|
|
60
|
+
return Promise.reject(new Error(`[pipework] ${who} was never given to the blueprint: set observability.statementTiming to this source, or it sees no statements.`));
|
|
61
|
+
}
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const [label, budget] of budgets) {
|
|
64
|
+
const ring = rings.get(label);
|
|
65
|
+
if (ring === undefined || ring.samples.length < size)
|
|
66
|
+
continue;
|
|
67
|
+
const value = p90(ring.samples);
|
|
68
|
+
if (value <= budget)
|
|
69
|
+
continue;
|
|
70
|
+
out.push({
|
|
71
|
+
key: `${prefix}${label}`,
|
|
72
|
+
severity,
|
|
73
|
+
message: `p90 of the last ${String(size)} "${label}" statements is ${String(value)} ms, over its ${String(budget)} ms budget`,
|
|
74
|
+
subject: { entity: 'statement-label', entityId: label },
|
|
75
|
+
...(options.owner !== undefined ? { owner: options.owner } : {}),
|
|
76
|
+
...(options.runbook !== undefined ? { runbook: options.runbook } : {}),
|
|
77
|
+
context: { label, p90Ms: value, window: size, budgetMs: budget },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return Promise.resolve(out);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=statement-sources.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"statement-sources.js","sourceRoot":"","sources":["../../../src/infra/alerts/statement-sources.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/core/config/load.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { resolveEnvironment, resolveEnvironmentStrict, resolveDbUrl, resolveTest
|
|
|
4
4
|
import { ConfigError } from './errors.js'
|
|
5
5
|
import { resolveEnvVars } from './env-resolve.js'
|
|
6
6
|
import { consumeConfigBaseDir } from './base-dir.js'
|
|
7
|
-
import type
|
|
7
|
+
import { composeSettledObservers, type StatementObserver, type StatementSettledObserver } from './statement-observer.js'
|
|
8
8
|
|
|
9
9
|
export interface ResolvedObservability {
|
|
10
10
|
readonly slowQueryMs: number
|
|
@@ -247,6 +247,8 @@ function buildResolved(config: Blueprint, environment: Environment, baseDir: str
|
|
|
247
247
|
|
|
248
248
|
const env = Object.freeze(resolveEnvVars((config.env ?? {}) as import('./env-types.js').EnvDefs, environment))
|
|
249
249
|
|
|
250
|
+
config.observability?.statementTiming?.attach()
|
|
251
|
+
|
|
250
252
|
return {
|
|
251
253
|
raw: config,
|
|
252
254
|
environment,
|
|
@@ -261,7 +263,10 @@ function buildResolved(config: Blueprint, environment: Environment, baseDir: str
|
|
|
261
263
|
logAllQueries: config.observability?.logAllQueries ?? false,
|
|
262
264
|
poolMetricsInterval: config.observability?.poolMetricsInterval ?? 0,
|
|
263
265
|
onStatement: config.observability?.onStatement,
|
|
264
|
-
onStatementSettled:
|
|
266
|
+
onStatementSettled: composeSettledObservers([
|
|
267
|
+
...(config.observability?.onStatementSettled !== undefined ? [config.observability.onStatementSettled] : []),
|
|
268
|
+
...(config.observability?.statementTiming !== undefined ? [config.observability.statementTiming.observe] : []),
|
|
269
|
+
]),
|
|
265
270
|
},
|
|
266
271
|
trace: {
|
|
267
272
|
hotRetentionDays: config.trace?.hotRetentionDays ?? 7,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod/v4'
|
|
2
|
-
import type { StatementObserver, StatementSettledObserver } from './statement-observer.js'
|
|
2
|
+
import type { StatementObserver, StatementSettledObserver, StatementSettledFeed } from './statement-observer.js'
|
|
3
3
|
|
|
4
4
|
const SETTING_NAME = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/
|
|
5
5
|
|
|
@@ -187,7 +187,10 @@ const observabilitySchema = z.object({
|
|
|
187
187
|
.describe('Per-statement observer — fires once per statement written to the wire, including driver-issued BEGIN/SAVEPOINT/COMMIT/ROLLBACK'),
|
|
188
188
|
onStatementSettled: z.custom<StatementSettledObserver>(v => typeof v === 'function', { message: 'Expected a function' })
|
|
189
189
|
.optional()
|
|
190
|
-
.describe('Per-statement completion hook — fires once per statement when it settles, with label, durationMs, rows, connectionId and (on failure) a sanitized error'),
|
|
190
|
+
.describe('Per-statement completion hook — fires once per statement when it settles, with label, statementId, statementPrefix, durationMs, rows, connectionId and (on failure) a sanitized error. Covers every statement through pipework\'s query path (pipe(), pipe.* writes, connection.drizzle, transactions, savepoints, the test harness), not the driver\'s own BEGIN/COMMIT, the raw client door or the migration runner\'s connection'),
|
|
191
|
+
statementTiming: z.custom<StatementSettledFeed>(v => typeof v === 'object' && v !== null && typeof (v as { observe?: unknown }).observe === 'function' && typeof (v as { attach?: unknown }).attach === 'function', { message: 'Expected the value returned by alerts.source.statementTiming' })
|
|
192
|
+
.optional()
|
|
193
|
+
.describe('Feeds alerts.source.statementTiming from every settled statement; fires alongside onStatementSettled, which keeps working'),
|
|
191
194
|
})
|
|
192
195
|
|
|
193
196
|
const envVarDefSchema = z.discriminatedUnion('type', [
|
|
@@ -63,14 +63,39 @@ export interface StatementSettledError {
|
|
|
63
63
|
/**
|
|
64
64
|
* One statement as it settles — the completion counterpart to `StatementEvent`.
|
|
65
65
|
* Fields are only ever added to this payload, never reshaped.
|
|
66
|
+
*
|
|
67
|
+
* Fires for every statement through pipework's query path (`pipe()`, `pipe.*`
|
|
68
|
+
* writes, `connection.drizzle`, transactions, savepoints, the test harness), not
|
|
69
|
+
* for the driver's own BEGIN/COMMIT, the raw `client` door or the migration
|
|
70
|
+
* runner's connection: a sum of `durationMs` leaves those out.
|
|
66
71
|
*/
|
|
67
72
|
export interface StatementSettledEvent {
|
|
68
73
|
/**
|
|
69
74
|
* The `pipe.labelled` label in effect when the statement settled; null when
|
|
70
|
-
* none. This is the label alone:
|
|
71
|
-
*
|
|
75
|
+
* none. This is the label alone: it never falls back to the statement text
|
|
76
|
+
* (`statementPrefix` carries that).
|
|
72
77
|
*/
|
|
73
78
|
readonly label: string | null
|
|
79
|
+
/**
|
|
80
|
+
* Which statement shape this was, stable across calls and processes:
|
|
81
|
+
* `v1:` followed by the first 16 hex characters of the SHA-256 of the
|
|
82
|
+
* statement text exactly as sent to the server (parameterized, `$1`, `$2`...
|
|
83
|
+
* placeholders) with every run of whitespace collapsed to one space and the
|
|
84
|
+
* ends trimmed, hashed as UTF-8. It holds no constants and no bound values, so
|
|
85
|
+
* the same statement with different parameter values or different
|
|
86
|
+
* formatting has the same id, and different SQL has a different one.
|
|
87
|
+
* `v1` names this exact definition: a change to the hash, the length or the
|
|
88
|
+
* normalization gets a new prefix (`v2:`), never a silent redefinition, so
|
|
89
|
+
* records keyed on an id never split without the prefix saying so.
|
|
90
|
+
*/
|
|
91
|
+
readonly statementId: string
|
|
92
|
+
/**
|
|
93
|
+
* The first 120 characters of the same whitespace-collapsed statement text
|
|
94
|
+
* (placeholders, no bound values): what `query:slow`, `query:in-flight` and
|
|
95
|
+
* `query:large` log as `sql`. A readable name for `statementId`; two shapes
|
|
96
|
+
* can share a prefix, so key on `statementId`.
|
|
97
|
+
*/
|
|
98
|
+
readonly statementPrefix: string
|
|
74
99
|
/**
|
|
75
100
|
* Wall-clock milliseconds the statement took, measured by the same timer as
|
|
76
101
|
* `query:slow` (so for one statement the two agree exactly).
|
|
@@ -93,13 +118,12 @@ export interface StatementSettledEvent {
|
|
|
93
118
|
}
|
|
94
119
|
|
|
95
120
|
/**
|
|
96
|
-
* Caller-supplied completion hook. Fires once per statement that settles,
|
|
97
|
-
* every statement
|
|
98
|
-
* `
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* connection; `onStatement` is the hook that sees wire truth.
|
|
121
|
+
* Caller-supplied completion hook. Fires once per statement that settles, for
|
|
122
|
+
* every statement through pipework's query path (`pipe()`, `pipe.*` writes,
|
|
123
|
+
* `connection.drizzle`, transactions, savepoints, the test harness), not for the
|
|
124
|
+
* driver's own BEGIN/COMMIT, the raw `client` door or the migration runner's
|
|
125
|
+
* connection; `onStatement` is the hook that sees wire truth. The event names the
|
|
126
|
+
* statement by `statementId` and `statementPrefix`, never by its parameters.
|
|
103
127
|
*
|
|
104
128
|
* Pure observation, fired after the statement has settled and its result is on
|
|
105
129
|
* its way back: a hook that throws, or returns a promise that rejects, is
|
|
@@ -107,3 +131,35 @@ export interface StatementSettledEvent {
|
|
|
107
131
|
* A returned promise is not awaited.
|
|
108
132
|
*/
|
|
109
133
|
export type StatementSettledObserver = (event: StatementSettledEvent) => void | Promise<void>
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Something fed by every settled statement in addition to `observability.onStatementSettled`, passed as
|
|
137
|
+
* `observability.statementTiming` (`alerts.source.statementTiming` returns one). Both it and the app's own hook
|
|
138
|
+
* fire for each statement; neither replaces the other. `attach` is called once when the blueprint resolves, so the
|
|
139
|
+
* feed can tell that it was wired in.
|
|
140
|
+
*/
|
|
141
|
+
export interface StatementSettledFeed {
|
|
142
|
+
readonly observe: StatementSettledObserver
|
|
143
|
+
attach(): void
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The hooks that fire for each settled statement, in order, one throwing never keeping the next from running. */
|
|
147
|
+
export function composeSettledObservers(observers: readonly StatementSettledObserver[]): StatementSettledObserver | undefined {
|
|
148
|
+
if (observers.length === 0) return undefined
|
|
149
|
+
if (observers.length === 1) return observers[0]
|
|
150
|
+
return (event) => {
|
|
151
|
+
const pending: Promise<void>[] = []
|
|
152
|
+
const thrown: unknown[] = []
|
|
153
|
+
for (const observer of observers) {
|
|
154
|
+
try {
|
|
155
|
+
const out = observer(event)
|
|
156
|
+
if (out !== undefined) pending.push(out)
|
|
157
|
+
} catch (err) {
|
|
158
|
+
thrown.push(err)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (thrown.length > 0) throw thrown[0]
|
|
162
|
+
if (pending.length > 0) return Promise.all(pending).then(() => undefined)
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -341,6 +341,21 @@ export function arrayAppend<C extends Column>(col: C, value: ElementOf<C>): SQL<
|
|
|
341
341
|
return sql<C['_']['data']>`array_append(${col}, ${element(col, value)}::${el})`
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
+
/**
|
|
345
|
+
* An array built from expressions: `ARRAY[e1, e2, ...]::<element>[]`, for the second argument of
|
|
346
|
+
* `arrayOverlaps` / `arrayContains` / `arrayContained`:
|
|
347
|
+
* `arrayOverlaps(t.keys, arrayOf(t.keys, concat(o.key, ':x'), concat(o.key, ':*')))`.
|
|
348
|
+
* `like` is an array column that supplies the element type (the one the result is compared with, or any
|
|
349
|
+
* column of the same type); it is read for its type only and does not appear in the SQL.
|
|
350
|
+
* A column or SQL expression element is used as is; a JS value is bound and converted by the element
|
|
351
|
+
* column's driver mapping. With no elements the result is the empty array `ARRAY[]::<element>[]`.
|
|
352
|
+
*/
|
|
353
|
+
export function arrayOf<C extends Column>(like: C, ...elements: Array<ElementOf<C> | Column | SQL>): SQL<C['_']['data']> {
|
|
354
|
+
const el = sql.raw(elementType(like, 'arrayOf'))
|
|
355
|
+
const parts = elements.map(e => (is(e, DrizzleColumn) || is(e, DrizzleSQL) ? e : element(like, e)))
|
|
356
|
+
return sql<C['_']['data']>`ARRAY[${sql.join(parts.map(p => sql`${p}`), sql`, `)}]::${el}[]`
|
|
357
|
+
}
|
|
358
|
+
|
|
344
359
|
/** PostgreSQL `array_remove(col, value)` — drops every element equal to `value`. `value` is bound and cast to the element type. */
|
|
345
360
|
export function arrayRemove<C extends Column>(col: C, value: ElementOf<C>): SQL<C['_']['data']> {
|
|
346
361
|
const el = sql.raw(elementType(col, 'arrayRemove'))
|
package/src/data/db/filter.ts
CHANGED
|
@@ -5,9 +5,9 @@ import {
|
|
|
5
5
|
} from '../query/index.js'
|
|
6
6
|
import { countAgg, countDistinctAgg, sumAgg, sumDistinctAgg, avgAgg, avgDistinctAgg, maxAgg, minAgg, boolAnd, boolOr, arrayAgg, stringAgg, jsonbAgg, jsonbObjectAgg, jsonbBuildObject } from './aggregates.js'
|
|
7
7
|
import { except, exceptAll, intersect, intersectAll, union, unionAll } from '../query/pg-core/index.js'
|
|
8
|
-
import { increment, decrement, add, subtract, coalesce, now, scalar, cast, collate, interval, clockTimestamp, strpos, textContains, startsWith, left, right, length, concat, arrayReplace, arrayHas, splitPart, lower, upper, trim, replace, substring, reverse, lpad, rpad, regexpReplace, regexpMatch, stringToArray, cardinality, arrayAt, arrayPosition, arraySlice, arrayToString, arrayAppend, arrayRemove, dateTrunc, rowNumber, rank, denseRank, over, identifier, withRecursive, caseWhen, walCurrentLsn, walReplayLsn, replicaFresh } from './expressions.js'
|
|
8
|
+
import { increment, decrement, add, subtract, coalesce, now, scalar, cast, collate, interval, clockTimestamp, strpos, textContains, startsWith, left, right, length, concat, arrayReplace, arrayHas, splitPart, lower, upper, trim, replace, substring, reverse, lpad, rpad, regexpReplace, regexpMatch, stringToArray, cardinality, arrayAt, arrayPosition, arraySlice, arrayToString, arrayAppend, arrayOf, arrayRemove, dateTrunc, rowNumber, rank, denseRank, over, identifier, withRecursive, caseWhen, walCurrentLsn, walReplayLsn, replicaFresh } from './expressions.js'
|
|
9
9
|
import { ago, castUuid, castJsonb, castTimestamp, castBoolean, castNumeric, castInteger, castText, nextSequence } from './composed.js'
|
|
10
|
-
import { json as jsonbExtract, pathText as jsonbExtractText, contains as jsonbContains, merge as jsonbMerge, remove as jsonbRemove } from '../jsonb/query.js'
|
|
10
|
+
import { json as jsonbExtract, pathText as jsonbExtractText, contains as jsonbContains, merge as jsonbMerge, concat as jsonbConcat, remove as jsonbRemove } from '../jsonb/query.js'
|
|
11
11
|
import { arrayAny, arrayAll, jsonbElements } from './array-elements.js'
|
|
12
12
|
import { inList, notInList } from './in-list.js'
|
|
13
13
|
|
|
@@ -56,6 +56,7 @@ export const filter = {
|
|
|
56
56
|
arraySlice,
|
|
57
57
|
arrayToString,
|
|
58
58
|
arrayAppend,
|
|
59
|
+
arrayOf,
|
|
59
60
|
arrayRemove,
|
|
60
61
|
dateTrunc,
|
|
61
62
|
rowNumber,
|
|
@@ -81,6 +82,7 @@ export const filter = {
|
|
|
81
82
|
jsonbExtractText,
|
|
82
83
|
jsonbContains,
|
|
83
84
|
jsonbMerge,
|
|
85
|
+
jsonbConcat,
|
|
84
86
|
jsonbRemove,
|
|
85
87
|
arrayAny,
|
|
86
88
|
arrayAll,
|
package/src/data/db/namespace.ts
CHANGED
|
@@ -38,7 +38,7 @@ export const pipe = Object.assign(getDb, {
|
|
|
38
38
|
/** Query operators and aggregates — eq, gt, lt, and, or, count, sum, etc. */
|
|
39
39
|
filter,
|
|
40
40
|
|
|
41
|
-
/** Names every statement `fn` starts, in `query:slow`, `query:large` and `query:in-flight` log lines: `pipe.labelled('servable-footprints', async () => await ...)`. A query builder is lazy: `fn` must await it, not return it, or it runs outside the scope. */
|
|
41
|
+
/** Names every statement `fn` starts, in `query:slow`, `query:large` and `query:in-flight` log lines (as `label`, beside the `sql` prefix): `pipe.labelled('servable-footprints', async () => await ...)`. A query builder is lazy: `fn` must await it, not return it, or it runs outside the scope. */
|
|
42
42
|
labelled,
|
|
43
43
|
|
|
44
44
|
/** Raw statement tag — for a statement pipework does not model. Interpolated values are parameters; `sql.raw` is not. Runs on the caller's connection, so inside a request it is the request transaction. Writes still go through pipe.update / insert / delete, which is what audit hooks. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import postgres from 'postgres'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
2
3
|
import { getLogger } from '../../core/logging/logger.js'
|
|
3
4
|
import { currentQueryLabel } from './query-label.js'
|
|
4
5
|
import { DrizzleQueryError } from '../query/errors.js'
|
|
@@ -7,6 +8,7 @@ import type { StatementWatch } from '../query/postgres-js/session.js'
|
|
|
7
8
|
import type { StatementSettledError, StatementSettledObserver } from '../../core/config/statement-observer.js'
|
|
8
9
|
|
|
9
10
|
const LABEL_SQL_CHARS = 120
|
|
11
|
+
const STATEMENT_ID_HEX_CHARS = 16
|
|
10
12
|
const SAMPLE_TIMEOUT_MS = 2_000
|
|
11
13
|
const PG_TRUNCATED_QUERY_LENGTH = 1_000
|
|
12
14
|
|
|
@@ -82,8 +84,22 @@ function settledError(raw: unknown): StatementSettledError {
|
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
function
|
|
86
|
-
return
|
|
87
|
+
function collapse(sqlText: string): string {
|
|
88
|
+
return sqlText.replace(/\s+/g, ' ').trim()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The first 120 characters of the whitespace-collapsed statement text: the `sql` of the log lines and `statementPrefix`. */
|
|
92
|
+
function sqlPrefix(sqlText: string): string {
|
|
93
|
+
return collapse(sqlText).slice(0, LABEL_SQL_CHARS)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `v1:` + the first 16 hex characters (64 bits) of the SHA-256 of the whitespace-collapsed
|
|
98
|
+
* parameterized text. 64 bits keeps a collision among an app's few thousand statement shapes
|
|
99
|
+
* negligible (birthday bound near 4 billion shapes) while the id stays short in a log line.
|
|
100
|
+
*/
|
|
101
|
+
function statementIdOf(sqlText: string): string {
|
|
102
|
+
return `v1:${createHash('sha256').update(collapse(sqlText)).digest('hex').slice(0, STATEMENT_ID_HEX_CHARS)}`
|
|
87
103
|
}
|
|
88
104
|
|
|
89
105
|
/**
|
|
@@ -157,7 +173,7 @@ export function createStatementWatch(options: StatementWatchOptions): StatementW
|
|
|
157
173
|
check('sqlChars', b.sqlChars, () => sqlText.length)
|
|
158
174
|
check('arms', b.arms, () => setOperationArms(sqlText))
|
|
159
175
|
check('rows', b.rows, () => (outcome !== undefined && 'result' in outcome ? rowsOf(outcome.result) : 0))
|
|
160
|
-
if (over.length > 0) getLogger().warn({ label:
|
|
176
|
+
if (over.length > 0) getLogger().warn({ label: currentQueryLabel() ?? null, sql: sqlPrefix(sqlText), database, over, ...measured }, 'query:large')
|
|
161
177
|
} catch { /* a measurement must never affect the statement */ }
|
|
162
178
|
}
|
|
163
179
|
|
|
@@ -170,13 +186,14 @@ export function createStatementWatch(options: StatementWatchOptions): StatementW
|
|
|
170
186
|
let settled = false
|
|
171
187
|
if (inFlightQueryMs !== undefined) {
|
|
172
188
|
const log = getLogger()
|
|
173
|
-
const label =
|
|
189
|
+
const label = currentQueryLabel() ?? null
|
|
190
|
+
const sql = sqlPrefix(sqlText)
|
|
174
191
|
interval = setInterval(() => {
|
|
175
192
|
const elapsedS = Math.round((Date.now() - startedMs) / 100) / 10
|
|
176
193
|
void sampleBackend(sqlText, startedMs).then(backend => {
|
|
177
194
|
if (settled) return
|
|
178
195
|
log.warn({
|
|
179
|
-
label, elapsedS, database,
|
|
196
|
+
label, sql, elapsedS, database,
|
|
180
197
|
pid: backend.pid, state: backend.state,
|
|
181
198
|
waitEventType: backend.waitEventType, waitEvent: backend.waitEvent,
|
|
182
199
|
}, 'query:in-flight')
|
|
@@ -196,7 +213,7 @@ export function createStatementWatch(options: StatementWatchOptions): StatementW
|
|
|
196
213
|
if (interval !== undefined) clearInterval(interval)
|
|
197
214
|
const durationMs = Date.now() - startedMs
|
|
198
215
|
if (slowQueryMs > 0 && durationMs > slowQueryMs) {
|
|
199
|
-
getLogger().warn({ label:
|
|
216
|
+
getLogger().warn({ label: currentQueryLabel() ?? null, sql: sqlPrefix(sqlText), durationMs, database }, 'query:slow')
|
|
200
217
|
}
|
|
201
218
|
const parameters = pendingQuery === undefined ? undefined : (pendingQuery as { parameters?: unknown }).parameters
|
|
202
219
|
if (watchesSize) warnIfLarge(sqlText, parameters, outcome, budgets)
|
|
@@ -204,6 +221,8 @@ export function createStatementWatch(options: StatementWatchOptions): StatementW
|
|
|
204
221
|
const connectionId = typeof parameters === 'object' && parameters !== null ? connections?.get(parameters) ?? null : null
|
|
205
222
|
fireSettled({
|
|
206
223
|
label: currentQueryLabel() ?? null,
|
|
224
|
+
statementId: statementIdOf(sqlText),
|
|
225
|
+
statementPrefix: sqlPrefix(sqlText),
|
|
207
226
|
durationMs,
|
|
208
227
|
rows: 'result' in outcome ? rowsOf(outcome.result) : 0,
|
|
209
228
|
connectionId,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { contains, merge, remove, containedBy, hasKey, text, json, path, pathText } from './query.js'
|
|
1
|
+
import { contains, merge, concat, remove, containedBy, hasKey, text, json, path, pathText } from './query.js'
|
|
2
2
|
import { boolean, number, integer } from './composed.js'
|
|
3
3
|
|
|
4
4
|
/** JSONB query operators — typed helpers for PostgreSQL JSONB containment, key checks, and field extraction. */
|
|
@@ -7,6 +7,8 @@ export const jsonb = {
|
|
|
7
7
|
contains,
|
|
8
8
|
/** Merges an object into a JSONB column in `.set()`: `coalesce(col, '{}') || value`. Top-level keys overwrite; others are kept. */
|
|
9
9
|
merge,
|
|
10
|
+
/** Appends to a JSONB column in `.set()`: `col || $1::jsonb`, value bound as jsonb. An array appends its elements; a null column stays null. */
|
|
11
|
+
concat,
|
|
10
12
|
/** Removes top-level keys from a JSONB column in `.set()`: `col - 'key'`. Composes as the column of `jsonb.merge`. */
|
|
11
13
|
remove,
|
|
12
14
|
/** The <@ contained-by operator — tests whether a JSONB column is contained by the given value. */
|
package/src/data/jsonb/query.ts
CHANGED
|
@@ -43,6 +43,25 @@ export function pathText(column: Column | SQL, ...keys: string[]): SQL<string> {
|
|
|
43
43
|
return text(intermediate, finalKey)
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Appends to a JSONB column for `.set()`, typically in `onConflictDoUpdate`: `col || $1::jsonb`, with `value`
|
|
48
|
+
* serialized and bound as jsonb. Appending an array to an array column adds its elements (`[1] || [2]` is `[1, 2]`);
|
|
49
|
+
* an object merges its top-level keys. The append happens in the statement, so concurrent upserts each
|
|
50
|
+
* add their element and none is lost. A null column stays null (SQL `||`): seed the column on insert, or
|
|
51
|
+
* wrap it in `coalesce(col, '[]'::jsonb)`.
|
|
52
|
+
*/
|
|
53
|
+
export function concat(column: Column | SQL, value: unknown): SQL {
|
|
54
|
+
const json: unknown = JSON.stringify(value)
|
|
55
|
+
if (typeof json !== 'string') {
|
|
56
|
+
throw new Error(
|
|
57
|
+
'[pipework] jsonb.concat() needs a JSON-serializable value, got ' + typeof value + '.\n\n' +
|
|
58
|
+
' undefined, functions and symbols have no JSON form.\n' +
|
|
59
|
+
" Fix: pass a value JSON can hold: jsonb.concat(col, [{ id: 1 }]).\n",
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
return sql`(${column} || ${json}::jsonb)`
|
|
63
|
+
}
|
|
64
|
+
|
|
46
65
|
/**
|
|
47
66
|
* Merges `value` into a JSONB column for `.set()`: `coalesce(col, '{}'::jsonb) || value::jsonb`. Top-level keys
|
|
48
67
|
* in `value` overwrite; others are kept; a null column becomes `value`. The merge happens in the statement,
|
|
@@ -57,7 +76,7 @@ export function merge(column: Column | SQL, value: Record<string, unknown>): SQL
|
|
|
57
76
|
" Fix: pass an object: jsonb.merge(col, { key: 'value' }).\n",
|
|
58
77
|
)
|
|
59
78
|
}
|
|
60
|
-
return sql`coalesce(${column}, '{}'::jsonb) || ${JSON.stringify(value)}::jsonb`
|
|
79
|
+
return sql`(coalesce(${column}, '{}'::jsonb) || ${JSON.stringify(value)}::jsonb)`
|
|
61
80
|
}
|
|
62
81
|
|
|
63
82
|
/**
|
|
@@ -81,6 +100,6 @@ export function remove(column: Column | SQL, ...keys: string[]): SQL {
|
|
|
81
100
|
)
|
|
82
101
|
}
|
|
83
102
|
}
|
|
84
|
-
if (keys.length === 1) return sql
|
|
85
|
-
return sql
|
|
103
|
+
if (keys.length === 1) return sql`(${column} - ${keys[0]!}::text)`
|
|
104
|
+
return sql`(${column} - array[${sql.join(keys.map(k => sql`${k}`), sql`, `)}]::text[])`
|
|
86
105
|
}
|
package/src/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type { TimeoutOptions } from './data/db/timeouts.js'
|
|
|
21
21
|
// sanctioned door to a raw connection; keeps the WRAPPED_PACKAGES postgres ban airtight.
|
|
22
22
|
export { tap } from './data/db/pool.js'
|
|
23
23
|
export type { ManagedConnection, ConnectOptions } from './data/db/pool.js'
|
|
24
|
-
export type { StatementObserver, StatementEvent, StatementSettledObserver, StatementSettledEvent, StatementSettledError } from './core/config/statement-observer.js'
|
|
24
|
+
export type { StatementObserver, StatementEvent, StatementSettledObserver, StatementSettledEvent, StatementSettledError, StatementSettledFeed } from './core/config/statement-observer.js'
|
|
25
25
|
|
|
26
26
|
export type { DefinedTable, ExternalTable, DomainDefinition, DefinitionMethods, FieldDescriptor, FieldModifiers, FieldMeta, DefineOptions,
|
|
27
27
|
FactoryBuilder, FactoryOptions, TraitDefinition, FieldColumn, ProjectedColumns,
|
|
@@ -10,3 +10,4 @@ export type { AlertsHttpOptions } from './http.js'
|
|
|
10
10
|
export { clientFaults } from './client-faults.js'
|
|
11
11
|
export type { ClientFaultsOptions, ClientFaultReport } from './client-faults.js'
|
|
12
12
|
export type { RequestFaultsOptions } from './request-faults.js'
|
|
13
|
+
export type { StatementTimingOptions, StatementTimingSource } from './statement-sources.js'
|
|
@@ -8,6 +8,7 @@ import { recordFault, recordFaultFor, useFaultTarget, faultsSource } from './fau
|
|
|
8
8
|
import { recordBeatFor } from './beat.js'
|
|
9
9
|
import { deliveryFailedSource, webhookFailedSource, creditsSource } from './subsystem-sources.js'
|
|
10
10
|
import { emailSource, storageSource, accessLinksSource, lockoutsSource } from './infra-sources.js'
|
|
11
|
+
import { statementTimingSource } from './statement-sources.js'
|
|
11
12
|
import { schedulesSource, stepsSource, pipelinesSource, tracesSource } from './async-sources.js'
|
|
12
13
|
|
|
13
14
|
/** Alerts namespace — raise and resolve keyed conditions, grouped, delivered to sinks through an outbox, traced. */
|
|
@@ -75,6 +76,8 @@ export const alerts = {
|
|
|
75
76
|
traces: tracesSource,
|
|
76
77
|
/** Raises `fault:<kind>` per kind of swallowed failure recorded by `recordFault` since the last tick (the pipework subsystems record audit-write, quota-record, credits-callback, trace-steps-lost, queue-* and scheduler faults). `kinds` limits it. */
|
|
77
78
|
faults: faultsSource,
|
|
79
|
+
/** Raises `statement-slow:<label>` while the p90 of a `pipe.labelled` label's last `window` settled statements is over that label's budget; give the returned source to `observability.statementTiming` too. */
|
|
80
|
+
statementTiming: statementTimingSource,
|
|
78
81
|
/** Raises `loop-stalled:<name>` while a background loop (scheduler, reaper, claim loop, support drain) has not run or succeeded within its allowance; the tick itself is covered by `pipework alerts stale`. */
|
|
79
82
|
loopStalled: loopStalledSource,
|
|
80
83
|
},
|