pipework 0.285.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/REFERENCE.md +6 -0
  3. package/dist/core/config/load.d.ts +1 -1
  4. package/dist/core/config/load.d.ts.map +1 -1
  5. package/dist/core/config/load.js +6 -1
  6. package/dist/core/config/load.js.map +1 -1
  7. package/dist/core/config/namespace.d.ts +1 -0
  8. package/dist/core/config/namespace.d.ts.map +1 -1
  9. package/dist/core/config/schema.d.ts +3 -1
  10. package/dist/core/config/schema.d.ts.map +1 -1
  11. package/dist/core/config/schema.js +3 -0
  12. package/dist/core/config/schema.js.map +1 -1
  13. package/dist/core/config/statement-observer.d.ts +12 -0
  14. package/dist/core/config/statement-observer.d.ts.map +1 -1
  15. package/dist/core/config/statement-observer.js +26 -1
  16. package/dist/core/config/statement-observer.js.map +1 -1
  17. package/dist/data/db/expressions.d.ts +10 -0
  18. package/dist/data/db/expressions.d.ts.map +1 -1
  19. package/dist/data/db/expressions.js +14 -0
  20. package/dist/data/db/expressions.js.map +1 -1
  21. package/dist/data/db/filter.d.ts +4 -2
  22. package/dist/data/db/filter.d.ts.map +1 -1
  23. package/dist/data/db/filter.js +4 -2
  24. package/dist/data/db/filter.js.map +1 -1
  25. package/dist/data/db/namespace.d.ts +2 -0
  26. package/dist/data/db/namespace.d.ts.map +1 -1
  27. package/dist/data/jsonb/namespace.d.ts +3 -1
  28. package/dist/data/jsonb/namespace.d.ts.map +1 -1
  29. package/dist/data/jsonb/namespace.js +3 -1
  30. package/dist/data/jsonb/namespace.js.map +1 -1
  31. package/dist/data/jsonb/query.d.ts +8 -0
  32. package/dist/data/jsonb/query.d.ts.map +1 -1
  33. package/dist/data/jsonb/query.js +19 -3
  34. package/dist/data/jsonb/query.js.map +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/infra/alerts/index.d.ts +1 -0
  38. package/dist/infra/alerts/index.d.ts.map +1 -1
  39. package/dist/infra/alerts/namespace.d.ts +3 -0
  40. package/dist/infra/alerts/namespace.d.ts.map +1 -1
  41. package/dist/infra/alerts/namespace.js +3 -0
  42. package/dist/infra/alerts/namespace.js.map +1 -1
  43. package/dist/infra/alerts/statement-sources.d.ts +27 -0
  44. package/dist/infra/alerts/statement-sources.d.ts.map +1 -0
  45. package/dist/infra/alerts/statement-sources.js +84 -0
  46. package/dist/infra/alerts/statement-sources.js.map +1 -0
  47. package/package.json +1 -1
  48. package/src/core/config/load.ts +7 -2
  49. package/src/core/config/schema.ts +4 -1
  50. package/src/core/config/statement-observer.ts +32 -0
  51. package/src/data/db/expressions.ts +15 -0
  52. package/src/data/db/filter.ts +4 -2
  53. package/src/data/jsonb/namespace.ts +3 -1
  54. package/src/data/jsonb/query.ts +22 -3
  55. package/src/index.ts +1 -1
  56. package/src/infra/alerts/index.ts +1 -0
  57. package/src/infra/alerts/namespace.ts +3 -0
  58. package/src/infra/alerts/statement-sources.ts +94 -0
@@ -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,
@@ -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. */
@@ -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`${column} - ${keys[0]!}::text`
85
- return sql`${column} - array[${sql.join(keys.map(k => sql`${k}`), sql`, `)}]::text[]`
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
  },
@@ -0,0 +1,94 @@
1
+ // An alert source over settled statements: per `pipe.labelled` label, the p90 of its last N durations against a budget.
2
+
3
+ import type { StatementSettledEvent, StatementSettledFeed } from '../../core/config/statement-observer.js'
4
+ import type { AlertSeverity } from './handle.js'
5
+ import type { AlertCondition, AlertSource } from './sources.js'
6
+
7
+ export interface StatementTimingOptions {
8
+ /** Budget in ms per label: `{ 'gate-read': 1000 }`. A label not named here is never recorded. At least one. */
9
+ readonly budgets: Readonly<Record<string, number>>
10
+ /** N: statements per label the p90 is taken over. The source stays quiet for a label until N have settled. */
11
+ readonly window: number
12
+ /** Default `warning`, as the backlog source. */
13
+ readonly severity?: AlertSeverity
14
+ readonly owner?: string
15
+ readonly runbook?: string
16
+ }
17
+
18
+ /** What `alerts.source.statementTiming` returns: an `AlertSource` that is also passed as `observability.statementTiming`. */
19
+ export type StatementTimingSource = AlertSource & StatementSettledFeed
20
+
21
+ interface Ring { readonly samples: number[]; next: number }
22
+
23
+ /** Nearest rank: the smallest sample that at least 90% of the samples are at or under. */
24
+ function p90(samples: readonly number[]): number {
25
+ const sorted = [...samples].sort((a, b) => a - b)
26
+ return sorted[Math.ceil(sorted.length * 0.9) - 1]!
27
+ }
28
+
29
+ /**
30
+ * Raises `statement-slow:<label>` while the p90 duration of a label's last `window` settled statements is over
31
+ * that label's budget; resolves when it is back under. Statements are fed in-process by
32
+ * `observability.statementTiming` (pass the returned source there as well as to `alerts.create({ sources })`;
33
+ * the app's own `onStatementSettled` still fires). Only the labels in `budgets` are recorded, each in a ring of
34
+ * `window` numbers, so memory is bounded by `window` times the labels named; unlabelled statements and other
35
+ * labels are ignored. Failed statements count, with the time they took. The window is this process's: a tick sees
36
+ * only statements that settled in its own process, and a label that stops being called keeps its last window.
37
+ * Context carries `label`, `p90Ms`, `window` and `budgetMs`.
38
+ */
39
+ export function statementTimingSource(options: StatementTimingOptions): StatementTimingSource {
40
+ const who = 'alerts.source.statementTiming'
41
+ if (!Number.isInteger(options.window) || options.window < 1) {
42
+ throw new Error(`[pipework] ${who} window must be a positive integer number of statements, got ${String(options.window)}.`)
43
+ }
44
+ const entries = Object.entries(options.budgets)
45
+ if (entries.length === 0) throw new Error(`[pipework] ${who} needs budgets: at least one label and its budget in ms.`)
46
+ const budgets = new Map<string, number>()
47
+ for (const [label, budget] of entries) {
48
+ if (label === '') throw new Error(`[pipework] ${who} budgets: a label must not be empty.`)
49
+ if (typeof budget !== 'number' || !Number.isFinite(budget) || budget <= 0) {
50
+ throw new Error(`[pipework] ${who} budget for "${label}" must be a positive number of milliseconds, got ${String(budget)}.`)
51
+ }
52
+ budgets.set(label, budget)
53
+ }
54
+ const size = options.window
55
+ const severity = options.severity ?? 'warning'
56
+ const rings = new Map<string, Ring>()
57
+ let attached = false
58
+ const prefix = 'statement-slow:'
59
+
60
+ return {
61
+ name: 'statement-slow',
62
+ owns: key => key.startsWith(prefix),
63
+ observe(event: StatementSettledEvent): void {
64
+ if (event.label === null || !budgets.has(event.label)) return
65
+ let ring = rings.get(event.label)
66
+ if (ring === undefined) { ring = { samples: [], next: 0 }; rings.set(event.label, ring) }
67
+ if (ring.samples.length < size) ring.samples.push(event.durationMs)
68
+ else { ring.samples[ring.next] = event.durationMs; ring.next = (ring.next + 1) % size }
69
+ },
70
+ attach(): void { attached = true },
71
+ evaluate(): Promise<AlertCondition[]> {
72
+ if (!attached) {
73
+ return Promise.reject(new Error(`[pipework] ${who} was never given to the blueprint: set observability.statementTiming to this source, or it sees no statements.`))
74
+ }
75
+ const out: AlertCondition[] = []
76
+ for (const [label, budget] of budgets) {
77
+ const ring = rings.get(label)
78
+ if (ring === undefined || ring.samples.length < size) continue
79
+ const value = p90(ring.samples)
80
+ if (value <= budget) continue
81
+ out.push({
82
+ key: `${prefix}${label}`,
83
+ severity,
84
+ message: `p90 of the last ${String(size)} "${label}" statements is ${String(value)} ms, over its ${String(budget)} ms budget`,
85
+ subject: { entity: 'statement-label', entityId: label },
86
+ ...(options.owner !== undefined ? { owner: options.owner } : {}),
87
+ ...(options.runbook !== undefined ? { runbook: options.runbook } : {}),
88
+ context: { label, p90Ms: value, window: size, budgetMs: budget },
89
+ })
90
+ }
91
+ return Promise.resolve(out)
92
+ },
93
+ }
94
+ }