velocious 1.0.581 → 1.0.583

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 (50) hide show
  1. package/README.md +19 -3
  2. package/build/configuration-types.js +2 -0
  3. package/build/database/drivers/base.js +209 -13
  4. package/build/database/query/preloader/belongs-to.js +32 -31
  5. package/build/database/query/preloader/has-many.js +50 -26
  6. package/build/database/query/preloader/has-one.js +20 -12
  7. package/build/database/query/query-data.js +23 -8
  8. package/build/database/query/with-count.js +47 -11
  9. package/build/environment-handlers/node/cli/commands/test.js +21 -2
  10. package/build/frontend-model-controller.js +131 -28
  11. package/build/src/configuration-types.d.ts +10 -0
  12. package/build/src/configuration-types.d.ts.map +1 -1
  13. package/build/src/configuration-types.js +3 -1
  14. package/build/src/database/drivers/base.d.ts +71 -0
  15. package/build/src/database/drivers/base.d.ts.map +1 -1
  16. package/build/src/database/drivers/base.js +178 -17
  17. package/build/src/database/query/preloader/belongs-to.d.ts.map +1 -1
  18. package/build/src/database/query/preloader/belongs-to.js +33 -31
  19. package/build/src/database/query/preloader/has-many.d.ts.map +1 -1
  20. package/build/src/database/query/preloader/has-many.js +48 -28
  21. package/build/src/database/query/preloader/has-one.d.ts.map +1 -1
  22. package/build/src/database/query/preloader/has-one.js +21 -13
  23. package/build/src/database/query/query-data.js +21 -8
  24. package/build/src/database/query/with-count.js +41 -12
  25. package/build/src/environment-handlers/node/cli/commands/test.d.ts +6 -0
  26. package/build/src/environment-handlers/node/cli/commands/test.d.ts.map +1 -1
  27. package/build/src/environment-handlers/node/cli/commands/test.js +22 -3
  28. package/build/src/frontend-model-controller.d.ts +40 -0
  29. package/build/src/frontend-model-controller.d.ts.map +1 -1
  30. package/build/src/frontend-model-controller.js +116 -28
  31. package/build/src/testing/test-filter-parser.d.ts +5 -0
  32. package/build/src/testing/test-filter-parser.d.ts.map +1 -1
  33. package/build/src/testing/test-filter-parser.js +19 -2
  34. package/build/src/testing/test-suite-splitter.d.ts +16 -1
  35. package/build/src/testing/test-suite-splitter.d.ts.map +1 -1
  36. package/build/src/testing/test-suite-splitter.js +38 -3
  37. package/build/testing/test-filter-parser.js +21 -1
  38. package/build/testing/test-suite-splitter.js +44 -2
  39. package/package.json +4 -1
  40. package/src/configuration-types.js +2 -0
  41. package/src/database/drivers/base.js +209 -13
  42. package/src/database/query/preloader/belongs-to.js +32 -31
  43. package/src/database/query/preloader/has-many.js +50 -26
  44. package/src/database/query/preloader/has-one.js +20 -12
  45. package/src/database/query/query-data.js +23 -8
  46. package/src/database/query/with-count.js +47 -11
  47. package/src/environment-handlers/node/cli/commands/test.js +21 -2
  48. package/src/frontend-model-controller.js +131 -28
  49. package/src/testing/test-filter-parser.js +21 -1
  50. package/src/testing/test-suite-splitter.js +44 -2
package/README.md CHANGED
@@ -23,8 +23,8 @@
23
23
  * Gap-less positional lists with automatic reordering via `actsAsList`, including models with numeric, string, or UUID primary keys (see [docs/acts-as-list.md](docs/acts-as-list.md))
24
24
  * Rails-style nested-attribute writes on frontend-model `save()` (see [docs/nested-attributes.md](docs/nested-attributes.md))
25
25
  * 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))
26
- * Per-row association counts via `.withCount(...)`, including cohort-safe intersected filters and safe batching of structurally identical aggregates, on frontend and backend queries (see [docs/with-count.md](docs/with-count.md))
27
- * 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))
26
+ * Per-row association counts via `.withCount(...)`, including cohort-safe intersected filters, safe batching of structurally identical aggregates, and automatic IN-list chunking for large parent sets, on frontend and backend queries (see [docs/with-count.md](docs/with-count.md))
27
+ * Consumer-defined per-row SQL aggregates/computations via `.queryData(...)`, with compatible projections sharing a roundtrip while preserving declared alias-overwrite order and automatic IN-list chunking for large parent sets, on frontend and backend queries (see [docs/query-data.md](docs/query-data.md))
28
28
  * Per-record ability checks via `.abilities(...)` on frontend queries + `record.can(action)` (see [docs/abilities.md](docs/abilities.md))
29
29
  * Translated model attributes with current-locale relationship sorting (see [docs/translations.md](docs/translations.md))
30
30
  * Cross-process broadcast bus for `broadcastToChannel` via `velocious beacon`, including background job runner processes (see [docs/beacon.md](docs/beacon.md))
@@ -290,10 +290,26 @@ npx velocious test --groups=4 --group-number=2
290
290
 
291
291
  # Combine with tags
292
292
  npx velocious test --groups=3 --group-number=1 --tag fast
293
+
294
+ # Prefer recorded file durations when balancing groups
295
+ npx velocious test --groups=4 --group-number=1 --timing-manifest=tmp/test-timings.json
293
296
  ```
294
297
 
295
298
  Files are distributed using a greedy load-balancing algorithm. Each file is
296
- weighted by its spec directory (`system/` = 20, `frontend-models/` = 10,
299
+ primarily weighted by a positive finite duration from the optional timing
300
+ manifest. Manifest keys are normalized project-relative test paths using `/`
301
+ separators:
302
+
303
+ ```json
304
+ {
305
+ "spec/system/sign-in-spec.js": 42.7,
306
+ "spec/controller/accounts-spec.js": 8.1
307
+ }
308
+ ```
309
+
310
+ Files absent from the manifest, unknown files, invalid duration values, and
311
+ unreadable or malformed JSON all fall back to the existing deterministic
312
+ heuristic. The heuristic weights files by spec directory (`system/` = 20, `frontend-models/` = 10,
297
313
  `controller/` = 3, default = 1) with a 2x multiplier for `.browser-spec.js`
298
314
  files. The heaviest files are assigned first to the group with the least
299
315
  accumulated weight, producing balanced wall-clock times across groups.
@@ -75,6 +75,8 @@
75
75
  * @property {boolean} [multipleStatements] - (MySQL) Opt in to multi-statement queries so a whole structure SQL dump loads in one round-trip via `StructureSqlLoader`. Off by default; ordinary queries otherwise reject stacked statements.
76
76
  * @property {number} [maxRowsPerInsert] - Maximum rows per `INSERT ... VALUES (...), (...), ...` statement generated by `Record.insertMultiple`. Defaults to 500.
77
77
  * @property {number} [maxInsertSqlBytes] - Maximum serialized SQL size, in bytes, for a single `INSERT ... VALUES (...), (...), ...` statement. Defaults to 1 MiB (1048576).
78
+ * @property {number} [maxInClauseValues] - Maximum values in a single `IN (...)` cohort used by preloads, association counts, and queryData aggregates. Defaults to 999.
79
+ * @property {number} [maxQuerySqlBytes] - Maximum serialized SQL size, in bytes, for a single cohort query used by preloads, association counts, and queryData aggregates. Defaults to 1 MiB (1048576).
78
80
  * @property {string} [password] - Password for the database user.
79
81
  * @property {number} [port] - Database port.
80
82
  * @property {string} [primaryKeyType] - Default type for implicit migration primary keys and references. Defaults to `uuid`.
@@ -118,6 +118,14 @@
118
118
  * @property {string[]} updateColumns - Columns to update on conflict.
119
119
  */
120
120
 
121
+ /**
122
+ * SqlTokenResult type.
123
+ * @typedef {object} SqlTokenResult
124
+ * @property {boolean} incomplete - Whether the scan hit its bound before finishing trivia/token parsing.
125
+ * @property {string | undefined} token - Lowercased token when parsing completed; undefined when no token was found.
126
+ * @property {number} index - Index immediately after the parsed token or trivia.
127
+ */
128
+
121
129
  import BacktraceCleaner from "../../utils/backtrace-cleaner.js"
122
130
  import { getDatabaseAnnotations } from "../annotations.js"
123
131
  import { formatDateForDatabase } from "../datetime-storage.js"
@@ -134,6 +142,11 @@ import TableForeignKey from "../table-data/table-foreign-key.js"
134
142
  import wait from "awaitery/build/wait.js"
135
143
  import {optionalPositiveInteger} from "typanic"
136
144
 
145
+ /** Maximum characters inspected when building the debug SQL preview. */
146
+ const SQL_PREVIEW_SCAN_LIMIT = 4096
147
+ /** Maximum characters inspected when deciding whether a statement invalidates schema metadata. */
148
+ const SCHEMA_INVALIDATION_SCAN_LIMIT = 8192
149
+
137
150
  /**
138
151
  * Marks a callback failure that happened after the owning transaction was durably committed.
139
152
  * The public transaction boundary unwraps it before deadlock classification.
@@ -793,10 +806,82 @@ export default class VelociousDatabaseDriversBase {
793
806
  return optionalPositiveInteger(this.getArgs().maxInsertSqlBytes, "maxInsertSqlBytes") ?? 1048576
794
807
  }
795
808
 
809
+ /**
810
+ * Maximum values in a single `IN (...)` cohort used by preloads, association
811
+ * counts, and queryData aggregates. The default stays under SQLite's default
812
+ * `MAX_VARIABLE_NUMBER` compile-time limit.
813
+ *
814
+ * Override via `maxInClauseValues` in the database configuration.
815
+ * @returns {number} - Maximum values per IN clause cohort.
816
+ */
817
+ maxInClauseValues() {
818
+ return optionalPositiveInteger(this.getArgs().maxInClauseValues, "maxInClauseValues") ?? 999
819
+ }
820
+
821
+ /**
822
+ * Maximum serialized SQL size, in bytes, for a single cohort query used by
823
+ * preloads, association counts, and queryData aggregates. Cohort chunking
824
+ * stops when the next value would push the generated string over this threshold.
825
+ *
826
+ * Override via `maxQuerySqlBytes` in the database configuration.
827
+ * @returns {number} - Maximum bytes per cohort query.
828
+ */
829
+ maxQuerySqlBytes() {
830
+ return optionalPositiveInteger(this.getArgs().maxQuerySqlBytes, "maxQuerySqlBytes") ?? 1048576
831
+ }
832
+
833
+ /**
834
+ * Splits `values` into cohort chunks that stay within both `maxCount` and
835
+ * `maxBytes` while preserving order.
836
+ *
837
+ * A chunk always contains at least one value, even if that single value exceeds
838
+ * the byte limit, so progress is guaranteed.
839
+ * @template T
840
+ * @param {Array<T>} values - Values to chunk.
841
+ * @param {(values: Array<T>) => string} buildSql - Function that builds the full SQL for a candidate chunk.
842
+ * @param {{maxCount?: number, maxBytes?: number}} [options] - Chunking bounds.
843
+ * @returns {Array<Array<T>>} - Value cohorts.
844
+ */
845
+ chunkValues(values, buildSql, {maxCount = this.maxInClauseValues(), maxBytes = this.maxQuerySqlBytes()} = {}) {
846
+ if (values.length === 0) return []
847
+
848
+ /**
849
+ * Chunks.
850
+ * @type {Array<Array<T>>} */
851
+ const chunks = []
852
+ /**
853
+ * Current chunk.
854
+ * @type {Array<T>} */
855
+ let currentChunk = []
856
+
857
+ for (const value of values) {
858
+ const candidate = [...currentChunk, value]
859
+ const candidateBytes = Buffer.byteLength(buildSql(candidate), "utf8")
860
+
861
+ if (currentChunk.length > 0 && (candidate.length > maxCount || candidateBytes > maxBytes)) {
862
+ chunks.push(currentChunk)
863
+ currentChunk = [value]
864
+ } else {
865
+ currentChunk = candidate
866
+ }
867
+ }
868
+
869
+ if (currentChunk.length > 0) {
870
+ chunks.push(currentChunk)
871
+ }
872
+
873
+ return chunks
874
+ }
875
+
796
876
  /**
797
877
  * Splits `rows` into chunks that stay within both {@link maxRowsPerInsert}
798
878
  * and {@link maxInsertSqlBytes} while preserving order.
799
879
  *
880
+ * Byte accounting is incremental: `buildSql` is called once with `[]` to
881
+ * measure the statement prefix and once per row with `[row]` to measure the
882
+ * row's values tuple. This keeps chunking linear in the number of rows
883
+ * instead of rebuilding the full multi-row SQL for every candidate.
884
+ *
800
885
  * A chunk always contains at least one row, even if that single row exceeds
801
886
  * the byte limit, so progress is guaranteed.
802
887
  * @param {Array<Array<ReturnType<typeof JSON.parse>>>} rows - Rows to insert.
@@ -811,6 +896,9 @@ export default class VelociousDatabaseDriversBase {
811
896
  const prefix = `${emptySql} VALUES `
812
897
  const baseByteLength = Buffer.byteLength(prefix, "utf8")
813
898
 
899
+ /**
900
+ * Current chunk.
901
+ * @type {Array<Array<ReturnType<typeof JSON.parse>>>} */
814
902
  let currentChunk = []
815
903
  let currentBytes = 0
816
904
 
@@ -1604,13 +1692,25 @@ export default class VelociousDatabaseDriversBase {
1604
1692
  }
1605
1693
  }
1606
1694
 
1695
+ /**
1696
+ * Returns a bounded prefix of `sql` for lightweight diagnostic scanning.
1697
+ * @param {string} sql - SQL string.
1698
+ * @param {number} limit - Maximum code units to inspect.
1699
+ * @returns {string} - Prefix of `sql`.
1700
+ */
1701
+ _diagnosticSqlPrefix(sql, limit) {
1702
+ return sql.length <= limit ? sql : sql.slice(0, limit)
1703
+ }
1704
+
1607
1705
  /**
1608
1706
  * Runs debug sql preview.
1609
1707
  * @param {string} sql - SQL to preview.
1610
1708
  * @returns {string} - Normalized truncated SQL preview for diagnostics.
1611
1709
  */
1612
1710
  _debugSqlPreview(sql) {
1613
- return sql
1711
+ const prefix = this._diagnosticSqlPrefix(sql, SQL_PREVIEW_SCAN_LIMIT)
1712
+
1713
+ return prefix
1614
1714
  .replace(/\s+/g, " ")
1615
1715
  .trim()
1616
1716
  .slice(0, 500)
@@ -1664,25 +1764,121 @@ export default class VelociousDatabaseDriversBase {
1664
1764
  .replace(/"/g, "'")
1665
1765
  }
1666
1766
 
1767
+ /**
1768
+ * Reads the next SQL token starting at `startIndex`, skipping leading trivia
1769
+ * (BOM, whitespace, block comments, line comments). If the scan cannot finish
1770
+ * skipping trivia before `limit`, the result is marked incomplete so callers
1771
+ * can conservatively treat the statement as schema-invalidating.
1772
+ * @param {string} sql - SQL string.
1773
+ * @param {number} startIndex - Index to start scanning.
1774
+ * @param {number} limit - Maximum absolute index to scan while skipping leading trivia.
1775
+ * @returns {SqlTokenResult} - Token result.
1776
+ */
1777
+ _readSqlToken(sql, startIndex, limit) {
1778
+ let i = startIndex
1779
+ const len = sql.length
1780
+
1781
+ while (i < len && i < limit) {
1782
+ const char = sql[i]
1783
+
1784
+ if (char === "\ufeff" || /\s/.test(char)) {
1785
+ i++
1786
+ continue
1787
+ }
1788
+
1789
+ if (char === "/" && sql[i + 1] === "*") {
1790
+ const close = sql.indexOf("*/", i + 2)
1791
+
1792
+ if (close === -1 || close + 2 > limit) {
1793
+ return {incomplete: true, index: i, token: undefined}
1794
+ }
1795
+
1796
+ i = close + 2
1797
+ continue
1798
+ }
1799
+
1800
+ if (char === "-" && sql[i + 1] === "-") {
1801
+ const newline = sql.indexOf("\n", i + 2)
1802
+
1803
+ if (newline === -1) {
1804
+ return {incomplete: false, index: len, token: undefined}
1805
+ }
1806
+
1807
+ if (newline + 1 > limit) {
1808
+ return {incomplete: true, index: i, token: undefined}
1809
+ }
1810
+
1811
+ i = newline + 1
1812
+ continue
1813
+ }
1814
+
1815
+ let token = ""
1816
+
1817
+ while (i < len) {
1818
+ const c = sql[i]
1819
+
1820
+ if (/\s/.test(c) || c === "\ufeff") break
1821
+ if (c === "/" && sql[i + 1] === "*") break
1822
+ if (c === "-" && sql[i + 1] === "-") break
1823
+
1824
+ token += c
1825
+ i++
1826
+ }
1827
+
1828
+ return {incomplete: false, token: token.toLowerCase(), index: i}
1829
+ }
1830
+
1831
+ if (i >= len) {
1832
+ return {incomplete: false, index: len, token: undefined}
1833
+ }
1834
+
1835
+ return {incomplete: true, index: i, token: undefined}
1836
+ }
1837
+
1667
1838
  /**
1668
1839
  * Runs schema cache invalidating sql.
1669
1840
  * @param {string} sql - SQL string.
1670
1841
  * @returns {boolean} - Whether the SQL should invalidate schema metadata.
1671
1842
  */
1672
1843
  _schemaCacheInvalidatingSql(sql) {
1673
- const normalized = sql
1674
- .trim()
1675
- .replace(/^\ufeff/, "")
1676
- .replace(/\/\*[\s\S]*?\*\//g, " ")
1677
- .replace(/--[^\n]*(\n|$)/g, " ")
1678
- .replace(/\s+/g, " ")
1679
- .toLowerCase()
1844
+ const first = this._readSqlToken(sql, 0, SCHEMA_INVALIDATION_SCAN_LIMIT)
1680
1845
 
1681
- if (!normalized) return false
1682
- if (/^(create|alter|drop|rename)\b/.test(normalized)) return true
1683
- if (/^comment\s+on\b/.test(normalized)) return true
1684
- if (/^exec(?:ute)?\s+sp_rename\b/.test(normalized)) return true
1685
- if (/^if\b[\s\S]*\bbegin\s+(create|alter|drop|rename)\b/.test(normalized)) return true
1846
+ if (first.incomplete) return true
1847
+
1848
+ const firstToken = first.token
1849
+
1850
+ if (!firstToken) return false
1851
+ if (/^(create|alter|drop|rename)$/.test(firstToken)) return true
1852
+
1853
+ if (firstToken === "comment") {
1854
+ const next = this._readSqlToken(sql, first.index, SCHEMA_INVALIDATION_SCAN_LIMIT)
1855
+
1856
+ return next.incomplete || next.token === "on"
1857
+ }
1858
+
1859
+ if (firstToken === "exec" || firstToken === "execute") {
1860
+ const next = this._readSqlToken(sql, first.index, SCHEMA_INVALIDATION_SCAN_LIMIT)
1861
+
1862
+ return next.incomplete || next.token === "sp_rename"
1863
+ }
1864
+
1865
+ if (firstToken === "if") {
1866
+ let index = first.index
1867
+
1868
+ while (true) {
1869
+ const result = this._readSqlToken(sql, index, SCHEMA_INVALIDATION_SCAN_LIMIT)
1870
+
1871
+ if (result.incomplete) return true
1872
+ if (!result.token) return false
1873
+ if (result.token === "begin") {
1874
+ const ddlResult = this._readSqlToken(sql, result.index, SCHEMA_INVALIDATION_SCAN_LIMIT)
1875
+
1876
+ return ddlResult.incomplete || /^(create|alter|drop|rename)$/.test(ddlResult.token || "")
1877
+ }
1878
+
1879
+ index = result.index
1880
+ }
1881
+ }
1686
1882
 
1687
1883
  return false
1688
1884
  }
@@ -87,25 +87,27 @@ export default class VelociousDatabaseQueryPreloaderBelongsTo {
87
87
  if (foreignKeyValues.size > 0) {
88
88
  await ensureModelClassInitialized(targetModelClass, this.relationship.getConfiguration(), modelsToLoad[0])
89
89
 
90
- /**
91
- * Where args.
92
- * @type {Record<string, string | number | Array<string | number>>} */
93
- const whereArgs = {}
90
+ // Build the query once with scope and selection, then clone it per cohort so
91
+ // the IN-list size stays within driver limits without rebuilding shared state.
92
+ let baseQuery = preloadQueryForModel(modelsToLoad, targetModelClass)
94
93
 
95
- whereArgs[primaryKey] = [...foreignKeyValues]
94
+ baseQuery = this.relationship.applyScope(baseQuery)
95
+ baseQuery = this.selection.applyToQuery({query: baseQuery, targetModelClass, mappingColumns: [primaryKey]})
96
96
 
97
- // Load target models to be preloaded on the given models
98
- let query = preloadQueryForModel(modelsToLoad, targetModelClass).where(whereArgs)
97
+ const driver = baseQuery.driver
98
+ const cohorts = driver.chunkValues([...foreignKeyValues], (chunk) => baseQuery.clone().where({[primaryKey]: chunk}).toSql())
99
99
 
100
- query = this.relationship.applyScope(query)
101
- query = this.selection.applyToQuery({query, targetModelClass, mappingColumns: [primaryKey]})
100
+ for (const cohort of cohorts) {
101
+ const cohortQuery = baseQuery.clone().where({[primaryKey]: cohort})
102
+ const foundTargetModels = await cohortQuery.toArray()
102
103
 
103
- targetModels = await query.toArray()
104
+ targetModels.push(...foundTargetModels)
104
105
 
105
- for (const targetModel of targetModels) {
106
- const primaryKeyValue = /** @type {string | number} */ (targetModel.readColumn(primaryKey))
106
+ for (const targetModel of foundTargetModels) {
107
+ const primaryKeyValue = /** @type {string | number} */ (targetModel.readColumn(primaryKey))
107
108
 
108
- targetModelsById[primaryKeyValue] = targetModel
109
+ targetModelsById[primaryKeyValue] = targetModel
110
+ }
109
111
  }
110
112
  }
111
113
 
@@ -205,33 +207,32 @@ export default class VelociousDatabaseQueryPreloaderBelongsTo {
205
207
 
206
208
  await ensureModelClassInitialized(targetModelClass, configuration, this.models[0])
207
209
 
208
- /**
209
- * Where args.
210
- * @type {Record<string, string | number | Array<string | number>>} */
211
- const whereArgs = {}
210
+ let baseQuery = preloadQueryForModel(this.models, targetModelClass)
212
211
 
213
- whereArgs[primaryKey] = [...foreignKeyValuesByType[targetType]]
212
+ baseQuery = this.relationship.applyScope(baseQuery)
213
+ baseQuery = this.selection.applyToQuery({query: baseQuery, targetModelClass, mappingColumns: [primaryKey]})
214
214
 
215
- let query = preloadQueryForModel(this.models, targetModelClass).where(whereArgs)
215
+ const driver = baseQuery.driver
216
+ const cohorts = driver.chunkValues([...foreignKeyValuesByType[targetType]], (chunk) => baseQuery.clone().where({[primaryKey]: chunk}).toSql())
216
217
 
217
- query = this.relationship.applyScope(query)
218
- query = this.selection.applyToQuery({query, targetModelClass, mappingColumns: [primaryKey]})
218
+ targetModelsByTypeAndId[targetType] = {}
219
219
 
220
- const foundTargetModels = await query.toArray()
220
+ for (const cohort of cohorts) {
221
+ const cohortQuery = baseQuery.clone().where({[primaryKey]: cohort})
222
+ const foundTargetModels = await cohortQuery.toArray()
221
223
 
222
- targetModels.push(...foundTargetModels)
224
+ targetModels.push(...foundTargetModels)
223
225
 
224
- const className = targetModelClass.getModelName()
226
+ const className = targetModelClass.getModelName()
225
227
 
226
- if (!targetModelsByClassName[className]) targetModelsByClassName[className] = []
227
- targetModelsByClassName[className].push(...foundTargetModels)
228
+ if (!targetModelsByClassName[className]) targetModelsByClassName[className] = []
229
+ targetModelsByClassName[className].push(...foundTargetModels)
228
230
 
229
- targetModelsByTypeAndId[targetType] = {}
231
+ for (const targetModel of foundTargetModels) {
232
+ const primaryKeyValue = /** @type {string | number} */ (targetModel.readColumn(primaryKey))
230
233
 
231
- for (const targetModel of foundTargetModels) {
232
- const primaryKeyValue = /** @type {string | number} */ (targetModel.readColumn(primaryKey))
233
-
234
- targetModelsByTypeAndId[targetType][primaryKeyValue] = targetModel
234
+ targetModelsByTypeAndId[targetType][primaryKeyValue] = targetModel
235
+ }
235
236
  }
236
237
  }
237
238
 
@@ -155,10 +155,11 @@ export default class VelociousDatabaseQueryPreloaderHasMany {
155
155
  modelsByPrimaryKeyValue[primaryKeyValue].push(model)
156
156
  }
157
157
 
158
- // Step 1: Query the through table to build parent→target ID mapping
159
- const throughModels = await preloadQueryForModel(modelsToLoad, throughModelClass)
160
- .where({[throughForeignKey]: [...modelsPrimaryKeyValues]})
161
- .toArray()
158
+ // Step 1: Query the through table to build parent→target ID mapping.
159
+ // Chunk the parent PK cohort so the through query's IN-list stays bounded.
160
+ const throughBaseQuery = preloadQueryForModel(modelsToLoad, throughModelClass)
161
+ const throughDriver = throughBaseQuery.driver
162
+ const throughCohorts = throughDriver.chunkValues([...modelsPrimaryKeyValues], (chunk) => throughBaseQuery.clone().where({[throughForeignKey]: chunk}).toSql())
162
163
 
163
164
  /**
164
165
  * Parent to target ids.
@@ -170,28 +171,43 @@ export default class VelociousDatabaseQueryPreloaderHasMany {
170
171
  * @type {Set<string | number>} */
171
172
  const allTargetIds = new Set()
172
173
 
173
- for (const throughModel of throughModels) {
174
- const parentId = /** @type {string | number} */ (throughModel.readColumn(throughForeignKey))
175
- const throughId = /** @type {string | number} */ (throughModel.readColumn(throughModelClass.primaryKey()))
174
+ for (const cohort of throughCohorts) {
175
+ const throughQuery = throughBaseQuery.clone().where({[throughForeignKey]: cohort})
176
+ const throughModels = await throughQuery.toArray()
176
177
 
177
- if (!(parentId in parentToTargetIds)) parentToTargetIds[parentId] = []
178
+ for (const throughModel of throughModels) {
179
+ const parentId = /** @type {string | number} */ (throughModel.readColumn(throughForeignKey))
180
+ const throughId = /** @type {string | number} */ (throughModel.readColumn(throughModelClass.primaryKey()))
178
181
 
179
- parentToTargetIds[parentId].push(throughId)
180
- allTargetIds.add(throughId)
182
+ if (!(parentId in parentToTargetIds)) parentToTargetIds[parentId] = []
183
+
184
+ parentToTargetIds[parentId].push(throughId)
185
+ allTargetIds.add(throughId)
186
+ }
181
187
  }
182
188
 
183
- // Step 2: Load target models by the foreign key that points to the through table
189
+ // Step 2: Load target models by the foreign key that points to the through table.
190
+ // Chunk the target ID cohort so the target query's IN-list stays bounded.
184
191
  /**
185
192
  * Target models.
186
193
  * @type {import("../../record/index.js").default[]} */
187
194
  let targetModels = []
188
195
 
189
196
  if (allTargetIds.size > 0) {
190
- let query = preloadQueryForModel(modelsToLoad, targetModelClass).where({[targetForeignKey]: [...allTargetIds]})
197
+ let targetBaseQuery = preloadQueryForModel(modelsToLoad, targetModelClass)
198
+
199
+ targetBaseQuery = this.relationship.applyScope(targetBaseQuery)
200
+ targetBaseQuery = this.selection.applyToQuery({query: targetBaseQuery, targetModelClass, mappingColumns: [targetForeignKey]})
201
+
202
+ const targetDriver = targetBaseQuery.driver
203
+ const targetCohorts = targetDriver.chunkValues([...allTargetIds], (chunk) => targetBaseQuery.clone().where({[targetForeignKey]: chunk}).toSql())
191
204
 
192
- query = this.relationship.applyScope(query)
193
- query = this.selection.applyToQuery({query, targetModelClass, mappingColumns: [targetForeignKey]})
194
- targetModels = await query.toArray()
205
+ for (const cohort of targetCohorts) {
206
+ const cohortQuery = targetBaseQuery.clone().where({[targetForeignKey]: cohort})
207
+ const foundTargetModels = await cohortQuery.toArray()
208
+
209
+ targetModels.push(...foundTargetModels)
210
+ }
195
211
  }
196
212
 
197
213
  // Step 3: Index target models by their foreign key (maps to through model ID)
@@ -285,27 +301,35 @@ export default class VelociousDatabaseQueryPreloaderHasMany {
285
301
  modelsByPrimaryKeyValue[primaryKeyValue].push(model)
286
302
  }
287
303
 
288
- /**
289
- * Where args.
290
- * @type {Record<string, string | number | Array<string | number>>} */
291
- const whereArgs = {}
304
+ await ensureModelClassInitialized(targetModelClass, this.relationship.getConfiguration(), modelsToLoad[0])
292
305
 
293
- whereArgs[foreignKey] = [...modelsPrimaryKeyValues]
306
+ // Build the query once with the polymorphic type constant (when present),
307
+ // relationship scope, and selection. The parent ID IN-list is cloned per cohort
308
+ // so the generated SQL stays within driver limits.
309
+ let baseQuery = preloadQueryForModel(modelsToLoad, targetModelClass)
294
310
 
295
311
  if (this.relationship.getPolymorphic()) {
296
312
  const typeColumn = this.relationship.getPolymorphicTypeColumn()
297
313
 
298
- whereArgs[typeColumn] = this.relationship.getModelClass().getModelName()
314
+ baseQuery = baseQuery.where({[typeColumn]: this.relationship.getModelClass().getModelName()})
299
315
  }
300
316
 
301
- await ensureModelClassInitialized(targetModelClass, this.relationship.getConfiguration(), modelsToLoad[0])
317
+ baseQuery = this.relationship.applyScope(baseQuery)
318
+ baseQuery = this.selection.applyToQuery({query: baseQuery, targetModelClass, mappingColumns: [foreignKey]})
302
319
 
303
- let query = preloadQueryForModel(modelsToLoad, targetModelClass).where(whereArgs)
320
+ /**
321
+ * Target models.
322
+ * @type {import("../../record/index.js").default[]} */
323
+ const targetModels = []
324
+ const driver = baseQuery.driver
325
+ const cohorts = driver.chunkValues([...modelsPrimaryKeyValues], (chunk) => baseQuery.clone().where({[foreignKey]: chunk}).toSql())
304
326
 
305
- query = this.relationship.applyScope(query)
306
- query = this.selection.applyToQuery({query, targetModelClass, mappingColumns: [foreignKey]})
327
+ for (const cohort of cohorts) {
328
+ const cohortQuery = baseQuery.clone().where({[foreignKey]: cohort})
329
+ const foundTargetModels = await cohortQuery.toArray()
307
330
 
308
- const targetModels = await query.toArray()
331
+ targetModels.push(...foundTargetModels)
332
+ }
309
333
 
310
334
  for (const targetModel of targetModels) {
311
335
  const foreignKeyValue = /** @type {string | number} */ (targetModel.readColumn(foreignKey))
@@ -72,28 +72,36 @@ export default class VelociousDatabaseQueryPreloaderHasOne {
72
72
 
73
73
  if (modelsPrimaryKeyValues.size == 0) return satisfiedTargets
74
74
 
75
- /**
76
- * Where args.
77
- * @type {Record<string, string | number | Array<string | number>>} */
78
- const whereArgs = {}
75
+ await ensureModelClassInitialized(targetModelClass, this.relationship.getConfiguration(), this.models[0])
79
76
 
80
- whereArgs[foreignKey] = [...modelsPrimaryKeyValues]
77
+ // Load target models to be preloaded on the given models.
78
+ // Build the query once with the polymorphic type constant (when present),
79
+ // relationship scope, and selection. The parent ID IN-list is cloned per cohort
80
+ // so the generated SQL stays within driver limits.
81
+ let baseQuery = preloadQueryForModel(this.models, targetModelClass)
81
82
 
82
83
  if (this.relationship.getPolymorphic()) {
83
84
  const typeColumn = this.relationship.getPolymorphicTypeColumn()
84
85
 
85
- whereArgs[typeColumn] = this.relationship.getModelClass().getModelName()
86
+ baseQuery = baseQuery.where({[typeColumn]: this.relationship.getModelClass().getModelName()})
86
87
  }
87
88
 
88
- await ensureModelClassInitialized(targetModelClass, this.relationship.getConfiguration(), this.models[0])
89
+ baseQuery = this.relationship.applyScope(baseQuery)
90
+ baseQuery = this.selection.applyToQuery({query: baseQuery, targetModelClass, mappingColumns: [foreignKey]})
89
91
 
90
- // Load target models to be preloaded on the given models
91
- let query = preloadQueryForModel(this.models, targetModelClass).where(whereArgs)
92
+ /**
93
+ * Target models.
94
+ * @type {import("../../record/index.js").default[]} */
95
+ const targetModels = []
96
+ const driver = baseQuery.driver
97
+ const cohorts = driver.chunkValues([...modelsPrimaryKeyValues], (chunk) => baseQuery.clone().where({[foreignKey]: chunk}).toSql())
92
98
 
93
- query = this.relationship.applyScope(query)
94
- query = this.selection.applyToQuery({query, targetModelClass, mappingColumns: [foreignKey]})
99
+ for (const cohort of cohorts) {
100
+ const cohortQuery = baseQuery.clone().where({[foreignKey]: cohort})
101
+ const foundTargetModels = await cohortQuery.toArray()
95
102
 
96
- const targetModels = await query.toArray()
103
+ targetModels.push(...foundTargetModels)
104
+ }
97
105
 
98
106
  for (const targetModel of targetModels) {
99
107
  const foreignKeyValue = /** @type {string | number} */ (targetModel.readColumn(foreignKey))
@@ -227,7 +227,7 @@ export async function runQueryData({rootModelClass, rootModels, entries}) {
227
227
  }
228
228
 
229
229
  for (const {query} of queryGroups) {
230
- await executeEntryQuery({primaryKey, query, rootModels})
230
+ await executeChunkedEntryQuery({primaryKey, query, rootIds, rootModels})
231
231
  }
232
232
  }
233
233
 
@@ -267,13 +267,6 @@ function prepareEntry({entry, entryIndex, primaryKey, rootIds, rootModelClass, s
267
267
  const rootTable = rootModelClass.tableName()
268
268
  const rootPkSql = `${driver.quoteTable(rootTable)}.${driver.quoteColumn(primaryKey)}`
269
269
 
270
- /**
271
- * Root where.
272
- * @type {Record<string, ReturnType<typeof JSON.parse>>} */
273
- const rootWhere = {}
274
- rootWhere[primaryKey] = rootIds
275
- query.where(rootWhere)
276
-
277
270
  const joinDescriptor = buildNestedJoinDescriptor(entry.chain)
278
271
 
279
272
  if (joinDescriptor !== true) {
@@ -370,3 +363,25 @@ async function executeEntryQuery({primaryKey, query, rootModels}) {
370
363
  }
371
364
  }
372
365
  }
366
+
367
+ /**
368
+ * Executes one compatible queryData group in cohorts so the root ID IN-list
369
+ * stays within driver limits, attaching each selected alias to the matching
370
+ * root record.
371
+ * @param {object} args - Options.
372
+ * @param {string} args.primaryKey - Root model primary key column.
373
+ * @param {import("./model-class-query.js").default} args.query - Prepared grouped query.
374
+ * @param {Array<string | number>} args.rootIds - Root primary-key values.
375
+ * @param {import("../record/index.js").default[]} args.rootModels - Loaded root records.
376
+ * @returns {Promise<void>}
377
+ */
378
+ async function executeChunkedEntryQuery({primaryKey, query, rootIds, rootModels}) {
379
+ const driver = query.driver
380
+ const cohorts = driver.chunkValues(rootIds, (chunk) => query.clone().where({[primaryKey]: chunk}).toSql())
381
+
382
+ for (const cohort of cohorts) {
383
+ const cohortQuery = query.clone().where({[primaryKey]: cohort})
384
+
385
+ await executeEntryQuery({primaryKey, query: cohortQuery, rootModels})
386
+ }
387
+ }