pipework 0.282.0 → 0.283.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 (47) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/core/config/load.d.ts +3 -1
  3. package/dist/core/config/load.d.ts.map +1 -1
  4. package/dist/core/config/load.js +1 -0
  5. package/dist/core/config/load.js.map +1 -1
  6. package/dist/core/config/namespace.d.ts +1 -0
  7. package/dist/core/config/namespace.d.ts.map +1 -1
  8. package/dist/core/config/schema.d.ts +3 -1
  9. package/dist/core/config/schema.d.ts.map +1 -1
  10. package/dist/core/config/schema.js +3 -0
  11. package/dist/core/config/schema.js.map +1 -1
  12. package/dist/core/config/statement-observer.d.ts +58 -0
  13. package/dist/core/config/statement-observer.d.ts.map +1 -1
  14. package/dist/core/surface/types.d.ts +1 -0
  15. package/dist/core/surface/types.d.ts.map +1 -1
  16. package/dist/data/db/instrumentation.d.ts +6 -4
  17. package/dist/data/db/instrumentation.d.ts.map +1 -1
  18. package/dist/data/db/instrumentation.js +12 -14
  19. package/dist/data/db/instrumentation.js.map +1 -1
  20. package/dist/data/db/pool.js +4 -4
  21. package/dist/data/db/pool.js.map +1 -1
  22. package/dist/data/db/query-watch.d.ts +8 -0
  23. package/dist/data/db/query-watch.d.ts.map +1 -1
  24. package/dist/data/db/query-watch.js +70 -2
  25. package/dist/data/db/query-watch.js.map +1 -1
  26. package/dist/data/db/raw-client-warning.js +1 -1
  27. package/dist/data/db/raw-client-warning.js.map +1 -1
  28. package/dist/data/query/postgres-js/session.d.ts +1 -1
  29. package/dist/data/query/postgres-js/session.d.ts.map +1 -1
  30. package/dist/data/query/postgres-js/session.js +16 -9
  31. package/dist/data/query/postgres-js/session.js.map +1 -1
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/test/plugin.js +6 -6
  35. package/dist/test/plugin.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/core/config/load.ts +4 -1
  38. package/src/core/config/schema.ts +4 -1
  39. package/src/core/config/statement-observer.ts +61 -0
  40. package/src/core/surface/types.ts +1 -0
  41. package/src/data/db/instrumentation.ts +15 -14
  42. package/src/data/db/pool.ts +4 -4
  43. package/src/data/db/query-watch.ts +72 -3
  44. package/src/data/db/raw-client-warning.ts +1 -1
  45. package/src/data/query/postgres-js/session.ts +20 -11
  46. package/src/index.ts +1 -1
  47. package/src/test/plugin.ts +6 -6
@@ -166,7 +166,10 @@ export function createManagedConnection(
166
166
  observability?: ResolvedObservability,
167
167
  applicationName?: string,
168
168
  ): ManagedConnection {
169
- const baseDebug = observability ? createDebugFn(observability) : undefined
169
+ const statementWatch = observability
170
+ ? createStatementWatch({ database: config.name, url: config.url, slowQueryMs: observability.slowQueryMs, inFlightQueryMs: observability.inFlightQueryMs, onSettled: observability.onStatementSettled })
171
+ : undefined
172
+ const baseDebug = observability ? createDebugFn(observability, statementWatch?.noteConnection) : undefined
170
173
  const slot: ProbeSlot | undefined = baseDebug ? { current: null } : undefined
171
174
  const debug = baseDebug && slot
172
175
  ? ((connection, query, parameters) => {
@@ -187,9 +190,6 @@ export function createManagedConnection(
187
190
  const observerOnly = observability !== undefined && isObserverOnly(observability)
188
191
  if (observerOnly) markObservedConnection(client)
189
192
 
190
- const statementWatch = observability
191
- ? createStatementWatch({ database: config.name, url: config.url, slowQueryMs: observability.slowQueryMs, inFlightQueryMs: observability.inFlightQueryMs })
192
- : undefined
193
193
  const db = splitLargeInserts(drizzle(client, statementWatch ? { statementWatch: statementWatch.watch } : {}))
194
194
  const canceller = createCanceller(config.url)
195
195
 
@@ -1,7 +1,9 @@
1
1
  import postgres from 'postgres'
2
2
  import { getLogger } from '../../core/logging/logger.js'
3
3
  import { currentQueryLabel } from './query-label.js'
4
+ import { DrizzleQueryError } from '../query/errors.js'
4
5
  import type { StatementWatch } from '../query/postgres-js/session.js'
6
+ import type { StatementSettledError, StatementSettledObserver } from '../../core/config/statement-observer.js'
5
7
 
6
8
  const LABEL_SQL_CHARS = 120
7
9
  const SAMPLE_TIMEOUT_MS = 2_000
@@ -23,13 +25,42 @@ export interface StatementWatchOptions {
23
25
  readonly slowQueryMs: number
24
26
  /** Undefined: no timer is ever created. */
25
27
  readonly inFlightQueryMs: number | undefined
28
+ /** `observability.onStatementSettled`; undefined: nothing extra is tracked or fired. */
29
+ readonly onSettled?: StatementSettledObserver | undefined
26
30
  }
27
31
 
28
32
  export interface StatementWatchHandle {
29
33
  readonly watch: StatementWatch
34
+ /**
35
+ * Pass to `createDebugFn` for the same pool: the driver's debug hook is the only place that
36
+ * learns a statement's connection. Undefined when no `onSettled` hook is configured.
37
+ */
38
+ readonly noteConnection: ((connectionId: number, parameters: readonly unknown[]) => void) | undefined
30
39
  close(): Promise<void>
31
40
  }
32
41
 
42
+ /** Rows a settled statement returned, or affected for a write: the driver's command-tag count, else the row count. */
43
+ function rowsOf(result: unknown): number {
44
+ if (typeof result !== 'object' || result === null) return 0
45
+ const count = (result as { count?: unknown }).count
46
+ if (typeof count === 'number') return count
47
+ return Array.isArray(result) ? result.length : 0
48
+ }
49
+
50
+ /** Name, message and code only: the driver's error object carries the statement text and bind parameters. */
51
+ function settledError(raw: unknown): StatementSettledError {
52
+ // Drizzle wraps a driver failure in an error whose message is `Failed query: <sql>\nparams: <values>`: report the cause.
53
+ let err = raw
54
+ while (err instanceof DrizzleQueryError && err.cause !== undefined) err = err.cause
55
+ if (typeof err !== 'object' || err === null) return { name: 'Error', message: String(err) }
56
+ const { name, message, code } = err as { name?: unknown; message?: unknown; code?: unknown }
57
+ return {
58
+ name: typeof name === 'string' ? name : 'Error',
59
+ message: typeof message === 'string' ? message : '',
60
+ ...(typeof code === 'string' ? { code } : {}),
61
+ }
62
+ }
63
+
33
64
  function labelFor(sqlText: string): string {
34
65
  return currentQueryLabel() ?? sqlText.replace(/\s+/g, ' ').trim().slice(0, LABEL_SQL_CHARS)
35
66
  }
@@ -41,7 +72,10 @@ function labelFor(sqlText: string): string {
41
72
  * dedicated connection, never the statement's own and never the app pool.
42
73
  */
43
74
  export function createStatementWatch(options: StatementWatchOptions): StatementWatchHandle {
44
- const { database, url, slowQueryMs, inFlightQueryMs } = options
75
+ const { database, url, slowQueryMs, inFlightQueryMs, onSettled } = options
76
+ // Keyed on the parameter array the driver builds for a statement: the same array reaches the
77
+ // debug hook (with the connection ordinal) and sits on the pending query the session hands us.
78
+ const connections = onSettled ? new WeakMap<object, number>() : undefined
45
79
  let sampler: postgres.Sql | undefined
46
80
  let sampling = false
47
81
  let closed = false
@@ -72,8 +106,24 @@ export function createStatementWatch(options: StatementWatchOptions): StatementW
72
106
  return Promise.race([run, timeout]).catch(() => UNKNOWN_BACKEND).finally(() => { clearTimeout(timer) })
73
107
  }
74
108
 
75
- const watch: StatementWatch = async <T>(sqlText: string, run: () => Promise<T>): Promise<T> => {
109
+ function fireSettled(event: Parameters<StatementSettledObserver>[0]): void {
110
+ if (!onSettled) return
111
+ const failed = (err: unknown): void => {
112
+ getLogger().warn({ err, database }, 'statement:settled-hook-failed')
113
+ }
114
+ try {
115
+ const out = onSettled(event)
116
+ if (out !== undefined && typeof (out as Promise<void>).then === 'function') void Promise.resolve(out).catch(failed)
117
+ } catch (err) {
118
+ failed(err)
119
+ }
120
+ }
121
+
122
+ const watch: StatementWatch = async <T>(sqlText: string, run: (seen: (pending: object) => void) => Promise<T>): Promise<T> => {
76
123
  const startedMs = Date.now()
124
+ let pendingQuery: object | undefined
125
+ let outcome: { result: unknown } | { error: unknown } | undefined
126
+ const seen = (pending: object): void => { pendingQuery = pending }
77
127
  let interval: NodeJS.Timeout | undefined
78
128
  let settled = false
79
129
  if (inFlightQueryMs !== undefined) {
@@ -93,7 +143,12 @@ export function createStatementWatch(options: StatementWatchOptions): StatementW
93
143
  interval.unref()
94
144
  }
95
145
  try {
96
- return await run()
146
+ const result = await run(seen)
147
+ if (onSettled) outcome = { result }
148
+ return result
149
+ } catch (err) {
150
+ if (onSettled) outcome = { error: err }
151
+ throw err
97
152
  } finally {
98
153
  settled = true
99
154
  if (interval !== undefined) clearInterval(interval)
@@ -101,11 +156,25 @@ export function createStatementWatch(options: StatementWatchOptions): StatementW
101
156
  if (slowQueryMs > 0 && durationMs > slowQueryMs) {
102
157
  getLogger().warn({ label: labelFor(sqlText), durationMs, database }, 'query:slow')
103
158
  }
159
+ if (onSettled && outcome !== undefined) {
160
+ const parameters = pendingQuery === undefined ? undefined : (pendingQuery as { parameters?: unknown }).parameters
161
+ const connectionId = typeof parameters === 'object' && parameters !== null ? connections?.get(parameters) ?? null : null
162
+ fireSettled({
163
+ label: currentQueryLabel() ?? null,
164
+ durationMs,
165
+ rows: 'result' in outcome ? rowsOf(outcome.result) : 0,
166
+ connectionId,
167
+ ...('error' in outcome ? { error: settledError(outcome.error) } : {}),
168
+ })
169
+ }
104
170
  }
105
171
  }
106
172
 
107
173
  return {
108
174
  watch,
175
+ noteConnection: connections
176
+ ? (connectionId, parameters) => { connections.set(parameters, connectionId) }
177
+ : undefined,
109
178
  async close() {
110
179
  closed = true
111
180
  const client = sampler
@@ -23,7 +23,7 @@ export function warnRawClientIsUnsanitized(database: string): void {
23
23
 
24
24
  getBaseLogger().warn(
25
25
  { database },
26
- '[pipework] raw .client read on a connection with observability.onStatement configured. ' +
26
+ '[pipework] raw .client read on a connection with observability.onStatement or onStatementSettled configured. ' +
27
27
  'Queries issued through .client bypass pipework\'s driver error boundary, so their errors ' +
28
28
  'serialize bind parameters (postgres.js marks query/parameters/args enumerable whenever its ' +
29
29
  'debug hook is installed). Use connection.drizzle — or pipe() inside a managed context — to ' +
@@ -16,10 +16,19 @@ import { type Assume, mapResultRow } from '../utils.js';
16
16
  import { propagateObserved, throughErrorBoundary } from './driver-error-boundary.js';
17
17
 
18
18
  /** Wraps one statement's execution; installed by the pool to time it and report it while it runs. */
19
- export type StatementWatch = <T>(query: string, run: () => Promise<T>) => Promise<T>;
19
+ export type StatementWatch = <T>(query: string, run: (seen: (pending: object) => void) => Promise<T>) => Promise<T>;
20
20
 
21
- function watched<T>(watch: StatementWatch | undefined, query: string, run: () => Promise<T>): Promise<T> {
22
- return watch === undefined ? run() : watch(query, run);
21
+ function watched<T>(watch: StatementWatch | undefined, query: string, run: (seen: (pending: object) => void) => Promise<T>): Promise<T> {
22
+ return watch === undefined ? run(noteSeen) : watch(query, run);
23
+ }
24
+
25
+ function noteSeen(): void {}
26
+
27
+ /** Starts the statement and tells the watch which pending query it is, so it can learn the connection it runs on. */
28
+ function startUnsafe(client: Sql, query: string, params: unknown[], seen: (pending: object) => void) {
29
+ const pending = client.unsafe(query, params as any[]);
30
+ seen(pending);
31
+ return pending;
23
32
  }
24
33
 
25
34
  export class PostgresJsPreparedQuery<T extends PreparedQueryConfig> extends PgPreparedQuery<T> {
@@ -58,8 +67,8 @@ export class PostgresJsPreparedQuery<T extends PreparedQueryConfig> extends PgPr
58
67
  const { fields, queryString: query, client, joinsNotNullableMap, customResultMapper, watch } = this;
59
68
  if (!fields && !customResultMapper) {
60
69
  return tracer.startActiveSpan('drizzle.driver.execute', () => {
61
- return throughErrorBoundary(client, () => watched(watch, query, () => this.queryWithCache(query, params, async () => {
62
- return await client.unsafe(query, params as any[]);
70
+ return throughErrorBoundary(client, () => watched(watch, query, (seen) => this.queryWithCache(query, params, async () => {
71
+ return await startUnsafe(client, query, params, seen);
63
72
  })));
64
73
  });
65
74
  }
@@ -69,8 +78,8 @@ export class PostgresJsPreparedQuery<T extends PreparedQueryConfig> extends PgPr
69
78
  'drizzle.query.text': query,
70
79
  'drizzle.query.params': JSON.stringify(params),
71
80
  });
72
- return throughErrorBoundary(client, () => watched(watch, query, () => this.queryWithCache(query, params, async () => {
73
- return await client.unsafe(query, params as any[]).values();
81
+ return throughErrorBoundary(client, () => watched(watch, query, (seen) => this.queryWithCache(query, params, async () => {
82
+ return await startUnsafe(client, query, params, seen).values();
74
83
  })));
75
84
  });
76
85
 
@@ -95,8 +104,8 @@ export class PostgresJsPreparedQuery<T extends PreparedQueryConfig> extends PgPr
95
104
  'drizzle.query.text': this.queryString,
96
105
  'drizzle.query.params': JSON.stringify(params),
97
106
  });
98
- return throughErrorBoundary(this.client, () => watched(this.watch, this.queryString, () => this.queryWithCache(this.queryString, params, async () => {
99
- return this.client.unsafe(this.queryString, params as any[]);
107
+ return throughErrorBoundary(this.client, () => watched(this.watch, this.queryString, (seen) => this.queryWithCache(this.queryString, params, async () => {
108
+ return startUnsafe(this.client, this.queryString, params, seen);
100
109
  })));
101
110
  });
102
111
  });
@@ -165,14 +174,14 @@ export class PostgresJsSession<
165
174
 
166
175
  query(query: string, params: unknown[]): Promise<RowList<Row[]>> {
167
176
  this.logger.logQuery(query, params);
168
- return throughErrorBoundary(this.client, () => watched(this.options.watch, query, () => this.client.unsafe(query, params as any[]).values()));
177
+ return throughErrorBoundary(this.client, () => watched(this.options.watch, query, (seen) => startUnsafe(this.client, query, params, seen).values()));
169
178
  }
170
179
 
171
180
  queryObjects<T extends Row>(
172
181
  query: string,
173
182
  params: unknown[],
174
183
  ): Promise<RowList<T[]>> {
175
- return throughErrorBoundary(this.client, () => watched(this.options.watch, query, () => this.client.unsafe(query, params as any[])));
184
+ return throughErrorBoundary(this.client, () => watched(this.options.watch, query, (seen) => startUnsafe(this.client, query, params, seen) as unknown as Promise<RowList<T[]>>));
176
185
  }
177
186
 
178
187
  override transaction<T>(
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 } from './core/config/statement-observer.js'
24
+ export type { StatementObserver, StatementEvent, StatementSettledObserver, StatementSettledEvent, StatementSettledError } 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,
@@ -127,10 +127,10 @@ async function createIsolatedConnection(
127
127
  observability: ResolvedObservability,
128
128
  withSystem: boolean,
129
129
  ): Promise<PostgresJsDatabase> {
130
- const debug = createDebugFn(observability)
130
+ const watch = createStatementWatch({ database: name, url: testDb.url, slowQueryMs: observability.slowQueryMs, inFlightQueryMs: observability.inFlightQueryMs, onSettled: observability.onStatementSettled })
131
+ const debug = createDebugFn(observability, watch.noteConnection)
131
132
  const client = postgres(testDb.url, { max: 1, onnotice: () => {}, ...(debug ? { debug } : {}) })
132
133
  if (isObserverOnly(observability)) markObservedConnection(client)
133
- const watch = createStatementWatch({ database: name, url: testDb.url, slowQueryMs: observability.slowQueryMs, inFlightQueryMs: observability.inFlightQueryMs })
134
134
  const db = drizzle(client, { statementWatch: watch.watch })
135
135
 
136
136
  await db.execute(sql.raw('BEGIN'))
@@ -177,12 +177,12 @@ export function createSetupConnection(
177
177
  testDb: TestDatabase,
178
178
  observability?: ResolvedObservability,
179
179
  ): { drizzle: PostgresJsDatabase; close: () => Promise<void> } {
180
- const debug = observability ? createDebugFn(observability) : undefined
181
- const client = postgres(testDb.url, { max: 1, onnotice: () => {}, ...(debug ? { debug } : {}) })
182
- if (observability && isObserverOnly(observability)) markObservedConnection(client)
183
180
  const watch = observability
184
- ? createStatementWatch({ database: 'setup', url: testDb.url, slowQueryMs: observability.slowQueryMs, inFlightQueryMs: observability.inFlightQueryMs })
181
+ ? createStatementWatch({ database: 'setup', url: testDb.url, slowQueryMs: observability.slowQueryMs, inFlightQueryMs: observability.inFlightQueryMs, onSettled: observability.onStatementSettled })
185
182
  : undefined
183
+ const debug = observability ? createDebugFn(observability, watch?.noteConnection) : undefined
184
+ const client = postgres(testDb.url, { max: 1, onnotice: () => {}, ...(debug ? { debug } : {}) })
185
+ if (observability && isObserverOnly(observability)) markObservedConnection(client)
186
186
  const db = splitLargeInserts(drizzle(client, watch ? { statementWatch: watch.watch } : {}))
187
187
  return { drizzle: db, close: async () => { await watch?.close(); await client.end() } }
188
188
  }