velocious 1.0.558 → 1.0.560
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/README.md +8 -3
- package/build/background-jobs/store.js +262 -16
- package/build/background-jobs/web/authorization.js +5 -4
- package/build/background-jobs/web/controller.js +13 -16
- package/build/background-jobs/web/counts-channel.js +79 -0
- package/build/background-jobs/web/index.js +2 -0
- package/build/background-jobs/web/registry.js +1 -1
- package/build/database/drivers/base.js +9 -2
- package/build/database/drivers/mysql/index.js +11 -2
- package/build/database/drivers/mysql/query.js +198 -22
- package/build/database/query/index.js +16 -1
- package/build/database/query/model-class-query.js +3 -2
- package/build/database/query/query-data.js +75 -7
- package/build/database/query/with-count.js +64 -28
- package/build/database/query-aborted-error.js +24 -0
- package/build/http-server/client/websocket-session.js +134 -28
- package/build/src/background-jobs/store.d.ts +79 -0
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +235 -17
- package/build/src/background-jobs/web/authorization.d.ts +5 -3
- package/build/src/background-jobs/web/authorization.d.ts.map +1 -1
- package/build/src/background-jobs/web/authorization.js +6 -5
- package/build/src/background-jobs/web/controller.d.ts.map +1 -1
- package/build/src/background-jobs/web/controller.js +14 -15
- package/build/src/background-jobs/web/counts-channel.d.ts +31 -0
- package/build/src/background-jobs/web/counts-channel.d.ts.map +1 -0
- package/build/src/background-jobs/web/counts-channel.js +71 -0
- package/build/src/background-jobs/web/index.d.ts.map +1 -1
- package/build/src/background-jobs/web/index.js +3 -1
- package/build/src/background-jobs/web/registry.d.ts +1 -1
- package/build/src/background-jobs/web/registry.d.ts.map +1 -1
- package/build/src/background-jobs/web/registry.js +2 -2
- package/build/src/database/drivers/base.d.ts +6 -1
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +10 -3
- package/build/src/database/drivers/mssql/index.d.ts +6 -0
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +12 -3
- package/build/src/database/drivers/mysql/query.d.ts +13 -3
- package/build/src/database/drivers/mysql/query.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/query.js +172 -23
- package/build/src/database/drivers/pgsql/index.d.ts +6 -0
- package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.d.ts +6 -0
- package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.native.d.ts +6 -0
- package/build/src/database/drivers/sqlite/index.native.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.web.d.ts +6 -0
- package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
- package/build/src/database/query/index.d.ts +13 -1
- package/build/src/database/query/index.d.ts.map +1 -1
- package/build/src/database/query/index.js +15 -3
- package/build/src/database/query/model-class-query.d.ts.map +1 -1
- package/build/src/database/query/model-class-query.js +4 -3
- package/build/src/database/query/query-data.d.ts.map +1 -1
- package/build/src/database/query/query-data.js +69 -8
- package/build/src/database/query/with-count.d.ts.map +1 -1
- package/build/src/database/query/with-count.js +60 -26
- package/build/src/database/query-aborted-error.d.ts +23 -0
- package/build/src/database/query-aborted-error.d.ts.map +1 -0
- package/build/src/database/query-aborted-error.js +23 -0
- package/build/src/http-server/client/websocket-session.d.ts +31 -3
- package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
- package/build/src/http-server/client/websocket-session.js +112 -26
- package/build/src/tenants/tenant-aggregator.d.ts +4 -0
- package/build/src/tenants/tenant-aggregator.d.ts.map +1 -1
- package/build/src/tenants/tenant-aggregator.js +4 -3
- package/build/tenants/tenant-aggregator.js +3 -2
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -1
- package/src/background-jobs/store.js +262 -16
- package/src/background-jobs/web/authorization.js +5 -4
- package/src/background-jobs/web/controller.js +13 -16
- package/src/background-jobs/web/counts-channel.js +79 -0
- package/src/background-jobs/web/index.js +2 -0
- package/src/background-jobs/web/registry.js +1 -1
- package/src/database/drivers/base.js +9 -2
- package/src/database/drivers/mysql/index.js +11 -2
- package/src/database/drivers/mysql/query.js +198 -22
- package/src/database/query/index.js +16 -1
- package/src/database/query/model-class-query.js +3 -2
- package/src/database/query/query-data.js +75 -7
- package/src/database/query/with-count.js +64 -28
- package/src/database/query-aborted-error.js +24 -0
- package/src/http-server/client/websocket-session.js +134 -28
- package/src/tenants/tenant-aggregator.js +3 -2
|
@@ -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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* @param {
|
|
5
|
-
* @
|
|
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
|
-
|
|
14
|
+
function checkoutConnection(pool, sql, signal) {
|
|
8
15
|
return new Promise((resolve, reject) => {
|
|
9
|
-
|
|
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(
|
|
53
|
+
reject(error)
|
|
12
54
|
} else {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
const value = rowData[field]
|
|
157
|
+
return
|
|
158
|
+
}
|
|
26
159
|
|
|
27
|
-
|
|
28
|
-
|
|
160
|
+
connection.query(sql, (/** @type {?} */ error, /** @type {?} */ results, /** @type {?} */ fields) => {
|
|
161
|
+
if (settled) return
|
|
29
162
|
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
195
|
-
await
|
|
221
|
+
for (const {query} of queryGroups) {
|
|
222
|
+
await executeEntryQuery({primaryKey, query, rootModels})
|
|
196
223
|
}
|
|
197
224
|
}
|
|
198
225
|
|
|
199
226
|
/**
|
|
200
|
-
*
|
|
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
|
-
* @
|
|
207
|
-
* @returns {Promise<void>}
|
|
234
|
+
* @returns {{aliases: string[], query: import("./model-class-query.js").default, signature: string}} - Prepared entry.
|
|
208
235
|
*/
|
|
209
|
-
|
|
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.
|
|
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
|
|
|
@@ -111,40 +111,36 @@ export async function runWithCount({models, modelClass, entries}) {
|
|
|
111
111
|
|
|
112
112
|
const primaryKey = modelClass.primaryKey()
|
|
113
113
|
const parentIds = models.map((model) => /** @type {string | number} */ (model.readColumn(primaryKey)))
|
|
114
|
+
const queryGroups = new Map()
|
|
114
115
|
|
|
115
116
|
for (const entry of entries) {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
? /** @type {number} */ (counts.get(modelPrimaryKeyValue))
|
|
125
|
-
: Number(counts.get(String(modelPrimaryKeyValue)) ?? 0)
|
|
126
|
-
|
|
127
|
-
// Counts go on the record's dedicated association-count map,
|
|
128
|
-
// NOT on `_attributes`, so a virtual `tasksCount` can't shadow a
|
|
129
|
-
// real `tasks_count` column (e.g. a counter_cache) nor leak into
|
|
130
|
-
// attribute-level serialization / change tracking.
|
|
131
|
-
model._setAssociationCount(entry.attributeName, resolvedCount)
|
|
117
|
+
const countQuery = queryForEntry({entry, modelClass, parentIds})
|
|
118
|
+
const sql = countQuery.toSql()
|
|
119
|
+
const existingGroup = queryGroups.get(sql)
|
|
120
|
+
|
|
121
|
+
if (existingGroup) {
|
|
122
|
+
existingGroup.entries.push(entry)
|
|
123
|
+
} else {
|
|
124
|
+
queryGroups.set(sql, {countQuery, entries: [entry]})
|
|
132
125
|
}
|
|
133
126
|
}
|
|
127
|
+
|
|
128
|
+
for (const {countQuery, entries: groupedEntries} of queryGroups.values()) {
|
|
129
|
+
const counts = await executeCountQuery(countQuery)
|
|
130
|
+
|
|
131
|
+
for (const entry of groupedEntries) attachCounts({counts, entry, models, primaryKey})
|
|
132
|
+
}
|
|
134
133
|
}
|
|
135
134
|
|
|
136
135
|
/**
|
|
137
|
-
*
|
|
136
|
+
* Builds the grouped count query for an entry.
|
|
138
137
|
* @param {object} args - Options.
|
|
139
|
-
* @param {WithCountEntry[]} args.entries - All entries, used for error context only.
|
|
140
138
|
* @param {WithCountEntry} args.entry - Entry being evaluated.
|
|
141
139
|
* @param {typeof import("../record/index.js").default} args.modelClass - Parent model class.
|
|
142
140
|
* @param {Array<string | number>} args.parentIds - Primary keys of the loaded parents.
|
|
143
|
-
* @returns {
|
|
141
|
+
* @returns {import("./model-class-query.js").default} - Prepared count query.
|
|
144
142
|
*/
|
|
145
|
-
|
|
146
|
-
void entries
|
|
147
|
-
|
|
143
|
+
function queryForEntry({entry, modelClass, parentIds}) {
|
|
148
144
|
const relationship = modelClass.getRelationshipByName(entry.relationshipName)
|
|
149
145
|
|
|
150
146
|
if (!relationship) {
|
|
@@ -176,20 +172,33 @@ async function countForEntry({entries, entry, modelClass, parentIds}) {
|
|
|
176
172
|
Object.assign(whereConditions, entry.where)
|
|
177
173
|
}
|
|
178
174
|
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
175
|
+
const baseQuery = targetModelClass._newQuery()
|
|
176
|
+
baseQuery._forceQualifyBaseTable = true
|
|
177
|
+
baseQuery.where(whereConditions)
|
|
178
|
+
|
|
179
|
+
const countQuery = relationship.applyScope(baseQuery)
|
|
182
180
|
|
|
183
181
|
countQuery._preload = {}
|
|
184
|
-
countQuery.
|
|
182
|
+
countQuery.reselect()
|
|
185
183
|
|
|
186
184
|
const driver = countQuery.driver
|
|
187
|
-
const quotedTable = driver.quoteTable(
|
|
185
|
+
const quotedTable = driver.quoteTable(countQuery.rootTableReference())
|
|
188
186
|
const quotedFk = driver.quoteColumn(foreignKey)
|
|
187
|
+
const qualifiedForeignKey = `${quotedTable}.${quotedFk}`
|
|
189
188
|
|
|
190
|
-
countQuery.
|
|
189
|
+
countQuery.group(qualifiedForeignKey)
|
|
190
|
+
countQuery.select(`${qualifiedForeignKey} AS parent_id`)
|
|
191
191
|
countQuery.select("COUNT(*) AS count_value")
|
|
192
192
|
|
|
193
|
+
return countQuery
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Executes a prepared grouped count query.
|
|
198
|
+
* @param {import("./model-class-query.js").default} countQuery - Prepared count query.
|
|
199
|
+
* @returns {Promise<Map<string | number, number>>} - Map of parent pk → count.
|
|
200
|
+
*/
|
|
201
|
+
async function executeCountQuery(countQuery) {
|
|
193
202
|
const rows = /** @type {Array<{parent_id: string | number, count_value: string | number}>} */ (
|
|
194
203
|
await countQuery._executeQuery()
|
|
195
204
|
)
|
|
@@ -207,3 +216,30 @@ async function countForEntry({entries, entry, modelClass, parentIds}) {
|
|
|
207
216
|
|
|
208
217
|
return counts
|
|
209
218
|
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Attaches one entry's resolved counts to the loaded models.
|
|
222
|
+
* @param {object} args - Options.
|
|
223
|
+
* @param {Map<string | number, number>} args.counts - Counts keyed by parent primary key.
|
|
224
|
+
* @param {WithCountEntry} args.entry - Entry whose alias receives the counts.
|
|
225
|
+
* @param {import("../record/index.js").default[]} args.models - Loaded parent records.
|
|
226
|
+
* @param {string} args.primaryKey - Parent primary key column.
|
|
227
|
+
* @returns {void}
|
|
228
|
+
*/
|
|
229
|
+
function attachCounts({counts, entry, models, primaryKey}) {
|
|
230
|
+
for (const model of models) {
|
|
231
|
+
const modelPrimaryKeyValue = /** @type {string | number} */ (model.readColumn(primaryKey))
|
|
232
|
+
// Tolerate driver differences in numeric return types: SQLite
|
|
233
|
+
// returns integers as JS numbers, but MySQL's raw driver can
|
|
234
|
+
// return count primary keys as strings. Try both.
|
|
235
|
+
const resolvedCount = counts.has(modelPrimaryKeyValue)
|
|
236
|
+
? /** @type {number} */ (counts.get(modelPrimaryKeyValue))
|
|
237
|
+
: Number(counts.get(String(modelPrimaryKeyValue)) ?? 0)
|
|
238
|
+
|
|
239
|
+
// Counts go on the record's dedicated association-count map,
|
|
240
|
+
// NOT on `_attributes`, so a virtual `tasksCount` can't shadow a
|
|
241
|
+
// real `tasks_count` column (e.g. a counter_cache) nor leak into
|
|
242
|
+
// attribute-level serialization / change tracking.
|
|
243
|
+
model._setAssociationCount(entry.attributeName, resolvedCount)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when a query is aborted via its `AbortSignal`/deadline. This is a
|
|
5
|
+
* terminal, non-retryable outcome whether cancellation happened before checkout
|
|
6
|
+
* or after an in-flight connection had to be destroyed.
|
|
7
|
+
*/
|
|
8
|
+
export default class QueryAbortedError extends Error {
|
|
9
|
+
/**
|
|
10
|
+
* Runs constructor.
|
|
11
|
+
* @param {object} [args] - Options.
|
|
12
|
+
* @param {unknown} [args.cause] - Error cause.
|
|
13
|
+
* @param {boolean} [args.connectionDestroyed] - Whether cancellation destroyed an in-flight connection.
|
|
14
|
+
* @param {string} [args.sql] - The SQL that was aborted.
|
|
15
|
+
*/
|
|
16
|
+
constructor({cause, connectionDestroyed = false, sql} = {}) {
|
|
17
|
+
super("Query aborted before it completed", {cause})
|
|
18
|
+
|
|
19
|
+
this.name = "QueryAbortedError"
|
|
20
|
+
this.code = "VELOCIOUS_QUERY_ABORTED"
|
|
21
|
+
this.connectionDestroyed = connectionDestroyed
|
|
22
|
+
this.sql = sql
|
|
23
|
+
}
|
|
24
|
+
}
|