velocious 1.0.593 → 1.0.595

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 (136) hide show
  1. package/README.md +3 -2
  2. package/build/configuration.js +109 -14
  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 +31 -4
  24. package/build/src/configuration.d.ts.map +1 -1
  25. package/build/src/configuration.js +88 -17
  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/testing/test-runner.d.ts.map +1 -1
  88. package/build/src/testing/test-runner.js +9 -8
  89. package/build/src/utils/model-scope.d.ts +2 -2
  90. package/build/src/utils/model-scope.d.ts.map +1 -1
  91. package/build/src/utils/model-scope.js +5 -3
  92. package/build/src/utils/ransack.d.ts.map +1 -1
  93. package/build/src/utils/ransack.js +5 -3
  94. package/build/sync/server-change-feed.js +58 -8
  95. package/build/sync/sync-scope-store.js +55 -5
  96. package/build/tenants/frontend-tenant-sqlite-lifecycle.js +150 -15
  97. package/build/tenants/tenant-handle.js +90 -6
  98. package/build/testing/browser-test-app.js +2 -0
  99. package/build/testing/browser-use-database-hook-scenarios.js +204 -0
  100. package/build/testing/test-runner.js +8 -7
  101. package/build/tsconfig.tsbuildinfo +1 -1
  102. package/build/utils/model-scope.js +5 -2
  103. package/build/utils/ransack.js +5 -2
  104. package/package.json +2 -1
  105. package/scripts/browser-test-session.js +60 -0
  106. package/scripts/test-browser.js +7 -7
  107. package/src/configuration.js +109 -14
  108. package/src/database/drivers/base.js +30 -0
  109. package/src/database/migrator.js +90 -24
  110. package/src/database/operation.js +55 -4
  111. package/src/database/query/join-object.js +7 -4
  112. package/src/database/query/model-class-query.js +38 -7
  113. package/src/database/query/preloader/belongs-to.js +9 -6
  114. package/src/database/query/preloader/has-many.js +25 -14
  115. package/src/database/query/preloader/has-one.js +7 -4
  116. package/src/database/query/preloader/query-for-model.js +16 -1
  117. package/src/database/query/preloader.js +6 -4
  118. package/src/database/query/query-data.js +3 -3
  119. package/src/database/query/where-model-class-hash.js +8 -4
  120. package/src/database/query/with-count.js +4 -3
  121. package/src/database/record/index.js +148 -5
  122. package/src/database/record/instance-relationships/base.js +13 -0
  123. package/src/database/record/instance-relationships/belongs-to.js +1 -1
  124. package/src/database/record/instance-relationships/has-many.js +5 -2
  125. package/src/database/record/instance-relationships/has-one.js +1 -1
  126. package/src/database/record/relationships/base.js +17 -0
  127. package/src/database/use-database.js +81 -28
  128. package/src/sync/server-change-feed.js +58 -8
  129. package/src/sync/sync-scope-store.js +55 -5
  130. package/src/tenants/frontend-tenant-sqlite-lifecycle.js +150 -15
  131. package/src/tenants/tenant-handle.js +90 -6
  132. package/src/testing/browser-test-app.js +2 -0
  133. package/src/testing/browser-use-database-hook-scenarios.js +204 -0
  134. package/src/testing/test-runner.js +8 -7
  135. package/src/utils/model-scope.js +5 -2
  136. 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
@@ -363,6 +364,15 @@ export default class VelociousConfiguration {
363
364
  */
364
365
  this._websocketChannelSubscriptions = new Map()
365
366
 
367
+ /**
368
+ * In-flight local (per-process) websocket channel broadcast deliveries,
369
+ * launched fire-and-forget from `_broadcastToChannelLocal` so one slow
370
+ * subscriber never blocks another. Tracked here so
371
+ * `awaitPendingBroadcasts` can snapshot and drain them before settling.
372
+ * Settled deliveries are removed by the tracking-level cleanup.
373
+ * @type {Set<Promise<void>>} */
374
+ this._localBroadcastDeliveries = new Set()
375
+
366
376
  /**
367
377
  * Stores the websocket sessions value.
368
378
  * @type {Set<import("./http-server/client/websocket-session.js").default>} - Live websocket sessions, including paused sessions within the grace window.
@@ -1102,6 +1112,18 @@ export default class VelociousConfiguration {
1102
1112
  }
1103
1113
  }
1104
1114
 
1115
+ /**
1116
+ * Invalidates record metadata owned by one closed/deleted physical tenant
1117
+ * database while preserving every other tenant generation.
1118
+ * @param {string} databaseIdentity - Logical identifier plus pool reuse key.
1119
+ * @returns {void}
1120
+ */
1121
+ clearRecordMetadataForDatabaseIdentity(databaseIdentity) {
1122
+ for (const modelClass of Object.values(this.modelClasses)) {
1123
+ modelClass.clearRecordMetadataValuesForDatabaseIdentity(databaseIdentity)
1124
+ }
1125
+ }
1126
+
1105
1127
  /**
1106
1128
  * Runs get database pool type.
1107
1129
  * @param {string} identifier - Identifier.
@@ -2054,10 +2076,25 @@ export default class VelociousConfiguration {
2054
2076
  * @returns {Promise<void>} - Resolves when complete.
2055
2077
  */
2056
2078
  async initializeModels(args = {type: "server"}) {
2079
+ const modelInitializationGeneration = this._modelInitializationGeneration
2080
+
2057
2081
  if (this._modelsInitialized) return
2058
- if (this._initializeModelsPromise) return await this._initializeModelsPromise
2082
+ if (this._initializeModelsPromise) {
2083
+ const initializeModelsPromise = this._initializeModelsPromise
2084
+
2085
+ await initializeModelsPromise
2086
+
2087
+ if (this._modelInitializationGeneration === modelInitializationGeneration && !this._modelsInitialized) {
2088
+ if (this._initializeModelsPromise === initializeModelsPromise) {
2089
+ this._initializeModelsPromise = undefined
2090
+ }
2091
+
2092
+ return await this.initializeModels(args)
2093
+ }
2094
+
2095
+ return
2096
+ }
2059
2097
 
2060
- const modelInitializationGeneration = this._modelInitializationGeneration
2061
2098
  const initializeModelsPromise = (async () => {
2062
2099
  const shouldSkipDummyModelInitialization = process.env.VELOCIOUS_SKIP_DUMMY_MODEL_INITIALIZATION === "1"
2063
2100
  && process.env.VELOCIOUS_BROWSER_TESTS === "true"
@@ -2110,6 +2147,12 @@ export default class VelociousConfiguration {
2110
2147
  * @returns {Promise<void>} - Resolves when complete.
2111
2148
  */
2112
2149
  async initialize({type} = {type: "undefined"}) {
2150
+ if (this._closeDatabaseConnectionsPromise) {
2151
+ await this._closeDatabaseConnectionsPromise
2152
+ }
2153
+
2154
+ const initializationGeneration = this._modelInitializationGeneration
2155
+
2113
2156
  if (this._isInitialized) return
2114
2157
  // Memoize the in-progress initialization so concurrent callers await the same
2115
2158
  // bootstrap instead of racing. `_isInitialized` was previously set to `true`
@@ -2117,15 +2160,29 @@ export default class VelociousConfiguration {
2117
2160
  // `pooledRunnerConcurrency > 1` starting several jobs on a cold child) could
2118
2161
  // skip initialization and load models / perform a job while the first call
2119
2162
  // was still awaiting model discovery and initializers. Mirrors connectBeacon.
2120
- if (this._initializePromise) return await this._initializePromise
2163
+ if (this._initializePromise) {
2164
+ const initializePromise = this._initializePromise
2165
+
2166
+ await initializePromise
2167
+
2168
+ if (this._modelInitializationGeneration === initializationGeneration && !this._isInitialized) {
2169
+ if (this._initializePromise === initializePromise) {
2170
+ this._initializePromise = undefined
2171
+ }
2172
+
2173
+ return await this.initialize({type})
2174
+ }
2175
+
2176
+ return
2177
+ }
2121
2178
 
2122
- this._initializePromise = (async () => {
2179
+ const initializePromise = (async () => {
2123
2180
  await this.initializeModels({type})
2124
2181
 
2125
2182
  // Model initialization can be invalidated by a concurrent connection close.
2126
2183
  // If models are not ready, stop without marking the configuration initialized
2127
2184
  // so the next caller retries a full bootstrap.
2128
- if (!this._modelsInitialized) return
2185
+ if (this._modelInitializationGeneration !== initializationGeneration || !this._modelsInitialized) return
2129
2186
 
2130
2187
  await this.getEnvironmentHandler().autoDiscoverResources(this)
2131
2188
  this._mergeDiscoveredAbilityResources()
@@ -2147,22 +2204,28 @@ export default class VelociousConfiguration {
2147
2204
  }
2148
2205
  }
2149
2206
 
2150
- this._isInitialized = true
2207
+ if (this._modelInitializationGeneration === initializationGeneration) {
2208
+ this._isInitialized = true
2209
+ }
2151
2210
  })()
2152
2211
 
2212
+ this._initializePromise = initializePromise
2213
+
2153
2214
  try {
2154
- await this._initializePromise
2215
+ await initializePromise
2155
2216
  } catch (error) {
2156
2217
  // Let a later call retry a failed initialization instead of every future
2157
2218
  // caller awaiting the same cached rejection.
2158
- this._initializePromise = undefined
2219
+ if (this._initializePromise === initializePromise) {
2220
+ this._initializePromise = undefined
2221
+ }
2159
2222
  throw error
2160
2223
  }
2161
2224
 
2162
2225
  // If the inner IIFE returned without marking the configuration initialized
2163
2226
  // (e.g. because models were invalidated mid-bootstrap), clear the promise so
2164
2227
  // a later call retries a full bootstrap.
2165
- if (!this._isInitialized) {
2228
+ if (!this._isInitialized && this._initializePromise === initializePromise) {
2166
2229
  this._initializePromise = undefined
2167
2230
  }
2168
2231
  }
@@ -2756,8 +2819,13 @@ export default class VelociousConfiguration {
2756
2819
  const websocketEvents = this._websocketEvents
2757
2820
 
2758
2821
  if (websocketEvents && typeof websocketEvents.awaitPendingBroadcasts === "function") {
2822
+ // Drain the host/worker publish queues (including event-log persistence)
2823
+ // before draining local deliveries, because host dispatch launches the
2824
+ // local deliveries synchronously and they must be part of the snapshot.
2759
2825
  await websocketEvents.awaitPendingBroadcasts()
2760
2826
  }
2827
+
2828
+ await this._awaitLocalBroadcastDeliveries()
2761
2829
  }
2762
2830
 
2763
2831
  /**
@@ -2791,7 +2859,7 @@ export default class VelociousConfiguration {
2791
2859
 
2792
2860
  if (!matches) continue
2793
2861
 
2794
- void this.withoutCurrentConnectionContexts(() => {
2862
+ const delivery = this.withoutCurrentConnectionContexts(() => {
2795
2863
  return Promise
2796
2864
  .resolve()
2797
2865
  .then(() => this._deliverWebsocketChannelBroadcast(subscription, body, {eventId: meta?.eventId}))
@@ -2799,9 +2867,35 @@ export default class VelociousConfiguration {
2799
2867
  console.error(`broadcastToChannel: ${name} subscription ${subscription.subscriptionId} deliverBroadcast threw`, error)
2800
2868
  })
2801
2869
  })
2870
+
2871
+ // Keep the fire-and-forget delivery (never awaited at broadcast time) but
2872
+ // track it so `awaitPendingBroadcasts` can drain it before settling. Remove
2873
+ // on settle; the failure handler also satisfies the promise so a rejected
2874
+ // delivery never becomes an unhandled rejection.
2875
+ this._localBroadcastDeliveries.add(delivery)
2876
+
2877
+ delivery.then(
2878
+ () => { this._localBroadcastDeliveries.delete(delivery) },
2879
+ () => { this._localBroadcastDeliveries.delete(delivery) }
2880
+ )
2802
2881
  }
2803
2882
  }
2804
2883
 
2884
+ /**
2885
+ * Awaits a snapshot of the in-flight local (per-process) websocket channel
2886
+ * broadcast deliveries. Called from `awaitPendingBroadcasts` after the host
2887
+ * publish queues drain, so every delivery those queues launched is captured.
2888
+ * New deliveries enqueued after the snapshot are not awaited. Individual
2889
+ * delivery errors are isolated per subscriber — the delivery chain already
2890
+ * logs them and resolves — so a snapshotted rejection never fails this barrier.
2891
+ * @returns {Promise<void>}
2892
+ */
2893
+ async _awaitLocalBroadcastDeliveries() {
2894
+ const snapshot = [...this._localBroadcastDeliveries]
2895
+
2896
+ await Promise.allSettled(snapshot)
2897
+ }
2898
+
2805
2899
  /**
2806
2900
  * Runs deliver websocket channel broadcast.
2807
2901
  * @param {import("./http-server/websocket-channel.js").default} subscription - Channel subscription.
@@ -3072,11 +3166,11 @@ export default class VelociousConfiguration {
3072
3166
  * database configuration. No ambient tenant value is read during checkout or
3073
3167
  * execution.
3074
3168
  * @template T
3075
- * @param {{databaseConfiguration: import("./configuration-types.js").DatabaseConfigurationType, databaseIdentifier: string, name?: string, tenant?: object}} options - Captured operation options.
3169
+ * @param {{databaseConfiguration: import("./configuration-types.js").DatabaseConfigurationType, databaseIdentifier: string, name?: string, schemaGeneration?: string, tenant?: object}} options - Captured operation options.
3076
3170
  * @param {(operation: DatabaseOperation) => Promise<T>} callback - Operation callback.
3077
3171
  * @returns {Promise<T>} - Callback result.
3078
3172
  */
3079
- async withDatabaseOperation({databaseConfiguration, databaseIdentifier, name = "Configuration.withDatabaseOperation", tenant, ...restArgs}, callback) {
3173
+ async withDatabaseOperation({databaseConfiguration, databaseIdentifier, name = "Configuration.withDatabaseOperation", schemaGeneration, tenant, ...restArgs}, callback) {
3080
3174
  restArgsError(restArgs)
3081
3175
 
3082
3176
  if (!databaseIdentifier) throw new Error("Configuration.withDatabaseOperation requires a databaseIdentifier")
@@ -3095,6 +3189,7 @@ export default class VelociousConfiguration {
3095
3189
  databaseIdentifier,
3096
3190
  enforceCurrentTenantReuseKey: false,
3097
3191
  owner,
3192
+ schemaGeneration,
3098
3193
  tenant
3099
3194
  })
3100
3195
 
@@ -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()}`)