@bakery-framework/orm 1.0.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.
@@ -0,0 +1,261 @@
1
+ import { Try } from '@bakery-framework/core/utils'
2
+ import type { SQLAdapter } from './base'
3
+
4
+ /**
5
+ * Query observability: one callback, called once per executed statement.
6
+ *
7
+ * Deliberately **not** an emitter and **not** a listener array. This sits in
8
+ * the hot path of every statement the ORM runs, so the cost of having the
9
+ * feature at all has to be a single null read when nobody is watching — see
10
+ * `observe()` below, which returns the driver's own value untouched in that
11
+ * case. An emitter would allocate an event object and walk a list per query
12
+ * whether or not anything subscribed.
13
+ *
14
+ * Process-wide rather than per-adapter, on purpose. Every adapter builds a
15
+ * *new* adapter instance per transaction (`SQLiteAdapter.transaction` wraps the
16
+ * transaction handle in a fresh `SQLiteAdapter`), so a hook attached to one
17
+ * instance would go silent for exactly the statements a slow-query panel most
18
+ * wants to see. `driver` is on the event instead, so a process talking to two
19
+ * databases can still tell them apart.
20
+ */
21
+
22
+ export type QueryMethod = 'all' | 'run' | 'get' | 'values' | 'iterate'
23
+
24
+ export interface QueryEvent {
25
+ /** The statement as handed to the driver, before dialect normalisation. */
26
+ sql: string
27
+ /** Wall-clock duration in milliseconds, fractional. */
28
+ ms: number
29
+ /**
30
+ * Rows the statement produced, or `null` when it cannot be known — a failed
31
+ * statement, or a result shape the adapter does not describe as an array.
32
+ * For `run` this is the *affected* row count, not a result set.
33
+ */
34
+ rows: number | null
35
+ driver: SQLAdapter.Driver
36
+ /** Which executor entry point ran. See the note on `iterate` in `ms`. */
37
+ method: QueryMethod
38
+ /** `null` when the statement succeeded; the thrown value otherwise. */
39
+ error: unknown
40
+ /**
41
+ * Bound parameter **values**, present only when the observer was registered
42
+ * with `{ params: true }`.
43
+ *
44
+ * Security decision, and the reason this is opt-in with the default off:
45
+ * parameters are user data. A query's bindings routinely hold passwords,
46
+ * session tokens, API keys and personal information, and anything an
47
+ * observer receives is one `logger.info` away from a log file, an analytics
48
+ * table or a dashboard panel. Defaulting this on would turn "add a slow
49
+ * query panel" into a credential leak that nobody reviewed. Callers that
50
+ * genuinely need bindings — a local query profiler, say — must ask, and are
51
+ * then responsible for what they do with them.
52
+ */
53
+ params?: readonly unknown[]
54
+ }
55
+
56
+ export type QueryObserver = (event: QueryEvent) => unknown
57
+
58
+ export interface QueryObserverOptions {
59
+ /**
60
+ * Include bound parameter values on every event. Defaults to `false`.
61
+ * Read the note on `QueryEvent.params` before turning this on.
62
+ */
63
+ params?: boolean
64
+ }
65
+
66
+ let observer: QueryObserver | null = null
67
+ let includeParams = false
68
+
69
+ /**
70
+ * Install (or with `null`, remove) the process-wide query observer.
71
+ *
72
+ * Returns a disposer that clears the observer only if it is still the one this
73
+ * call installed, so a test that forgets to restore cannot silently unhook a
74
+ * later one.
75
+ *
76
+ * ```ts no-check — illustrative: the app decides where slow queries go
77
+ * import { setQueryObserver } from '@bakery-framework/orm'
78
+ *
79
+ * setQueryObserver(event => {
80
+ * if (event.ms > 100) slowQueries.push(event)
81
+ * })
82
+ * ```
83
+ */
84
+ export function setQueryObserver(
85
+ next: QueryObserver | null,
86
+ options: QueryObserverOptions = {},
87
+ ): () => void {
88
+ observer = next
89
+ includeParams = next ? options.params === true : false
90
+ const installed = next
91
+ return () => {
92
+ if (observer !== installed) return
93
+ observer = null
94
+ includeParams = false
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The observer currently installed, if any. For tests and diagnostics.
100
+ */
101
+ export function getQueryObserver(): QueryObserver | null {
102
+ return observer
103
+ }
104
+
105
+ function emit(event: QueryEvent, params: unknown[]): void {
106
+ const fn = observer
107
+ // Re-read rather than trusting the caller's check: an observer may be
108
+ // uninstalled while a query is in flight.
109
+ if (!fn) return
110
+ if (includeParams) event.params = params
111
+ // An observer is application code in the middle of somebody's query. A
112
+ // throwing or rejecting hook is the observer's bug, and it must not become
113
+ // the caller's failed write.
114
+ Try(() => fn(event))
115
+ }
116
+
117
+ type Exec<R> = (sqlText: string, params?: unknown[]) => Promise<R> | R
118
+
119
+ async function timed<R>(
120
+ driver: SQLAdapter.Driver,
121
+ method: QueryMethod,
122
+ fn: Exec<R>,
123
+ countRows: (result: R) => number | null,
124
+ sqlText: string,
125
+ params: unknown[],
126
+ ): Promise<R> {
127
+ const started = performance.now()
128
+ try {
129
+ const result = await fn(sqlText, params)
130
+ emit(
131
+ {
132
+ sql: sqlText,
133
+ ms: performance.now() - started,
134
+ // Called bare, not through `Try`: every `countRows` passed in from
135
+ // `createExecutor` is total by construction, and wrapping it cost more
136
+ // per query than the guard was worth.
137
+ rows: countRows(result),
138
+ driver,
139
+ method,
140
+ error: null,
141
+ },
142
+ params,
143
+ )
144
+ return result
145
+ } catch (error) {
146
+ emit(
147
+ {
148
+ sql: sqlText,
149
+ ms: performance.now() - started,
150
+ rows: null,
151
+ driver,
152
+ method,
153
+ // Normalised so `error` is a reliable "did this throw" signal: a driver
154
+ // that rejects with `undefined` would otherwise be indistinguishable
155
+ // from success.
156
+ error: error ?? new Error('query failed with no error value'),
157
+ },
158
+ params,
159
+ )
160
+ throw error
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Wrap one executor entry point so it reports to the observer.
166
+ *
167
+ * The unobserved path is the `if` below and nothing else: no timer read, no
168
+ * event object, and the underlying call's own return value — which for `all`
169
+ * and `run` may legitimately be synchronous — passes straight through.
170
+ */
171
+ export function observe<R>(
172
+ driver: SQLAdapter.Driver,
173
+ method: QueryMethod,
174
+ fn: (sqlText: string, params?: unknown[]) => Promise<R>,
175
+ countRows: (result: R) => number | null,
176
+ ): (sqlText: string, params?: unknown[]) => Promise<R>
177
+ export function observe<R>(
178
+ driver: SQLAdapter.Driver,
179
+ method: QueryMethod,
180
+ fn: Exec<R>,
181
+ countRows: (result: R) => number | null,
182
+ ): (sqlText: string, params?: unknown[]) => Promise<R> | R
183
+ // Two overloads because the executor's five entry points are not one shape.
184
+ // `get` and `values` are declared to return a promise unconditionally, while
185
+ // `all` and `run` may legitimately be synchronous — and the implementation
186
+ // signature, which has to admit both, cannot narrow to the former on its own.
187
+ // Without the first overload `createExecutor` fails to satisfy `Executor`.
188
+ export function observe<R>(
189
+ driver: SQLAdapter.Driver,
190
+ method: QueryMethod,
191
+ fn: Exec<R>,
192
+ countRows: (result: R) => number | null,
193
+ ): (sqlText: string, params?: unknown[]) => Promise<R> | R {
194
+ return (sqlText: string, params: unknown[] = []) => {
195
+ if (!observer) return fn(sqlText, params)
196
+ return timed(driver, method, fn, countRows, sqlText, params)
197
+ }
198
+ }
199
+
200
+ /**
201
+ * `iterate` is a stream, so its duration means something different from the
202
+ * other four, and the choice here is deliberate:
203
+ *
204
+ * - `ms` spans from the call to `iterate()` until iteration **ends** — the
205
+ * generator is exhausted, throws, or the consumer breaks out of the loop.
206
+ * That includes whatever the consumer did between yields, so an `iterate`
207
+ * event is not comparable with an `all` event and must not be averaged into
208
+ * the same "query time" number. It is still the useful measure: for a stream,
209
+ * the interesting quantity is how long the statement held a cursor open.
210
+ * - `rows` counts rows **consumed**, not rows matched. A consumer that breaks
211
+ * after ten rows of a million-row scan reports ten.
212
+ * - Exactly one event, emitted from `finally`, so an early `break` (which
213
+ * resumes the generator with a return completion, skipping `catch`) still
214
+ * reports.
215
+ */
216
+ async function* drain(
217
+ driver: SQLAdapter.Driver,
218
+ source: AsyncIterable<SQLAdapter.RowRecord> | Iterable<SQLAdapter.RowRecord>,
219
+ sqlText: string,
220
+ params: unknown[],
221
+ started: number,
222
+ ): AsyncIterable<SQLAdapter.RowRecord> {
223
+ let rows = 0
224
+ let error: unknown = null
225
+ try {
226
+ for await (const row of source) {
227
+ rows++
228
+ yield row
229
+ }
230
+ } catch (err) {
231
+ error = err ?? new Error('iteration failed with no error value')
232
+ throw err
233
+ } finally {
234
+ emit(
235
+ {
236
+ sql: sqlText,
237
+ ms: performance.now() - started,
238
+ rows,
239
+ driver,
240
+ method: 'iterate',
241
+ error,
242
+ },
243
+ params,
244
+ )
245
+ }
246
+ }
247
+
248
+ export function observeIterate(
249
+ driver: SQLAdapter.Driver,
250
+ iterate: SQLAdapter.Executor['iterate'],
251
+ ): SQLAdapter.Executor['iterate'] {
252
+ return (sqlText: string, params: unknown[] = []) => {
253
+ if (!observer) return iterate(sqlText, params)
254
+ // The source is created here rather than inside `drain`, and `started` with
255
+ // it: an async generator body does not run until the first `next()`, so
256
+ // both would otherwise be deferred to whenever the consumer got round to
257
+ // pulling — and the statement would be issued later than it is today.
258
+ const started = performance.now()
259
+ return drain(driver, iterate(sqlText, params), sqlText, params, started)
260
+ }
261
+ }