velocious 1.0.593 → 1.0.594

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 (131) hide show
  1. package/README.md +3 -2
  2. package/build/configuration.js +68 -13
  3. package/build/database/drivers/base.js +30 -0
  4. package/build/database/migrator.js +90 -24
  5. package/build/database/operation.js +55 -4
  6. package/build/database/query/join-object.js +7 -4
  7. package/build/database/query/model-class-query.js +38 -7
  8. package/build/database/query/preloader/belongs-to.js +9 -6
  9. package/build/database/query/preloader/has-many.js +25 -14
  10. package/build/database/query/preloader/has-one.js +7 -4
  11. package/build/database/query/preloader/query-for-model.js +16 -1
  12. package/build/database/query/preloader.js +6 -4
  13. package/build/database/query/query-data.js +3 -3
  14. package/build/database/query/where-model-class-hash.js +8 -4
  15. package/build/database/query/with-count.js +4 -3
  16. package/build/database/record/index.js +148 -5
  17. package/build/database/record/instance-relationships/base.js +13 -0
  18. package/build/database/record/instance-relationships/belongs-to.js +1 -1
  19. package/build/database/record/instance-relationships/has-many.js +5 -2
  20. package/build/database/record/instance-relationships/has-one.js +1 -1
  21. package/build/database/record/relationships/base.js +17 -0
  22. package/build/database/use-database.js +81 -28
  23. package/build/src/configuration.d.ts +13 -4
  24. package/build/src/configuration.d.ts.map +1 -1
  25. package/build/src/configuration.js +56 -16
  26. package/build/src/database/drivers/base.d.ts +11 -0
  27. package/build/src/database/drivers/base.d.ts.map +1 -1
  28. package/build/src/database/drivers/base.js +28 -1
  29. package/build/src/database/migrator.d.ts +34 -0
  30. package/build/src/database/migrator.d.ts.map +1 -1
  31. package/build/src/database/migrator.js +83 -23
  32. package/build/src/database/operation.d.ts +15 -1
  33. package/build/src/database/operation.d.ts.map +1 -1
  34. package/build/src/database/operation.js +52 -5
  35. package/build/src/database/query/join-object.d.ts.map +1 -1
  36. package/build/src/database/query/join-object.js +7 -5
  37. package/build/src/database/query/model-class-query.d.ts +6 -0
  38. package/build/src/database/query/model-class-query.d.ts.map +1 -1
  39. package/build/src/database/query/model-class-query.js +33 -8
  40. package/build/src/database/query/preloader/belongs-to.d.ts.map +1 -1
  41. package/build/src/database/query/preloader/belongs-to.js +9 -7
  42. package/build/src/database/query/preloader/has-many.d.ts.map +1 -1
  43. package/build/src/database/query/preloader/has-many.js +23 -16
  44. package/build/src/database/query/preloader/has-one.d.ts.map +1 -1
  45. package/build/src/database/query/preloader/has-one.js +7 -5
  46. package/build/src/database/query/preloader/query-for-model.d.ts +8 -0
  47. package/build/src/database/query/preloader/query-for-model.d.ts.map +1 -1
  48. package/build/src/database/query/preloader/query-for-model.js +15 -2
  49. package/build/src/database/query/preloader.d.ts.map +1 -1
  50. package/build/src/database/query/preloader.js +6 -5
  51. package/build/src/database/query/query-data.js +4 -4
  52. package/build/src/database/query/where-model-class-hash.d.ts.map +1 -1
  53. package/build/src/database/query/where-model-class-hash.js +7 -5
  54. package/build/src/database/query/with-count.js +5 -4
  55. package/build/src/database/record/index.d.ts +95 -0
  56. package/build/src/database/record/index.d.ts.map +1 -1
  57. package/build/src/database/record/index.js +126 -6
  58. package/build/src/database/record/instance-relationships/base.d.ts +6 -0
  59. package/build/src/database/record/instance-relationships/base.d.ts.map +1 -1
  60. package/build/src/database/record/instance-relationships/base.js +12 -1
  61. package/build/src/database/record/instance-relationships/belongs-to.js +2 -2
  62. package/build/src/database/record/instance-relationships/has-many.d.ts.map +1 -1
  63. package/build/src/database/record/instance-relationships/has-many.js +6 -3
  64. package/build/src/database/record/instance-relationships/has-one.js +2 -2
  65. package/build/src/database/record/relationships/base.d.ts +11 -0
  66. package/build/src/database/record/relationships/base.d.ts.map +1 -1
  67. package/build/src/database/record/relationships/base.js +15 -1
  68. package/build/src/database/use-database.d.ts +33 -5
  69. package/build/src/database/use-database.d.ts.map +1 -1
  70. package/build/src/database/use-database.js +81 -30
  71. package/build/src/sync/server-change-feed.d.ts +14 -2
  72. package/build/src/sync/server-change-feed.d.ts.map +1 -1
  73. package/build/src/sync/server-change-feed.js +50 -10
  74. package/build/src/sync/sync-scope-store.d.ts +13 -2
  75. package/build/src/sync/sync-scope-store.d.ts.map +1 -1
  76. package/build/src/sync/sync-scope-store.js +48 -7
  77. package/build/src/tenants/frontend-tenant-sqlite-lifecycle.d.ts +43 -1
  78. package/build/src/tenants/frontend-tenant-sqlite-lifecycle.d.ts.map +1 -1
  79. package/build/src/tenants/frontend-tenant-sqlite-lifecycle.js +141 -17
  80. package/build/src/tenants/tenant-handle.d.ts +36 -0
  81. package/build/src/tenants/tenant-handle.d.ts.map +1 -1
  82. package/build/src/tenants/tenant-handle.js +73 -3
  83. package/build/src/testing/browser-test-app.js +3 -1
  84. package/build/src/testing/browser-use-database-hook-scenarios.d.ts +16 -0
  85. package/build/src/testing/browser-use-database-hook-scenarios.d.ts.map +1 -0
  86. package/build/src/testing/browser-use-database-hook-scenarios.js +182 -0
  87. package/build/src/utils/model-scope.d.ts +2 -2
  88. package/build/src/utils/model-scope.d.ts.map +1 -1
  89. package/build/src/utils/model-scope.js +5 -3
  90. package/build/src/utils/ransack.d.ts.map +1 -1
  91. package/build/src/utils/ransack.js +5 -3
  92. package/build/sync/server-change-feed.js +58 -8
  93. package/build/sync/sync-scope-store.js +55 -5
  94. package/build/tenants/frontend-tenant-sqlite-lifecycle.js +150 -15
  95. package/build/tenants/tenant-handle.js +90 -6
  96. package/build/testing/browser-test-app.js +2 -0
  97. package/build/testing/browser-use-database-hook-scenarios.js +204 -0
  98. package/build/tsconfig.tsbuildinfo +1 -1
  99. package/build/utils/model-scope.js +5 -2
  100. package/build/utils/ransack.js +5 -2
  101. package/package.json +1 -1
  102. package/scripts/test-browser.js +5 -1
  103. package/src/configuration.js +68 -13
  104. package/src/database/drivers/base.js +30 -0
  105. package/src/database/migrator.js +90 -24
  106. package/src/database/operation.js +55 -4
  107. package/src/database/query/join-object.js +7 -4
  108. package/src/database/query/model-class-query.js +38 -7
  109. package/src/database/query/preloader/belongs-to.js +9 -6
  110. package/src/database/query/preloader/has-many.js +25 -14
  111. package/src/database/query/preloader/has-one.js +7 -4
  112. package/src/database/query/preloader/query-for-model.js +16 -1
  113. package/src/database/query/preloader.js +6 -4
  114. package/src/database/query/query-data.js +3 -3
  115. package/src/database/query/where-model-class-hash.js +8 -4
  116. package/src/database/query/with-count.js +4 -3
  117. package/src/database/record/index.js +148 -5
  118. package/src/database/record/instance-relationships/base.js +13 -0
  119. package/src/database/record/instance-relationships/belongs-to.js +1 -1
  120. package/src/database/record/instance-relationships/has-many.js +5 -2
  121. package/src/database/record/instance-relationships/has-one.js +1 -1
  122. package/src/database/record/relationships/base.js +17 -0
  123. package/src/database/use-database.js +81 -28
  124. package/src/sync/server-change-feed.js +58 -8
  125. package/src/sync/sync-scope-store.js +55 -5
  126. package/src/tenants/frontend-tenant-sqlite-lifecycle.js +150 -15
  127. package/src/tenants/tenant-handle.js +90 -6
  128. package/src/testing/browser-test-app.js +2 -0
  129. package/src/testing/browser-use-database-hook-scenarios.js +204 -0
  130. package/src/utils/model-scope.js +5 -2
  131. package/src/utils/ransack.js +5 -2
package/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
  * Self-sustaining sync feeds: upstream imports triggered by the changes pull itself, with framework-owned coalescing and throttling (see [docs/sync-upstream-imports.md](docs/sync-upstream-imports.md))
21
21
  * AwesomeTasks-shaped offline sync proof using routed resources, domain commands, signed offline grants, and peer-forwarded mutations (see the [developer guide](docs/shared-resource-sync-guide.md) and [proof](docs/awesome-tasks-offline-sync-proof.md))
22
22
  * SQLite web persistence that automatically prefers OPFS, then IndexedDB, and migrates legacy persisted bytes when possible (see [docs/sqlite-web-persistence.md](docs/sqlite-web-persistence.md))
23
- * Bounded frontend tenant SQLite handles with durable flush/close, backend-complete deletion, clean-only LRU eviction, and scoped pins (see [docs/frontend-tenant-sqlite-lifecycle.md](docs/frontend-tenant-sqlite-lifecycle.md))
23
+ * Bounded frontend tenant SQLite handles with independently deduplicated per-database migrations/model readiness, React lifecycle integration, durable flush/close, backend-complete deletion, clean-only LRU eviction, and scoped pins (see [docs/frontend-tenant-sqlite-lifecycle.md](docs/frontend-tenant-sqlite-lifecycle.md))
24
24
  * Expo / Metro compatibility guidance and a real Expo export check (see [docs/expo-metro-compatibility.md](docs/expo-metro-compatibility.md))
25
25
  * 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))
26
26
  * Rails-style nested-attribute writes on frontend-model `save()` (see [docs/nested-attributes.md](docs/nested-attributes.md))
@@ -955,7 +955,8 @@ Translated models also get a `currentTranslation` `hasOne` relationship scoped t
955
955
  Async class APIs initialize record metadata on first use when a model has not
956
956
  already been initialized eagerly. See [docs/model-initialization.md](docs/model-initialization.md)
957
957
  for the eager and lazy initialization behavior, including atomic shared bootstrap
958
- and complete recovery after an eager initialization failure.
958
+ and complete recovery after an eager initialization failure or database-connection
959
+ closure without overlapping stale and current bootstrap side effects.
959
960
 
960
961
  ## Lifecycle callbacks
961
962
 
@@ -302,8 +302,9 @@ export default class VelociousConfiguration {
302
302
  */
303
303
  this._initializeModelsPromise = undefined
304
304
  /**
305
- * In-progress `initialize()` promise, memoized so concurrent callers await
306
- * the same bootstrap. Reset to undefined if initialization fails.
305
+ * Current `initialize()` promise, memoized so concurrent callers await the
306
+ * same bootstrap. Retained across a connection close until stale bootstrap
307
+ * work settles, then cleared by identity before the new generation retries.
307
308
  * @type {Promise<void> | undefined}
308
309
  */
309
310
  this._initializePromise = undefined
@@ -1102,6 +1103,18 @@ export default class VelociousConfiguration {
1102
1103
  }
1103
1104
  }
1104
1105
 
1106
+ /**
1107
+ * Invalidates record metadata owned by one closed/deleted physical tenant
1108
+ * database while preserving every other tenant generation.
1109
+ * @param {string} databaseIdentity - Logical identifier plus pool reuse key.
1110
+ * @returns {void}
1111
+ */
1112
+ clearRecordMetadataForDatabaseIdentity(databaseIdentity) {
1113
+ for (const modelClass of Object.values(this.modelClasses)) {
1114
+ modelClass.clearRecordMetadataValuesForDatabaseIdentity(databaseIdentity)
1115
+ }
1116
+ }
1117
+
1105
1118
  /**
1106
1119
  * Runs get database pool type.
1107
1120
  * @param {string} identifier - Identifier.
@@ -2054,10 +2067,25 @@ export default class VelociousConfiguration {
2054
2067
  * @returns {Promise<void>} - Resolves when complete.
2055
2068
  */
2056
2069
  async initializeModels(args = {type: "server"}) {
2070
+ const modelInitializationGeneration = this._modelInitializationGeneration
2071
+
2057
2072
  if (this._modelsInitialized) return
2058
- if (this._initializeModelsPromise) return await this._initializeModelsPromise
2073
+ if (this._initializeModelsPromise) {
2074
+ const initializeModelsPromise = this._initializeModelsPromise
2075
+
2076
+ await initializeModelsPromise
2077
+
2078
+ if (this._modelInitializationGeneration === modelInitializationGeneration && !this._modelsInitialized) {
2079
+ if (this._initializeModelsPromise === initializeModelsPromise) {
2080
+ this._initializeModelsPromise = undefined
2081
+ }
2082
+
2083
+ return await this.initializeModels(args)
2084
+ }
2085
+
2086
+ return
2087
+ }
2059
2088
 
2060
- const modelInitializationGeneration = this._modelInitializationGeneration
2061
2089
  const initializeModelsPromise = (async () => {
2062
2090
  const shouldSkipDummyModelInitialization = process.env.VELOCIOUS_SKIP_DUMMY_MODEL_INITIALIZATION === "1"
2063
2091
  && process.env.VELOCIOUS_BROWSER_TESTS === "true"
@@ -2110,6 +2138,12 @@ export default class VelociousConfiguration {
2110
2138
  * @returns {Promise<void>} - Resolves when complete.
2111
2139
  */
2112
2140
  async initialize({type} = {type: "undefined"}) {
2141
+ if (this._closeDatabaseConnectionsPromise) {
2142
+ await this._closeDatabaseConnectionsPromise
2143
+ }
2144
+
2145
+ const initializationGeneration = this._modelInitializationGeneration
2146
+
2113
2147
  if (this._isInitialized) return
2114
2148
  // Memoize the in-progress initialization so concurrent callers await the same
2115
2149
  // bootstrap instead of racing. `_isInitialized` was previously set to `true`
@@ -2117,15 +2151,29 @@ export default class VelociousConfiguration {
2117
2151
  // `pooledRunnerConcurrency > 1` starting several jobs on a cold child) could
2118
2152
  // skip initialization and load models / perform a job while the first call
2119
2153
  // was still awaiting model discovery and initializers. Mirrors connectBeacon.
2120
- if (this._initializePromise) return await this._initializePromise
2154
+ if (this._initializePromise) {
2155
+ const initializePromise = this._initializePromise
2121
2156
 
2122
- this._initializePromise = (async () => {
2157
+ await initializePromise
2158
+
2159
+ if (this._modelInitializationGeneration === initializationGeneration && !this._isInitialized) {
2160
+ if (this._initializePromise === initializePromise) {
2161
+ this._initializePromise = undefined
2162
+ }
2163
+
2164
+ return await this.initialize({type})
2165
+ }
2166
+
2167
+ return
2168
+ }
2169
+
2170
+ const initializePromise = (async () => {
2123
2171
  await this.initializeModels({type})
2124
2172
 
2125
2173
  // Model initialization can be invalidated by a concurrent connection close.
2126
2174
  // If models are not ready, stop without marking the configuration initialized
2127
2175
  // so the next caller retries a full bootstrap.
2128
- if (!this._modelsInitialized) return
2176
+ if (this._modelInitializationGeneration !== initializationGeneration || !this._modelsInitialized) return
2129
2177
 
2130
2178
  await this.getEnvironmentHandler().autoDiscoverResources(this)
2131
2179
  this._mergeDiscoveredAbilityResources()
@@ -2147,22 +2195,28 @@ export default class VelociousConfiguration {
2147
2195
  }
2148
2196
  }
2149
2197
 
2150
- this._isInitialized = true
2198
+ if (this._modelInitializationGeneration === initializationGeneration) {
2199
+ this._isInitialized = true
2200
+ }
2151
2201
  })()
2152
2202
 
2203
+ this._initializePromise = initializePromise
2204
+
2153
2205
  try {
2154
- await this._initializePromise
2206
+ await initializePromise
2155
2207
  } catch (error) {
2156
2208
  // Let a later call retry a failed initialization instead of every future
2157
2209
  // caller awaiting the same cached rejection.
2158
- this._initializePromise = undefined
2210
+ if (this._initializePromise === initializePromise) {
2211
+ this._initializePromise = undefined
2212
+ }
2159
2213
  throw error
2160
2214
  }
2161
2215
 
2162
2216
  // If the inner IIFE returned without marking the configuration initialized
2163
2217
  // (e.g. because models were invalidated mid-bootstrap), clear the promise so
2164
2218
  // a later call retries a full bootstrap.
2165
- if (!this._isInitialized) {
2219
+ if (!this._isInitialized && this._initializePromise === initializePromise) {
2166
2220
  this._initializePromise = undefined
2167
2221
  }
2168
2222
  }
@@ -3072,11 +3126,11 @@ export default class VelociousConfiguration {
3072
3126
  * database configuration. No ambient tenant value is read during checkout or
3073
3127
  * execution.
3074
3128
  * @template T
3075
- * @param {{databaseConfiguration: import("./configuration-types.js").DatabaseConfigurationType, databaseIdentifier: string, name?: string, tenant?: object}} options - Captured operation options.
3129
+ * @param {{databaseConfiguration: import("./configuration-types.js").DatabaseConfigurationType, databaseIdentifier: string, name?: string, schemaGeneration?: string, tenant?: object}} options - Captured operation options.
3076
3130
  * @param {(operation: DatabaseOperation) => Promise<T>} callback - Operation callback.
3077
3131
  * @returns {Promise<T>} - Callback result.
3078
3132
  */
3079
- async withDatabaseOperation({databaseConfiguration, databaseIdentifier, name = "Configuration.withDatabaseOperation", tenant, ...restArgs}, callback) {
3133
+ async withDatabaseOperation({databaseConfiguration, databaseIdentifier, name = "Configuration.withDatabaseOperation", schemaGeneration, tenant, ...restArgs}, callback) {
3080
3134
  restArgsError(restArgs)
3081
3135
 
3082
3136
  if (!databaseIdentifier) throw new Error("Configuration.withDatabaseOperation requires a databaseIdentifier")
@@ -3095,6 +3149,7 @@ export default class VelociousConfiguration {
3095
3149
  databaseIdentifier,
3096
3150
  enforceCurrentTenantReuseKey: false,
3097
3151
  owner,
3152
+ schemaGeneration,
3098
3153
  tenant
3099
3154
  })
3100
3155
 
@@ -195,6 +195,10 @@ export default class VelociousDatabaseDriversBase {
195
195
  * Narrows the runtime value to the documented type.
196
196
  * @type {TransactionCallbackFrame[]} */
197
197
  _transactionCallbackFrames
198
+ /** @type {Promise<void>} */
199
+ _transactionCompletionPromise
200
+ /** @type {(() => void) | undefined} */
201
+ _resolveTransactionCompletion
198
202
  /**
199
203
  * Narrows the runtime value to the documented type.
200
204
  * @type {Map<string, Promise<ReturnType<typeof JSON.parse>>>} */
@@ -231,6 +235,8 @@ export default class VelociousDatabaseDriversBase {
231
235
  this.logger = new Logger(this)
232
236
  this._transactionCallbackFrames = []
233
237
  this._transactionsCount = 0
238
+ this._transactionCompletionPromise = Promise.resolve()
239
+ this._resolveTransactionCompletion = undefined
234
240
  this._transactionsActionsMutex = new Mutex()
235
241
  this._schemaCache = new Map()
236
242
  }
@@ -1426,6 +1432,12 @@ export default class VelociousDatabaseDriversBase {
1426
1432
  */
1427
1433
  insideTransaction() { return this._transactionsCount > 0 }
1428
1434
 
1435
+ /**
1436
+ * Returns the completion promise identifying the current outer transaction.
1437
+ * @returns {Promise<void>} Resolves after that transaction commits or rolls back.
1438
+ */
1439
+ transactionCompletion() { return this._transactionCompletionPromise }
1440
+
1429
1441
  /**
1430
1442
  * Runs start transaction.
1431
1443
  * @param {Pick<QueryOptions, "operationOwner">} [options] - Transaction ownership.
@@ -1446,6 +1458,12 @@ export default class VelociousDatabaseDriversBase {
1446
1458
 
1447
1459
  await this._startTransactionAction(options)
1448
1460
  this._transactionsCount++
1461
+
1462
+ if (this._transactionsCount === 1) {
1463
+ this._transactionCompletionPromise = new Promise((resolve) => {
1464
+ this._resolveTransactionCompletion = resolve
1465
+ })
1466
+ }
1449
1467
  })
1450
1468
 
1451
1469
  if (!blockingOperationLease) return
@@ -1472,9 +1490,20 @@ export default class VelociousDatabaseDriversBase {
1472
1490
  await this._transactionsActionsMutex.sync(async () => {
1473
1491
  await this._commitTransactionAction(options)
1474
1492
  this._transactionsCount--
1493
+ this._resolveCompletedTransaction()
1475
1494
  })
1476
1495
  }
1477
1496
 
1497
+ /** Resolves the current outer transaction completion when it has finished. */
1498
+ _resolveCompletedTransaction() {
1499
+ if (this._transactionsCount !== 0) return
1500
+
1501
+ const resolve = this._resolveTransactionCompletion
1502
+
1503
+ this._resolveTransactionCompletion = undefined
1504
+ if (resolve) resolve()
1505
+ }
1506
+
1478
1507
  /**
1479
1508
  * Runs commit transaction action.
1480
1509
  * @param {Pick<QueryOptions, "operationOwner">} [options] - Transaction ownership.
@@ -2091,6 +2120,7 @@ export default class VelociousDatabaseDriversBase {
2091
2120
  await this._rollbackTransactionAction(options)
2092
2121
  } finally {
2093
2122
  this._transactionsCount--
2123
+ this._resolveCompletedTransaction()
2094
2124
 
2095
2125
  // A rolled-back transaction may have reverted DDL (e.g. a CREATE TABLE
2096
2126
  // run lazily inside the transaction), so any cached schema metadata is
@@ -145,18 +145,75 @@ export default class VelociousDatabaseMigrator {
145
145
  * @returns {Promise<void>} - Resolves when complete.
146
146
  */
147
147
  async migrateFilesFromRequireContext(requireContext) {
148
- /**
149
- * Files.
150
- * @type {import("./migrator/types.js").MigrationObjectType[]} */
151
- let files = []
148
+ const files = this.migrationsFromRequireContext(requireContext)
149
+
150
+ await this.configuration.ensureConnections({databaseIdentifiers: this.databaseIdentifiers, name: "Database migrator: migrate require-context files"}, async () => {
151
+ for (const migration of files) {
152
+ await this.runMigrationFile({
153
+ migration,
154
+ requireMigration: async () => requireContext(migration.file).default
155
+ })
156
+ }
157
+
158
+ await this._afterMigrations()
159
+ })
160
+ }
161
+
162
+ /**
163
+ * Migrates exactly one already-captured physical database. This is the
164
+ * frontend/tenant counterpart to the ambient multi-database entrypoints:
165
+ * callers own the captured connection and no configuration fallback is read.
166
+ * @param {object} args - Captured migration arguments.
167
+ * @param {import("../configuration-types.js").DatabaseConfigurationType} args.databaseConfiguration - Captured physical database configuration.
168
+ * @param {string} args.databaseIdentifier - Logical database identifier.
169
+ * @param {import("./drivers/base.js").default} args.db - Captured physical connection.
170
+ * @param {import("./migrator/types.js").RequireMigrationContextType} args.requireContext - Frontend migration require context.
171
+ * @returns {Promise<number>} - Number of newly applied migrations.
172
+ */
173
+ async migrateRequireContextForDatabase({databaseConfiguration, databaseIdentifier, db, requireContext}) {
174
+ if (!databaseConfiguration.migrations) return 0
175
+
176
+ await MigrationsLedger.ensureTable(db)
177
+ const appliedVersions = new Set(await MigrationsLedger.appliedVersions(db))
178
+ const migrations = this.migrationsFromRequireContext(requireContext)
179
+ let appliedCount = 0
180
+
181
+ for (const migration of migrations) {
182
+ const version = `${migration.date}`
183
+
184
+ if (appliedVersions.has(version)) continue
185
+
186
+ const MigrationClass = requireContext(migration.file).default
187
+
188
+ if (!MigrationClass || typeof MigrationClass !== "function") {
189
+ throw new Error(`Migration ${migration.file} must export a default migration class. Type: ${typeof MigrationClass}`)
190
+ }
191
+ if (!(MigrationClass.getDatabaseIdentifiers() || ["default"]).includes(databaseIdentifier)) continue
192
+
193
+ const migrationInstance = new MigrationClass({configuration: this.configuration, databaseIdentifier, db})
194
+
195
+ await this.runMigrationUp({migration, migrationInstance})
196
+ await MigrationsLedger.recordVersion(db, version)
197
+ appliedVersions.add(version)
198
+ appliedCount++
199
+ }
200
+
201
+ return appliedCount
202
+ }
203
+
204
+ /**
205
+ * Parses and orders migrations from a browser/native require context.
206
+ * @param {import("./migrator/types.js").RequireMigrationContextType} requireContext - Migration require context.
207
+ * @returns {import("./migrator/types.js").MigrationObjectType[]} - Ordered migrations.
208
+ */
209
+ migrationsFromRequireContext(requireContext) {
210
+ const migrations = []
152
211
 
153
212
  for (const file of requireContext.keys()) {
154
- // "13,14" because somes "require-context"-npm-module deletes first character!?
155
213
  const match = file.match(/(\d{13,14})-(.+)\.js$/)
156
214
 
157
215
  if (!match) continue
158
216
 
159
- // Fix require-context-npm-module deletes first character
160
217
  let fileName = file
161
218
  let dateNumber = match[1]
162
219
 
@@ -165,30 +222,39 @@ export default class VelociousDatabaseMigrator {
165
222
  fileName = `2${fileName}`
166
223
  }
167
224
 
168
- // Parse regex
169
- const date = parseInt(dateNumber)
170
- const migrationName = match[2]
171
- const migrationClassName = inflection.camelize(migrationName.replaceAll("-", "_"))
172
-
173
- files.push({
225
+ migrations.push({
226
+ date: parseInt(dateNumber),
174
227
  file: fileName,
175
- date,
176
- migrationClassName
228
+ migrationClassName: inflection.camelize(match[2].replaceAll("-", "_"))
177
229
  })
178
230
  }
179
231
 
180
- files = files.sort((migration1, migration2) => migration1.date - migration2.date)
232
+ return migrations.sort((migration1, migration2) => migration1.date - migration2.date)
233
+ }
181
234
 
182
- await this.configuration.ensureConnections({databaseIdentifiers: this.databaseIdentifiers, name: "Database migrator: migrate require-context files"}, async () => {
183
- for (const migration of files) {
184
- await this.runMigrationFile({
185
- migration,
186
- requireMigration: async () => requireContext(migration.file).default
187
- })
188
- }
235
+ /**
236
+ * Runs one migration's upward implementation.
237
+ * @param {object} args - Migration arguments.
238
+ * @param {import("./migrator/types.js").MigrationObjectType} args.migration - Migration descriptor.
239
+ * @param {import("./migration/index.js").default} args.migrationInstance - Migration instance.
240
+ * @returns {Promise<void>} - Resolves after the migration succeeds.
241
+ */
242
+ async runMigrationUp({migration, migrationInstance}) {
243
+ try {
244
+ await migrationInstance.change()
245
+ } catch (changeError) {
246
+ if (!(changeError instanceof NotImplementedError)) throw changeError
247
+
248
+ try {
249
+ await migrationInstance.up()
250
+ } catch (upError) {
251
+ if (upError instanceof NotImplementedError) {
252
+ throw new Error(`'change' or 'up' didn't exist on migration: ${migration.file}`, {cause: upError})
253
+ }
189
254
 
190
- await this._afterMigrations()
191
- })
255
+ throw upError
256
+ }
257
+ }
192
258
  }
193
259
 
194
260
  /**
@@ -17,9 +17,10 @@ export default class VelociousDatabaseOperation {
17
17
  * @param {import("./drivers/base.js").default} args.connection - Pinned physical connection.
18
18
  * @param {string} args.databaseIdentifier - Singular database identifier.
19
19
  * @param {symbol} args.owner - Opaque pool lease owner.
20
+ * @param {string} [args.schemaGeneration] - Tenant schema generation owning record metadata.
20
21
  * @param {object | undefined} args.tenant - Tenant descriptor captured by the owning handle.
21
22
  */
22
- constructor({configuration, databaseConfiguration, configurationReuseKey, connection, databaseIdentifier, enforceCurrentTenantReuseKey = true, owner, tenant}) {
23
+ constructor({configuration, databaseConfiguration, configurationReuseKey, connection, databaseIdentifier, enforceCurrentTenantReuseKey = true, owner, schemaGeneration, tenant}) {
23
24
  this._active = true
24
25
  this._configuration = configuration
25
26
  this._databaseConfiguration = databaseConfiguration || configuration.resolveDatabaseConfiguration(databaseIdentifier, tenant)
@@ -27,7 +28,10 @@ export default class VelociousDatabaseOperation {
27
28
  this._databaseIdentifier = databaseIdentifier
28
29
  this._enforceCurrentTenantReuseKey = enforceCurrentTenantReuseKey
29
30
  this._physicalConnection = connection
31
+ this._schemaGeneration = schemaGeneration
30
32
  this._tenant = tenant
33
+ /** @type {WeakMap<typeof import("./record/index.js").default, typeof import("./record/index.js").default>} */
34
+ this._boundModelClasses = new WeakMap()
31
35
  this._connection = new OperationConnection({
32
36
  connection,
33
37
  operation: this,
@@ -45,12 +49,57 @@ export default class VelociousDatabaseOperation {
45
49
  this.assertActive()
46
50
  this.assertModel(ModelClass)
47
51
 
48
- return ModelClass._newQuery({
52
+ return this.modelClass(ModelClass)._newQuery({
49
53
  driver: this.connection(),
50
54
  operation: this
51
55
  })
52
56
  }
53
57
 
58
+ /**
59
+ * Returns a model-class view whose schema metadata is bound to this physical
60
+ * database generation. Construction still produces the application's original
61
+ * model class, so lifecycle callbacks and model registries retain class identity.
62
+ * @template {typeof import("./record/index.js").default} MC
63
+ * @param {MC} ModelClass - Canonical model class.
64
+ * @returns {MC} - Operation-bound model class.
65
+ */
66
+ modelClass(ModelClass) {
67
+ if (!this._schemaGeneration) return ModelClass
68
+
69
+ const canonicalModelClass = /** @type {MC} */ (ModelClass._recordMetadataModelClass || ModelClass)
70
+
71
+ const existing = this._boundModelClasses.get(canonicalModelClass)
72
+
73
+ if (existing) return /** @type {MC} */ (existing)
74
+
75
+ const databaseIdentity = this.databaseIdentity()
76
+ const metadataKey = `${databaseIdentity.length}:${databaseIdentity}:${this._schemaGeneration}`
77
+ const metadataProperties = canonicalModelClass.recordMetadataPropertyNames()
78
+ const boundModelClass = new Proxy(canonicalModelClass, {
79
+ construct: (target, args, newTarget) => Reflect.construct(target, args, newTarget),
80
+ get: (target, property, receiver) => {
81
+ if (property === "_recordMetadataModelClass") return target
82
+ if (property === "_recordMetadataBinder") return (/** @type {typeof import("./record/index.js").default} */ targetModelClass) => this.modelClass(targetModelClass)
83
+ if (property === "_recordMetadataOperation") return this
84
+ if (typeof property === "string" && metadataProperties.has(property)) return target.recordMetadataValue(metadataKey, property)
85
+
86
+ return Reflect.get(target, property, receiver)
87
+ },
88
+ set: (target, property, value, receiver) => {
89
+ if (typeof property === "string" && metadataProperties.has(property)) {
90
+ target.setRecordMetadataValue(metadataKey, property, value)
91
+ return true
92
+ }
93
+
94
+ return Reflect.set(target, property, value, receiver)
95
+ }
96
+ })
97
+
98
+ this._boundModelClasses.set(canonicalModelClass, boundModelClass)
99
+
100
+ return /** @type {MC} */ (boundModelClass)
101
+ }
102
+
54
103
  /**
55
104
  * Verifies that a model belongs to this operation's configuration and database.
56
105
  * @param {typeof import("./record/index.js").default} ModelClass - Model class to verify.
@@ -162,9 +211,11 @@ export default class VelociousDatabaseOperation {
162
211
  async ensureModelInitialized(ModelClass) {
163
212
  this.assertActive()
164
213
 
165
- if (ModelClass.isInitialized()) this.assertModel(ModelClass)
214
+ const boundModelClass = this.modelClass(ModelClass)
215
+
216
+ if (boundModelClass.isInitialized()) this.assertModel(ModelClass)
166
217
 
167
- await ModelClass.ensureInitialized({
218
+ await boundModelClass.ensureInitialized({
168
219
  configuration: this._configuration,
169
220
  connection: this.connection()
170
221
  })
@@ -54,12 +54,15 @@ export default class VelociousDatabaseQueryJoinObject extends JoinBase {
54
54
  for (const joinKey in join) {
55
55
  const joinValue = join[joinKey]
56
56
  const relationship = modelClass.getRelationshipByName(joinKey)
57
- const targetModelClass = relationship.getTargetModelClass()
57
+ const rawTargetModelClass = relationship.getTargetModelClass()
58
58
 
59
- if (!targetModelClass) {
59
+ if (!rawTargetModelClass) {
60
60
  throw new Error(`Relationship ${modelClass.name}#${joinKey} has no target model class`)
61
61
  }
62
62
 
63
+ const targetModelClass = query.bindModelClass(rawTargetModelClass)
64
+ const foreignKey = relationship.getForeignKeyForModelClasses({modelClass, targetModelClass})
65
+
63
66
  const joinPath = path.concat([joinKey])
64
67
  const parentTableRef = query.getJoinTableReference(path)
65
68
  const targetEntry = query._registerJoinPath(joinPath)
@@ -80,9 +83,9 @@ export default class VelociousDatabaseQueryJoinObject extends JoinBase {
80
83
 
81
84
  if (relationship.getType() == "belongsTo") {
82
85
  sql += `${conn.quoteTable(targetTableRef)}.${conn.quoteColumn(relationship.getPrimaryKey())} = `
83
- sql += `${conn.quoteTable(parentTableRef)}.${conn.quoteColumn(relationship.getForeignKey())}`
86
+ sql += `${conn.quoteTable(parentTableRef)}.${conn.quoteColumn(foreignKey)}`
84
87
  } else if (relationship.getType() == "hasMany" || relationship.getType() == "hasOne") {
85
- sql += `${conn.quoteTable(targetTableRef)}.${conn.quoteColumn(relationship.getForeignKey())} = `
88
+ sql += `${conn.quoteTable(targetTableRef)}.${conn.quoteColumn(foreignKey)} = `
86
89
  sql += `${conn.quoteTable(parentTableRef)}.${conn.quoteColumn(relationship.getPrimaryKey())}`
87
90
  } else {
88
91
  throw new Error(`Unknown relationship type: ${relationship.getType()}`)
@@ -87,6 +87,22 @@ function normalizeScopePath(path) {
87
87
  return [...path]
88
88
  }
89
89
 
90
+ /**
91
+ * Narrows a shared model-scope descriptor to the backend model class required by ModelClassQuery.
92
+ * @param {import("../../utils/model-scope.js").ModelScopeDescriptor} scopeDescriptor - Shared scope descriptor.
93
+ * @returns {typeof import("../record/index.js").default} - Backend scope owner.
94
+ */
95
+ function backendScopeModelClass(scopeDescriptor) {
96
+ const modelClass = scopeDescriptor.modelClass
97
+
98
+ if (!("canonicalRecordMetadataModelClass" in modelClass)) {
99
+ throw new Error("A frontend-model scope cannot be applied to a database record query")
100
+ }
101
+
102
+ // The runtime member check above narrows the shared frontend/backend descriptor boundary.
103
+ return /** @type {typeof import("../record/index.js").default} */ (modelClass)
104
+ }
105
+
90
106
  /**
91
107
  * Deep-copies a preload select map (keyed by model name with attribute arrays)
92
108
  * so a cloned query's selections can be mutated without affecting the original.
@@ -490,6 +506,15 @@ export default class VelociousDatabaseQueryModelClassQuery extends DatabaseQuery
490
506
  return this.modelClass
491
507
  }
492
508
 
509
+ /**
510
+ * Binds a relationship target to this query's physical database generation.
511
+ * @param {typeof import("../record/index.js").default} modelClass - Canonical relationship target.
512
+ * @returns {typeof import("../record/index.js").default} - Query-bound relationship target.
513
+ */
514
+ bindModelClass(modelClass) {
515
+ return this.getModelClass().bindRecordMetadataModelClass(modelClass)
516
+ }
517
+
493
518
  /**
494
519
  * Runs get join base path.
495
520
  * @returns {string[]} - The join base path.
@@ -563,7 +588,7 @@ export default class VelociousDatabaseQueryModelClassQuery extends DatabaseQuery
563
588
  throw new Error(`No target model class for ${modelClass.name}#${relationshipName}`)
564
589
  }
565
590
 
566
- modelClass = targetModelClass
591
+ modelClass = this.bindModelClass(targetModelClass)
567
592
  }
568
593
 
569
594
  return modelClass
@@ -642,7 +667,9 @@ export default class VelociousDatabaseQueryModelClassQuery extends DatabaseQuery
642
667
  throw new Error("scope() expects a descriptor returned by defineScope(...).scope(...)")
643
668
  }
644
669
 
645
- if (scopeDescriptor.modelClass !== this.getModelClass()) {
670
+ const scopeModelClass = backendScopeModelClass(scopeDescriptor)
671
+
672
+ if (scopeModelClass.canonicalRecordMetadataModelClass() !== this.getModelClass().canonicalRecordMetadataModelClass()) {
646
673
  throw new Error(`Cannot apply ${scopeDescriptor.modelClass.name} scope to ${this.getModelClass().name} query`)
647
674
  }
648
675
 
@@ -671,7 +698,9 @@ export default class VelociousDatabaseQueryModelClassQuery extends DatabaseQuery
671
698
  const fullJoinPath = this.getJoinBasePath().concat(joinPath)
672
699
  const targetModelClass = this._resolveModelClassForJoinPath(fullJoinPath)
673
700
 
674
- if (scopeDescriptor.modelClass !== targetModelClass) {
701
+ const scopeModelClass = backendScopeModelClass(scopeDescriptor)
702
+
703
+ if (scopeModelClass.canonicalRecordMetadataModelClass() !== targetModelClass.canonicalRecordMetadataModelClass()) {
675
704
  throw new Error(`Cannot apply ${scopeDescriptor.modelClass.name} scope to join path ${fullJoinPath.join(".")} (${targetModelClass.name})`)
676
705
  }
677
706
 
@@ -1418,11 +1447,12 @@ function splitWhereHash({hash, modelClass}) {
1418
1447
 
1419
1448
  if (isNested) {
1420
1449
  if (relationship) {
1421
- const targetModelClass = relationship.getTargetModelClass()
1422
- if (!targetModelClass) {
1450
+ const rawTargetModelClass = relationship.getTargetModelClass()
1451
+ if (!rawTargetModelClass) {
1423
1452
  fallbackHash[key] = value
1424
1453
  continue
1425
1454
  }
1455
+ const targetModelClass = modelClass.bindRecordMetadataModelClass(rawTargetModelClass)
1426
1456
  const nestedResult = splitWhereHash({hash: value, modelClass: targetModelClass})
1427
1457
  const nestedResolvedKeys = Object.keys(nestedResult.resolvedHash)
1428
1458
  const nestedFallbackKeys = Object.keys(nestedResult.fallbackHash)
@@ -1476,8 +1506,9 @@ function buildJoinObjectFromWhereHash({hash, modelClass}) {
1476
1506
  if (!relationship) continue
1477
1507
 
1478
1508
  if (isPlainObject(value)) {
1479
- const targetModelClass = relationship.getTargetModelClass()
1480
- if (!targetModelClass) continue
1509
+ const rawTargetModelClass = relationship.getTargetModelClass()
1510
+ if (!rawTargetModelClass) continue
1511
+ const targetModelClass = modelClass.bindRecordMetadataModelClass(rawTargetModelClass)
1481
1512
  const nestedJoinObject = buildJoinObjectFromWhereHash({hash: value, modelClass: targetModelClass})
1482
1513
 
1483
1514
  joinObject[key] = Object.keys(nestedJoinObject).length > 0 ? nestedJoinObject : true
@@ -2,7 +2,7 @@
2
2
 
3
3
  import ensureModelClassInitialized from "./ensure-model-class-initialized.js"
4
4
  import PreloaderSelection from "./selection.js"
5
- import preloadQueryForModel from "./query-for-model.js"
5
+ import preloadQueryForModel, { bindPreloadModelClass } from "./query-for-model.js"
6
6
  import restArgsError from "../../../utils/rest-args-error.js"
7
7
 
8
8
  export default class VelociousDatabaseQueryPreloaderBelongsTo {
@@ -22,7 +22,8 @@ export default class VelociousDatabaseQueryPreloaderBelongsTo {
22
22
  }
23
23
 
24
24
  async run() {
25
- const foreignKey = this.relationship.getForeignKey()
25
+ const sourceModelClass = this.models[0].getModelClass()
26
+ const foreignKey = this.relationship.getForeignKeyForModelClasses({modelClass: sourceModelClass, targetModelClass: sourceModelClass})
26
27
  const primaryKey = this.relationship.getPrimaryKey()
27
28
  const relationshipName = this.relationship.getRelationshipName()
28
29
 
@@ -30,9 +31,11 @@ export default class VelociousDatabaseQueryPreloaderBelongsTo {
30
31
  return await this._runPolymorphic({foreignKey, primaryKey, relationshipName})
31
32
  }
32
33
 
33
- const targetModelClass = this.relationship.getTargetModelClass()
34
+ const rawTargetModelClass = this.relationship.getTargetModelClass()
34
35
 
35
- if (!targetModelClass) throw new Error("No target model class could be gotten from relationship")
36
+ if (!rawTargetModelClass) throw new Error("No target model class could be gotten from relationship")
37
+
38
+ const targetModelClass = bindPreloadModelClass(this.models, rawTargetModelClass)
36
39
 
37
40
  /**
38
41
  * Satisfied targets.
@@ -155,7 +158,7 @@ export default class VelociousDatabaseQueryPreloaderBelongsTo {
155
158
  for (const model of this.models) {
156
159
  const targetType = /** @type {string | undefined} */ (model.readColumn(typeColumn))
157
160
  const instanceRelationship = model.getRelationshipByName(relationshipName)
158
- const targetModelClass = targetType ? configuration.getModelClass(targetType) : undefined
161
+ const targetModelClass = targetType ? bindPreloadModelClass(this.models, configuration.getModelClass(targetType)) : undefined
159
162
 
160
163
  if (targetModelClass && this.selection.isSatisfied({instanceRelationship, targetModelClass, mappingColumns: [primaryKey]})) {
161
164
  const loaded = /** @type {import("../../record/index.js").default | undefined} */ (instanceRelationship.getLoadedOrUndefined())
@@ -203,7 +206,7 @@ export default class VelociousDatabaseQueryPreloaderBelongsTo {
203
206
  const targetModels = []
204
207
 
205
208
  for (const targetType in foreignKeyValuesByType) {
206
- const targetModelClass = configuration.getModelClass(targetType)
209
+ const targetModelClass = bindPreloadModelClass(this.models, configuration.getModelClass(targetType))
207
210
 
208
211
  await ensureModelClassInitialized(targetModelClass, configuration, this.models[0])
209
212