velocious 1.0.559 → 1.0.561

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 (64) hide show
  1. package/README.md +7 -2
  2. package/build/background-jobs/worker.js +39 -11
  3. package/build/database/drivers/base.js +9 -2
  4. package/build/database/drivers/mysql/index.js +11 -2
  5. package/build/database/drivers/mysql/query.js +198 -22
  6. package/build/database/query/index.js +16 -1
  7. package/build/database/query/model-class-query.js +3 -2
  8. package/build/database/query/query-data.js +75 -7
  9. package/build/database/query/with-count.js +64 -28
  10. package/build/database/query-aborted-error.js +24 -0
  11. package/build/http-server/client/websocket-session.js +134 -28
  12. package/build/src/background-jobs/worker.d.ts +15 -1
  13. package/build/src/background-jobs/worker.d.ts.map +1 -1
  14. package/build/src/background-jobs/worker.js +35 -13
  15. package/build/src/database/drivers/base.d.ts +6 -1
  16. package/build/src/database/drivers/base.d.ts.map +1 -1
  17. package/build/src/database/drivers/base.js +10 -3
  18. package/build/src/database/drivers/mssql/index.d.ts +6 -0
  19. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  20. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  21. package/build/src/database/drivers/mysql/index.js +12 -3
  22. package/build/src/database/drivers/mysql/query.d.ts +13 -3
  23. package/build/src/database/drivers/mysql/query.d.ts.map +1 -1
  24. package/build/src/database/drivers/mysql/query.js +172 -23
  25. package/build/src/database/drivers/pgsql/index.d.ts +6 -0
  26. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  27. package/build/src/database/drivers/sqlite/index.d.ts +6 -0
  28. package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
  29. package/build/src/database/drivers/sqlite/index.native.d.ts +6 -0
  30. package/build/src/database/drivers/sqlite/index.native.d.ts.map +1 -1
  31. package/build/src/database/drivers/sqlite/index.web.d.ts +6 -0
  32. package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
  33. package/build/src/database/query/index.d.ts +13 -1
  34. package/build/src/database/query/index.d.ts.map +1 -1
  35. package/build/src/database/query/index.js +15 -3
  36. package/build/src/database/query/model-class-query.d.ts.map +1 -1
  37. package/build/src/database/query/model-class-query.js +4 -3
  38. package/build/src/database/query/query-data.d.ts.map +1 -1
  39. package/build/src/database/query/query-data.js +69 -8
  40. package/build/src/database/query/with-count.d.ts.map +1 -1
  41. package/build/src/database/query/with-count.js +60 -26
  42. package/build/src/database/query-aborted-error.d.ts +23 -0
  43. package/build/src/database/query-aborted-error.d.ts.map +1 -0
  44. package/build/src/database/query-aborted-error.js +23 -0
  45. package/build/src/http-server/client/websocket-session.d.ts +31 -3
  46. package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
  47. package/build/src/http-server/client/websocket-session.js +112 -26
  48. package/build/src/tenants/tenant-aggregator.d.ts +4 -0
  49. package/build/src/tenants/tenant-aggregator.d.ts.map +1 -1
  50. package/build/src/tenants/tenant-aggregator.js +4 -3
  51. package/build/tenants/tenant-aggregator.js +3 -2
  52. package/build/tsconfig.tsbuildinfo +1 -1
  53. package/package.json +3 -1
  54. package/src/background-jobs/worker.js +39 -11
  55. package/src/database/drivers/base.js +9 -2
  56. package/src/database/drivers/mysql/index.js +11 -2
  57. package/src/database/drivers/mysql/query.js +198 -22
  58. package/src/database/query/index.js +16 -1
  59. package/src/database/query/model-class-query.js +3 -2
  60. package/src/database/query/query-data.js +75 -7
  61. package/src/database/query/with-count.js +64 -28
  62. package/src/database/query-aborted-error.js +24 -0
  63. package/src/http-server/client/websocket-session.js +134 -28
  64. package/src/tenants/tenant-aggregator.js +3 -2
package/README.md CHANGED
@@ -20,8 +20,8 @@
20
20
  * Gap-less positional lists with automatic reordering via `actsAsList` (see [docs/acts-as-list.md](docs/acts-as-list.md))
21
21
  * Rails-style nested-attribute writes on frontend-model `save()` (see [docs/nested-attributes.md](docs/nested-attributes.md))
22
22
  * Async-aware test-data factories with inherited traits, graph-first native association autosave, metadata-aware override precedence, callbacks, sequences, and linting (see [docs/factories.md](docs/factories.md))
23
- * Per-row association counts via `.withCount(...)` on frontend and backend queries (see [docs/with-count.md](docs/with-count.md))
24
- * Consumer-defined per-row SQL aggregates/computations via `.queryData(...)` on frontend and backend queries (see [docs/query-data.md](docs/query-data.md))
23
+ * Per-row association counts via `.withCount(...)`, including safe batching of structurally identical aggregates, on frontend and backend queries (see [docs/with-count.md](docs/with-count.md))
24
+ * Consumer-defined per-row SQL aggregates/computations via `.queryData(...)`, with compatible projections sharing a roundtrip while preserving declared alias-overwrite order, on frontend and backend queries (see [docs/query-data.md](docs/query-data.md))
25
25
  * Per-record ability checks via `.abilities(...)` on frontend queries + `record.can(action)` (see [docs/abilities.md](docs/abilities.md))
26
26
  * Translated model attributes with current-locale relationship sorting (see [docs/translations.md](docs/translations.md))
27
27
  * Cross-process broadcast bus for `broadcastToChannel` via `velocious beacon`, including background job runner processes (see [docs/beacon.md](docs/beacon.md))
@@ -34,6 +34,7 @@
34
34
  * In-process driver schema metadata caching (see [docs/schema-metadata-cache.md](docs/schema-metadata-cache.md))
35
35
  * Planned local-first shared-resource sync architecture (see [docs/offline-sync.md](docs/offline-sync.md))
36
36
  * Selective named database connection checkouts, bounded pool waits, and debugging held connections (see [docs/database-connections.md](docs/database-connections.md))
37
+ * AbortSignal-driven MySQL/MariaDB query cancellation for raw, model, and cross-tenant aggregate queries (see [docs/database-query-cancellation.md](docs/database-query-cancellation.md))
37
38
  * Optional built-in debug endpoint for inspecting server and database connection state (see [docs/debug-endpoint.md](docs/debug-endpoint.md))
38
39
  * Optional built-in API manifest endpoint describing every registered frontend-model resource as human- and machine-readable JSON (see [docs/api-manifest-endpoint.md](docs/api-manifest-endpoint.md))
39
40
 
@@ -59,6 +60,8 @@ npm run test
59
60
  npm run test:expo
60
61
  ```
61
62
 
63
+ Maintainers cutting a package release must follow the [Velocious release runbook](docs/releasing.md); `npm run release:patch` commits, pushes, and publishes rather than acting as a local-only version command.
64
+
62
65
  # Code quality (fallow)
63
66
 
64
67
  [fallow](https://github.com/fallow-rs/fallow) analyzes the codebase for unused/dead code, duplication, and complexity. CI runs it as a **regression gate**: it fails only on findings beyond the committed baseline in `fallow-baselines/`, so existing backlog never blocks a PR but new issues do.
@@ -1565,6 +1568,8 @@ database: {
1565
1568
 
1566
1569
  Velocious includes a lightweight websocket entry point for API-style calls and server-side events.
1567
1570
 
1571
+ Inbound frames remain ordered when TCP splits a frame across reads. Velocious limits a single final client data frame and a reassembled fragmented message to 16 MiB; larger payloads close the connection. See [WebSocket connections](docs/websocket-connections.md) for wire-protocol details.
1572
+
1568
1573
  ## Connect and call a controller
1569
1574
 
1570
1575
  ```js
@@ -193,8 +193,11 @@ export default class BackgroundJobsWorker {
193
193
  this.inflightPooledJobs = new Set()
194
194
  /** @type {Set<import("node:child_process").ChildProcess>} */
195
195
  this.pooledChildren = new Set()
196
- /** @type {Map<import("node:child_process").ChildProcess, {createdAtMs: number, jobsRun: number, inflight: Map<string, {payload: import("./types.js").BackgroundJobPayload & {id: string}, resolve?: (value: void) => void, timeoutTimer?: ReturnType<typeof setTimeout> | null}>, retiring: boolean, settling?: boolean, timeoutSigkillTimer?: ReturnType<typeof setTimeout> | null}>} */
196
+ /** @type {Map<import("node:child_process").ChildProcess, {createdAtMs: number, jobsRun: number, inflight: Map<string, {payload: import("./types.js").BackgroundJobPayload & {id: string}, resolve?: (value: void) => void, timeoutTimer?: ReturnType<typeof setTimeout> | null}>, lastDispatchSeq: number, retiring: boolean, settling?: boolean, timeoutSigkillTimer?: ReturnType<typeof setTimeout> | null}>} */
197
197
  this.pooledChildStates = new Map()
198
+ // Monotonic dispatch counter for round-robin child selection: each dispatch stamps
199
+ // the chosen child, and selection prefers the child dispatched least recently.
200
+ this._pooledDispatchSeq = 0
198
201
  }
199
202
 
200
203
  /**
@@ -666,18 +669,13 @@ export default class BackgroundJobsWorker {
666
669
  * @returns {Promise<void>} - Resolves after the durable report.
667
670
  */
668
671
  _runPooledJob(payload) {
669
- let child
670
- for (const candidate of this.pooledChildren) {
671
- const state = this.pooledChildStates.get(candidate)
672
- if (state && !state.retiring && state.inflight.size < this.pooledRunnerConcurrency) {
673
- child = candidate
674
- break
675
- }
676
- }
677
- if (!child) child = this._createPooledChild()
672
+ const child = this._selectPooledChild() || this._createPooledChild()
678
673
  const state = this.pooledChildStates.get(child)
679
674
  if (!state) throw new Error("Pooled runner state missing")
680
675
 
676
+ // Stamp the round-robin cursor so the next dispatch prefers a different child.
677
+ state.lastDispatchSeq = ++this._pooledDispatchSeq
678
+
681
679
  return new Promise((resolve) => {
682
680
  const timeoutTimer = this._armPooledJobTimeout({child, jobId: payload.id})
683
681
 
@@ -690,6 +688,36 @@ export default class BackgroundJobsWorker {
690
688
  })
691
689
  }
692
690
 
691
+ /**
692
+ * Selects a pooled child to run the next job, or undefined when every non-retiring
693
+ * child is already full (the caller then lazily spawns one). Among children with a
694
+ * free concurrency slot, picks the one dispatched least recently — a round-robin that
695
+ * spreads jobs (notably multi-minute RunBuildJobs, each pinning a tenant connection
696
+ * for its whole run) evenly across children instead of first-fit packing the earliest
697
+ * one until it is full. A freshly spawned or replacement child therefore takes its
698
+ * fair share one job at a time as its turn comes up, rather than absorbing a burst to
699
+ * "catch up" to the others.
700
+ * @returns {import("node:child_process").ChildProcess | undefined} - The chosen child, or undefined when all non-retiring children are full.
701
+ */
702
+ _selectPooledChild() {
703
+ /** @type {import("node:child_process").ChildProcess | undefined} */
704
+ let selected
705
+ let selectedSeq = Infinity
706
+
707
+ for (const child of this.pooledChildren) {
708
+ const state = this.pooledChildStates.get(child)
709
+
710
+ if (!state || state.retiring || state.inflight.size >= this.pooledRunnerConcurrency) continue
711
+
712
+ if (state.lastDispatchSeq < selectedSeq) {
713
+ selected = child
714
+ selectedSeq = state.lastDispatchSeq
715
+ }
716
+ }
717
+
718
+ return selected
719
+ }
720
+
693
721
  /**
694
722
  * Arms a per-job wall-clock backstop for a pooled job. A pooled child hosts many
695
723
  * concurrent jobs, so a single genuinely-hung job would otherwise pin its
@@ -756,7 +784,7 @@ export default class BackgroundJobsWorker {
756
784
  })
757
785
  this.pooledChildren.add(child)
758
786
  this.inflightProcessChildren.add(child)
759
- this.pooledChildStates.set(child, {createdAtMs: Date.now(), jobsRun: 0, inflight: new Map(), retiring: false})
787
+ this.pooledChildStates.set(child, {createdAtMs: Date.now(), jobsRun: 0, inflight: new Map(), lastDispatchSeq: 0, retiring: false})
760
788
  child.on("message", (message) => this._handlePooledChildMessage({child, message}))
761
789
  child.once("exit", (code, signal) => this._handlePooledChildFailure({child, error: new Error(`Pooled background job runner exited: code=${code} signal=${signal || "none"}`)}))
762
790
  child.once("error", (error) => this._handlePooledChildFailure({child, error}))
@@ -59,6 +59,7 @@
59
59
  * @property {boolean} [processListComment] - Whether to add process-list comments to the query.
60
60
  * @property {boolean} [retry] - Whether retryable errors may retry the query; defaults to true.
61
61
  * @property {boolean} [sessionTimeZone] - Whether to ensure the configured database session time zone before the query.
62
+ * @property {AbortSignal} [signal] - Aborts the in-flight query (destroying its connection) when it fires.
62
63
  * @property {string} [sourceStack] - Stack captured at the caller boundary.
63
64
  */
64
65
 
@@ -116,6 +117,7 @@ import { formatDateForDatabase } from "../datetime-storage.js"
116
117
  import isDate from "../../utils/is-date.js"
117
118
  import Logger from "../../logger.js"
118
119
  import Query from "../query/index.js"
120
+ import QueryAbortedError from "../query-aborted-error.js"
119
121
  import Handler from "../handler.js"
120
122
  import Mutex from "epic-locks/build/mutex.js"
121
123
  import UUID from "pure-uuid"
@@ -1170,6 +1172,10 @@ export default class VelociousDatabaseDriversBase {
1170
1172
  } catch (error) {
1171
1173
  if (!(error instanceof Error)) throw error
1172
1174
 
1175
+ // A deliberately-aborted query must never be silently re-run — its
1176
+ // connection was destroyed on purpose, so treat it as terminal.
1177
+ if (error instanceof QueryAbortedError) throw error
1178
+
1173
1179
  const retryInfo = this.retryableDatabaseError(error)
1174
1180
 
1175
1181
  if (options.retry !== false && tries < maxTries && retryInfo.retry) {
@@ -1274,7 +1280,7 @@ export default class VelociousDatabaseDriversBase {
1274
1280
  await this.beforeQuery(sql, options)
1275
1281
 
1276
1282
  try {
1277
- return await this._queryActual(sql)
1283
+ return await this._queryActual(sql, options)
1278
1284
  } finally {
1279
1285
  await this.afterQuery(sql, options)
1280
1286
  }
@@ -1463,9 +1469,10 @@ export default class VelociousDatabaseDriversBase {
1463
1469
  * Runs query actual.
1464
1470
  * @abstract
1465
1471
  * @param {string} sql - SQL string.
1472
+ * @param {QueryOptions} [options] - Query options (carries the optional abort signal).
1466
1473
  * @returns {Promise<QueryResultType>} - Resolves with the query actual.
1467
1474
  */
1468
- _queryActual(sql) { // eslint-disable-line no-unused-vars
1475
+ _queryActual(sql, options) { // eslint-disable-line no-unused-vars
1469
1476
  throw new Error(`queryActual not implemented`)
1470
1477
  }
1471
1478
 
@@ -13,6 +13,7 @@ import Insert from "./sql/insert.js"
13
13
  import Options from "./options.js"
14
14
  import mysql from "mysql"
15
15
  import query from "./query.js"
16
+ import QueryAbortedError from "../../query-aborted-error.js"
16
17
  import QueryParser from "./query-parser.js"
17
18
  import streamQuery from "./query-stream.js"
18
19
  import RemoveIndex from "./sql/remove-index.js"
@@ -371,15 +372,23 @@ export default class VelociousDatabaseDriversMysql extends Base{
371
372
  /**
372
373
  * Runs query actual.
373
374
  * @param {string} sql - SQL string.
375
+ * @param {import("../base.js").QueryOptions} [options] - Query options (carries the optional abort signal).
374
376
  * @returns {Promise<import("../base.js").QueryResultType>} - Resolves with the query actual.
375
377
  */
376
- async _queryActual(sql) {
378
+ async _queryActual(sql, options = {}) {
377
379
  if (!this.pool) await this.connect()
378
380
  if (!this.pool) throw new Error("MySQL pool failed to initialize")
379
381
 
380
382
  try {
381
- return await query(this.pool, sql)
383
+ return await query(this.pool, sql, {signal: options.signal})
382
384
  } catch (error) {
385
+ // Preserve an abort as-is so the retry loop can recognise it as terminal
386
+ // (wrapping it in a plain Error would lose the QueryAbortedError type).
387
+ if (error instanceof QueryAbortedError) {
388
+ if (error.connectionDestroyed) this.resetCurrentSessionTimeZone()
389
+ throw error
390
+ }
391
+
383
392
  // Re-throw to un-corrupt stacktrace
384
393
  if (error instanceof Error) {
385
394
  throw new Error(`Query failed: ${error.message}`, {cause: error})
@@ -1,37 +1,213 @@
1
+ // @ts-check
2
+
3
+ import mysql from "mysql"
4
+ import QueryAbortedError from "../../query-aborted-error.js"
5
+
1
6
  /**
2
- * Runs query.
3
- * @param {import("mysql").Pool} pool - Pool.
4
- * @param {string} sql - SQL string.
5
- * @returns {Promise<Record<string, ?>[]>} - Resolves with string value.
7
+ * Checks out one pool connection while honoring cancellation before checkout completes.
8
+ * A connection returned after cancellation is released without running the query.
9
+ * @param {import("mysql").Pool} pool - Pool to check out from.
10
+ * @param {string} sql - SQL associated with the checkout.
11
+ * @param {AbortSignal | undefined} signal - Optional cancellation signal.
12
+ * @returns {Promise<import("mysql").PoolConnection>} - Checked-out connection.
6
13
  */
7
- export default async function query(pool, sql) {
14
+ function checkoutConnection(pool, sql, signal) {
8
15
  return new Promise((resolve, reject) => {
9
- pool.query(sql, (error, results, fields) => {
16
+ let settled = false
17
+ /** @type {(() => void) | undefined} */
18
+ let removeAbortListener
19
+
20
+ const settle = () => {
21
+ settled = true
22
+ if (removeAbortListener) removeAbortListener()
23
+ }
24
+
25
+ const onAbort = () => {
26
+ if (settled) return
27
+
28
+ settle()
29
+ reject(new QueryAbortedError({sql}))
30
+ }
31
+
32
+ if (signal) {
33
+ signal.addEventListener("abort", onAbort, {once: true})
34
+ removeAbortListener = () => signal.removeEventListener("abort", onAbort)
35
+ }
36
+
37
+ if (signal?.aborted) {
38
+ onAbort()
39
+
40
+ return
41
+ }
42
+
43
+ pool.getConnection((error, connection) => {
44
+ if (settled) {
45
+ if (connection) connection.release()
46
+
47
+ return
48
+ }
49
+
50
+ settle()
51
+
10
52
  if (error) {
11
- reject(new Error(`Query failed because of ${error}: ${sql}`))
53
+ reject(error)
12
54
  } else {
13
- const rows = []
14
- const resultRows = Array.isArray(results) ? results : []
15
- const resultFields = Array.isArray(fields) ? fields : []
55
+ resolve(connection)
56
+ }
57
+ })
58
+ })
59
+ }
60
+
61
+ /**
62
+ * Best-effort `KILL QUERY` so the server aborts the running statement — releasing
63
+ * its locks/resources immediately — instead of finishing it after the client
64
+ * socket is destroyed. Destroying the socket alone does not interrupt a
65
+ * non-cooperative running statement (e.g. `SLEEP` or a long scan) server-side, so
66
+ * the deadline would otherwise only suppress the client while the query keeps
67
+ * holding database resources. Runs on a throwaway connection because the driver
68
+ * pool is capped at one connection (the one running the aborted query). Any
69
+ * failure is swallowed: the caller still destroys the socket and rejects.
70
+ * @param {import("mysql").Pool} pool - Pool whose connection config seeds the kill connection.
71
+ * @param {number | null | undefined} threadId - Server thread id of the query to kill.
72
+ * @returns {Promise<void>} - Resolves once the kill has been attempted.
73
+ */
74
+ function killServerQuery(pool, threadId) {
75
+ return new Promise((resolve) => {
76
+ const connectionConfig = /** @type {{config?: {connectionConfig?: unknown}}} */ (pool).config?.connectionConfig
77
+
78
+ if (!threadId || !connectionConfig) {
79
+ resolve()
80
+
81
+ return
82
+ }
83
+
84
+ let killConnection
85
+
86
+ try {
87
+ killConnection = mysql.createConnection(/** @type {?} */ (connectionConfig))
88
+ } catch {
89
+ resolve()
90
+
91
+ return
92
+ }
93
+
94
+ killConnection.on("error", () => {})
95
+ killConnection.query(`KILL QUERY ${Number(threadId)}`, () => {
96
+ killConnection.destroy()
97
+ resolve()
98
+ })
99
+ })
100
+ }
101
+
102
+ /**
103
+ * Runs `sql` on a dedicated connection checked out of `pool` so it can be
104
+ * aborted while it is still executing. When `signal` fires before the query
105
+ * settles the connection is destroyed — which aborts the running statement at
106
+ * the socket and removes the connection from the pool so it is never handed back
107
+ * mid-statement — and the promise rejects with a {@link QueryAbortedError}. On
108
+ * success the connection is released back to the pool. On a fatal connection
109
+ * error it is destroyed; on an ordinary query error (syntax, constraint, etc.)
110
+ * it is released, because the connection itself is still healthy.
111
+ * @param {import("mysql").Pool} pool - Pool.
112
+ * @param {string} sql - SQL string.
113
+ * @param {{signal?: AbortSignal}} [options] - Query options.
114
+ * @returns {Promise<Record<string, ?>[]>} - Resolves with the mapped rows.
115
+ */
116
+ export default async function query(pool, sql, {signal} = {}) {
117
+ if (signal?.aborted) throw new QueryAbortedError({sql})
118
+
119
+ const connection = await checkoutConnection(pool, sql, signal)
120
+
121
+ return await new Promise((resolve, reject) => {
122
+ let settled = false
123
+ /** @type {(() => void) | undefined} */
124
+ let removeAbortListener
125
+
126
+ const settle = () => {
127
+ settled = true
128
+ if (removeAbortListener) removeAbortListener()
129
+ }
130
+
131
+ const onAbort = () => {
132
+ if (settled) return
133
+
134
+ settle()
135
+ const threadId = connection.threadId
136
+
137
+ // Destroy — never release — so a connection still mid-statement is not
138
+ // returned to the pool and the pool slot is freed even if the separate
139
+ // server-side kill attempt stalls. The pool spawns a fresh connection on
140
+ // the next checkout.
141
+ connection.destroy()
142
+ void killServerQuery(pool, threadId)
143
+ reject(new QueryAbortedError({connectionDestroyed: true, sql}))
144
+ }
145
+
146
+ if (signal) {
147
+ signal.addEventListener("abort", onAbort, {once: true})
148
+ removeAbortListener = () => signal.removeEventListener("abort", onAbort)
149
+ }
16
150
 
17
- for (const rowData of resultRows) {
18
- /**
19
- * Result.
20
- * @type {Record<string, ?>} */
21
- const result = {}
151
+ // An abort that landed between the checkout above and attaching the listener
152
+ // would not fire the listener (the event already dispatched), so re-check and
153
+ // abort synchronously to close that race before issuing the query.
154
+ if (signal?.aborted) {
155
+ onAbort()
22
156
 
23
- for (const fieldData of resultFields) {
24
- const field = fieldData.name
25
- const value = rowData[field]
157
+ return
158
+ }
26
159
 
27
- result[field] = value
28
- }
160
+ connection.query(sql, (/** @type {?} */ error, /** @type {?} */ results, /** @type {?} */ fields) => {
161
+ if (settled) return
29
162
 
30
- rows.push(result)
163
+ settle()
164
+
165
+ if (error) {
166
+ // A fatal error leaves the socket unusable, so discard it; an ordinary
167
+ // query error keeps a healthy connection that can be reused.
168
+ if (error.fatal) {
169
+ connection.destroy()
170
+ } else {
171
+ connection.release()
31
172
  }
32
173
 
33
- resolve(rows)
174
+ reject(new Error(`Query failed because of ${error}: ${sql}`))
175
+
176
+ return
34
177
  }
178
+
179
+ connection.release()
180
+ resolve(mapRows(results, fields))
35
181
  })
36
182
  })
37
183
  }
184
+
185
+ /**
186
+ * Materializes the driver rows as isolated plain records keyed by field name.
187
+ * @param {?} results - Driver result rows.
188
+ * @param {?} fields - Driver result fields.
189
+ * @returns {Record<string, ?>[]} - Mapped rows.
190
+ */
191
+ function mapRows(results, fields) {
192
+ const rows = []
193
+ const resultRows = Array.isArray(results) ? results : []
194
+ const resultFields = Array.isArray(fields) ? fields : []
195
+
196
+ for (const rowData of resultRows) {
197
+ /**
198
+ * Result.
199
+ * @type {Record<string, ?>} */
200
+ const result = {}
201
+
202
+ for (const fieldData of resultFields) {
203
+ const field = fieldData.name
204
+ const value = rowData[field]
205
+
206
+ result[field] = value
207
+ }
208
+
209
+ rows.push(result)
210
+ }
211
+
212
+ return rows
213
+ }
@@ -125,6 +125,7 @@ function mergeJoinValue(existing, incoming) {
125
125
  * @property {Record<string, string[]>} [preloadSelects] - Attribute names to load for preloaded relationships, keyed by target model name.
126
126
  * @property {Record<string, string[]>} [preloadSelectsExtra] - Extra selects to load in addition to the defaults for preloaded relationships, keyed by target model name.
127
127
  * @property {Array<import("./select-base.js").default>} [selects] - SELECT clauses for the query.
128
+ * @property {AbortSignal} [signal] - Signal passed to database query execution.
128
129
  * @property {boolean} [distinct] - Whether the query should use DISTINCT.
129
130
  * @property {Array<import("./where-base.js").default>} [wheres] - WHERE conditions for the query.
130
131
  */
@@ -150,6 +151,7 @@ export default class VelociousDatabaseQuery {
150
151
  preloadSelectsExtra = {},
151
152
  distinct = false,
152
153
  selects = [],
154
+ signal,
153
155
  wheres = []
154
156
  }) {
155
157
  if (!driver) throw new Error("No driver given to query")
@@ -182,6 +184,7 @@ export default class VelociousDatabaseQuery {
182
184
  this._preloadSelectsExtra = preloadSelectsExtra
183
185
  this._distinct = distinct
184
186
  this._selects = selects
187
+ this._signal = signal
185
188
 
186
189
  /**
187
190
  * Narrows the runtime value to the documented type.
@@ -213,6 +216,7 @@ export default class VelociousDatabaseQuery {
213
216
  preload: {...this._preload},
214
217
  distinct: this._distinct,
215
218
  selects: [...this._selects],
219
+ signal: this._signal,
216
220
  wheres: [...this._wheres]
217
221
  })
218
222
 
@@ -482,11 +486,22 @@ export default class VelociousDatabaseQuery {
482
486
  */
483
487
  async _executeQuery({logName = this.queryLogName("Load")} = {}) {
484
488
  const sql = this.toSql()
485
- const results = await this.driver.query(sql, {logName})
489
+ const results = await this.driver.query(sql, {logName, signal: this._signal})
486
490
 
487
491
  return results
488
492
  }
489
493
 
494
+ /**
495
+ * Sets the signal used to cancel database execution for this query and its clones.
496
+ * @param {AbortSignal | undefined} signal - Cancellation signal, or undefined to clear it.
497
+ * @returns {this} - Query for chaining.
498
+ */
499
+ signal(signal) {
500
+ this._signal = signal
501
+
502
+ return this
503
+ }
504
+
490
505
  /**
491
506
  * Runs results.
492
507
  * @returns {Promise<Array<object>>} Array of results from the database
@@ -234,6 +234,7 @@ export default class VelociousDatabaseQueryModelClassQuery extends DatabaseQuery
234
234
  preloadSelectsExtra: clonePreloadSelectMap(this._preloadSelectsExtra),
235
235
  distinct: this._distinct,
236
236
  selects: [...this._selects],
237
+ signal: this._signal,
237
238
  wheres: [...this._wheres],
238
239
  joinBasePath: [...this._joinBasePath],
239
240
  joinTracker: this._joinTracker.clone(),
@@ -373,7 +374,7 @@ export default class VelociousDatabaseQueryModelClassQuery extends DatabaseQuery
373
374
  ].join(" ")
374
375
  const results = /** @type {{count: number}[]} */ (await this.driver.query(
375
376
  sql,
376
- {logName: this.queryLogName("Count")}
377
+ {logName: this.queryLogName("Count"), signal: this._signal}
377
378
  ))
378
379
 
379
380
  if (results.length != 1 || !("count" in results[0])) {
@@ -780,7 +781,7 @@ export default class VelociousDatabaseQueryModelClassQuery extends DatabaseQuery
780
781
  sql = `UPDATE ${driver.quoteTable(tableName)} SET ${setCols}${whereSql}`
781
782
  }
782
783
 
783
- await driver.query(sql, {logName: this.queryLogName("Update All")})
784
+ await driver.query(sql, {logName: this.queryLogName("Update All"), signal: this._signal})
784
785
  }
785
786
 
786
787
  /**
@@ -190,23 +190,50 @@ export async function runQueryData({rootModelClass, rootModels, entries}) {
190
190
 
191
191
  const primaryKey = rootModelClass.primaryKey()
192
192
  const rootIds = rootModels.map((model) => /** @type {string | number} */ (model.readColumn(primaryKey)))
193
+ const preparedEntries = entries.map((entry, entryIndex) => prepareEntry({entry, entryIndex, primaryKey, rootIds, rootModelClass}))
194
+ /**
195
+ * Compatible query groups.
196
+ * @type {Array<{aliases: Set<string>, query: import("./model-class-query.js").default, signature: string}>} */
197
+ const queryGroups = []
198
+
199
+ for (const preparedEntry of preparedEntries) {
200
+ const compatibleGroup = queryGroups.find((group, groupIndex) => {
201
+ if (group.signature !== preparedEntry.signature) return false
202
+ if (queryGroups.slice(groupIndex + 1).some((interveningGroup) => (
203
+ preparedEntry.aliases.some((alias) => interveningGroup.aliases.has(alias))
204
+ ))) return false
205
+
206
+ return preparedEntry.aliases.every((alias) => !group.aliases.has(alias))
207
+ })
208
+
209
+ if (compatibleGroup) {
210
+ compatibleGroup.query.select(preparedEntry.query.getSelects().slice(1))
211
+ for (const alias of preparedEntry.aliases) compatibleGroup.aliases.add(alias)
212
+ } else {
213
+ queryGroups.push({
214
+ aliases: new Set(preparedEntry.aliases),
215
+ query: preparedEntry.query,
216
+ signature: preparedEntry.signature
217
+ })
218
+ }
219
+ }
193
220
 
194
- for (const entry of entries) {
195
- await runEntry({entry, primaryKey, rootIds, rootModelClass, rootModels})
221
+ for (const {query} of queryGroups) {
222
+ await executeEntryQuery({primaryKey, query, rootModels})
196
223
  }
197
224
  }
198
225
 
199
226
  /**
200
- * Runs run entry.
227
+ * Prepares one queryData entry and its compatibility metadata.
201
228
  * @param {object} args - Options.
202
229
  * @param {QueryDataEntry} args.entry - Entry being evaluated.
230
+ * @param {number} args.entryIndex - Stable position used to isolate opaque projections.
203
231
  * @param {string} args.primaryKey - Root model primary key column.
204
232
  * @param {Array<string | number>} args.rootIds - Root primary-key values.
205
233
  * @param {typeof import("../record/index.js").default} args.rootModelClass - Root model class.
206
- * @param {import("../record/index.js").default[]} args.rootModels - Loaded root records.
207
- * @returns {Promise<void>}
234
+ * @returns {{aliases: string[], query: import("./model-class-query.js").default, signature: string}} - Prepared entry.
208
235
  */
209
- async function runEntry({entry, primaryKey, rootIds, rootModelClass, rootModels}) {
236
+ function prepareEntry({entry, entryIndex, primaryKey, rootIds, rootModelClass}) {
210
237
  const targetModelClass = resolveTargetModelClass(rootModelClass, entry.chain)
211
238
  const fn = targetModelClass.getQueryDataByName(entry.fnName)
212
239
 
@@ -219,7 +246,7 @@ async function runEntry({entry, primaryKey, rootIds, rootModelClass, rootModels}
219
246
 
220
247
  // Empty out any defaults the query factory added — queryData runs
221
248
  // a bare aggregate, not a full model load.
222
- query._selects = []
249
+ query.reselect()
223
250
  query._preload = {}
224
251
 
225
252
  // Force the root WHERE to qualify by table name so it survives the
@@ -264,6 +291,47 @@ async function runEntry({entry, primaryKey, rootIds, rootModelClass, rootModels}
264
291
  tableName: targetTableRef
265
292
  })
266
293
 
294
+ const aliases = selectedAliases(query)
295
+ const signatureQuery = query.clone()
296
+ signatureQuery.reselect(signatureQuery.getSelects().slice(0, 1))
297
+
298
+ return {
299
+ aliases: aliases || [],
300
+ query,
301
+ signature: aliases ? signatureQuery.toSql() : `opaque:${entryIndex}`
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Returns explicit aliases selected after the reserved parent id.
307
+ * Entries with an opaque select stay isolated by receiving a unique compatibility alias.
308
+ * @param {import("./model-class-query.js").default} query - Prepared queryData query.
309
+ * @returns {string[] | null} - Selected aliases, or null for an opaque projection.
310
+ */
311
+ function selectedAliases(query) {
312
+ const aliases = []
313
+
314
+ for (const select of query.getSelects().slice(1)) {
315
+ const sql = select.toSql()
316
+ const match = sql.match(/\sAS\s+([^\s]+)\s*$/iu)
317
+
318
+ if (!match) return null
319
+
320
+ aliases.push(match[1].replace(/^["[`]|["`\]]$/gu, ""))
321
+ }
322
+
323
+ return aliases
324
+ }
325
+
326
+ /**
327
+ * Executes one compatible queryData group and attaches every selected alias.
328
+ * @param {object} args - Options.
329
+ * @param {string} args.primaryKey - Root model primary key column.
330
+ * @param {import("./model-class-query.js").default} args.query - Prepared grouped query.
331
+ * @param {import("../record/index.js").default[]} args.rootModels - Loaded root records.
332
+ * @returns {Promise<void>}
333
+ */
334
+ async function executeEntryQuery({primaryKey, query, rootModels}) {
267
335
  const rows = /** @type {Array<Record<string, ?>>} */ (await query._executeQuery())
268
336
  const byParent = new Map()
269
337