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
@@ -8,6 +8,9 @@
8
8
  * @property {boolean} dirty - Whether delayed writes remain.
9
9
  * @property {number} lastUsed - Monotonic recency sequence.
10
10
  * @property {number} pinCount - Active scoped pins.
11
+ * @property {Promise<void> | undefined} readinessPromise - In-progress schema readiness.
12
+ * @property {boolean} ready - Whether migrations and model metadata are ready.
13
+ * @property {string | undefined} schemaGeneration - Ready or in-progress schema generation.
11
14
  * @property {LifecycleState} state - Current lifecycle state.
12
15
  */
13
16
 
@@ -55,7 +58,17 @@ export default class FrontendTenantSqliteLifecycle {
55
58
  const key = this.key(databaseIdentifier, databaseConfiguration)
56
59
  let entry = this.entries.get(key)
57
60
  if (!entry) {
58
- entry = {databaseConfiguration, databaseIdentifier, dirty: false, lastUsed: 0, pinCount: 0, state: "closed"}
61
+ entry = {
62
+ databaseConfiguration,
63
+ databaseIdentifier,
64
+ dirty: false,
65
+ lastUsed: 0,
66
+ pinCount: 0,
67
+ readinessPromise: undefined,
68
+ ready: false,
69
+ schemaGeneration: undefined,
70
+ state: "closed"
71
+ }
59
72
  this.entries.set(key, entry)
60
73
  }
61
74
  return entry
@@ -67,6 +80,8 @@ export default class FrontendTenantSqliteLifecycle {
67
80
  dirty: entry.dirty,
68
81
  lastUsed: entry.lastUsed,
69
82
  pinCount: entry.pinCount,
83
+ ready: entry.ready,
84
+ schemaGeneration: entry.schemaGeneration,
70
85
  state: entry.state
71
86
  })
72
87
  }
@@ -94,6 +109,78 @@ export default class FrontendTenantSqliteLifecycle {
94
109
  })
95
110
  }
96
111
 
112
+ /**
113
+ * Opens and prepares one captured physical tenant database generation. The
114
+ * readiness callback runs outside the lifecycle bookkeeping lock, so distinct
115
+ * tenant databases can migrate concurrently while matching callers share one
116
+ * promise.
117
+ * @param {string} databaseIdentifier - Logical database identifier.
118
+ * @param {import("../configuration-types.js").DatabaseConfigurationType} databaseConfiguration - Captured physical configuration.
119
+ * @param {string} schemaGeneration - Application schema generation.
120
+ * @param {() => Promise<void>} callback - Migration and metadata initialization.
121
+ * @returns {Promise<Readonly<ReturnType<FrontendTenantSqliteLifecycle["snapshot"]>>>} - Ready lifecycle snapshot.
122
+ */
123
+ async initialize(databaseIdentifier, databaseConfiguration, schemaGeneration, callback) {
124
+ this.assertSqlite(databaseConfiguration)
125
+ if (!databaseConfiguration.tenantOnly) throw new Error("Frontend tenant database initialization requires a tenant-only SQLite database")
126
+ if (typeof schemaGeneration !== "string" || schemaGeneration.length === 0) {
127
+ throw new TypeError("Frontend tenant database initialization requires a non-empty schemaGeneration")
128
+ }
129
+
130
+ const readiness = await this.serialize(async () => {
131
+ const entry = this.entry(databaseIdentifier, databaseConfiguration)
132
+
133
+ if (entry.readinessPromise) {
134
+ if (entry.schemaGeneration !== schemaGeneration) {
135
+ throw new Error(`Frontend tenant database is already initializing schema generation ${JSON.stringify(entry.schemaGeneration)}; cannot initialize mismatched generation ${JSON.stringify(schemaGeneration)}`)
136
+ }
137
+
138
+ return {entry, promise: entry.readinessPromise}
139
+ }
140
+ if (entry.ready && entry.schemaGeneration === schemaGeneration) {
141
+ return {entry, promise: Promise.resolve()}
142
+ }
143
+ if (entry.schemaGeneration && entry.schemaGeneration !== schemaGeneration && (entry.pinCount > 0 || this.configuration.getDatabasePool(databaseIdentifier).capturedConnectionInUse(databaseConfiguration))) {
144
+ throw new Error(`Cannot replace frontend tenant schema generation ${JSON.stringify(entry.schemaGeneration)} while its physical database is in use`)
145
+ }
146
+ if (entry.state !== "open") await this.openUnlocked(entry)
147
+ if (entry.schemaGeneration && entry.schemaGeneration !== schemaGeneration) {
148
+ this.configuration.clearRecordMetadataForDatabaseIdentity(this.key(databaseIdentifier, databaseConfiguration))
149
+ }
150
+
151
+ entry.ready = false
152
+ entry.schemaGeneration = schemaGeneration
153
+ entry.pinCount++
154
+ const promise = Promise.resolve().then(callback)
155
+
156
+ entry.readinessPromise = promise
157
+
158
+ return {entry, promise}
159
+ })
160
+
161
+ try {
162
+ await readiness.promise
163
+ await this.serialize(async () => {
164
+ if (readiness.entry.readinessPromise === readiness.promise) {
165
+ readiness.entry.readinessPromise = undefined
166
+ readiness.entry.ready = true
167
+ readiness.entry.pinCount--
168
+ }
169
+ })
170
+ } catch (error) {
171
+ await this.serialize(async () => {
172
+ if (readiness.entry.readinessPromise === readiness.promise) {
173
+ readiness.entry.readinessPromise = undefined
174
+ readiness.entry.ready = false
175
+ readiness.entry.pinCount--
176
+ }
177
+ })
178
+ throw error
179
+ }
180
+
181
+ return this.snapshot(readiness.entry)
182
+ }
183
+
97
184
  async flush(/** @type {string} */ databaseIdentifier, /** @type {import("../configuration-types.js").DatabaseConfigurationType} */ databaseConfiguration) {
98
185
  this.assertSqlite(databaseConfiguration)
99
186
  return await this.serialize(async () => {
@@ -117,7 +204,10 @@ export default class FrontendTenantSqliteLifecycle {
117
204
  try {
118
205
  if (flush) await this.configuration.getDatabasePool(databaseIdentifier).flushCapturedConnection(databaseConfiguration)
119
206
  await this.configuration.getDatabasePool(databaseIdentifier).closeCapturedConnection(databaseConfiguration)
207
+ this.configuration.clearRecordMetadataForDatabaseIdentity(this.key(databaseIdentifier, databaseConfiguration))
120
208
  entry.dirty = false
209
+ entry.ready = false
210
+ entry.schemaGeneration = undefined
121
211
  entry.state = "closed"
122
212
  this.entries.delete(this.key(databaseIdentifier, databaseConfiguration))
123
213
  return this.snapshot(entry)
@@ -136,11 +226,16 @@ export default class FrontendTenantSqliteLifecycle {
136
226
  entry.state = "deleting"
137
227
  try {
138
228
  await this.configuration.getDatabasePool(databaseIdentifier).deleteCapturedDatabase(databaseConfiguration)
229
+ this.configuration.clearRecordMetadataForDatabaseIdentity(this.key(databaseIdentifier, databaseConfiguration))
139
230
  entry.dirty = false
231
+ entry.ready = false
232
+ entry.schemaGeneration = undefined
140
233
  entry.state = "closed"
141
234
  this.entries.delete(this.key(databaseIdentifier, databaseConfiguration))
142
235
  return this.snapshot(entry)
143
236
  } catch (error) {
237
+ this.configuration.clearRecordMetadataForDatabaseIdentity(this.key(databaseIdentifier, databaseConfiguration))
238
+ entry.ready = false
144
239
  entry.state = "closed"
145
240
  throw error
146
241
  }
@@ -150,7 +245,7 @@ export default class FrontendTenantSqliteLifecycle {
150
245
  inspect(/** @type {string} */ databaseIdentifier, /** @type {import("../configuration-types.js").DatabaseConfigurationType} */ databaseConfiguration) {
151
246
  this.assertSqlite(databaseConfiguration)
152
247
  const entry = this.entries.get(this.key(databaseIdentifier, databaseConfiguration))
153
- return entry ? this.snapshot(entry) : Object.freeze({databaseIdentifier, dirty: false, lastUsed: 0, pinCount: 0, state: "closed"})
248
+ return entry ? this.snapshot(entry) : Object.freeze({databaseIdentifier, dirty: false, lastUsed: 0, pinCount: 0, ready: false, schemaGeneration: undefined, state: "closed"})
154
249
  }
155
250
 
156
251
  inspectAll() {
@@ -158,7 +253,13 @@ export default class FrontendTenantSqliteLifecycle {
158
253
  return Object.freeze({handles: Object.freeze(handles), maxOpenHandles: this.maxOpenHandles, openCount: handles.filter(({state}) => state === "open").length})
159
254
  }
160
255
 
161
- reset() { this.entries.clear() }
256
+ reset() {
257
+ for (const entry of this.entries.values()) {
258
+ this.configuration.clearRecordMetadataForDatabaseIdentity(this.key(entry.databaseIdentifier, entry.databaseConfiguration))
259
+ }
260
+
261
+ this.entries.clear()
262
+ }
162
263
 
163
264
  async withPin(/** @type {string} */ databaseIdentifier, /** @type {import("../configuration-types.js").DatabaseConfigurationType} */ databaseConfiguration, /** @type {() => Promise<ReturnType<typeof JSON.parse>>} */ callback) {
164
265
  this.assertSqlite(databaseConfiguration)
@@ -176,21 +277,52 @@ export default class FrontendTenantSqliteLifecycle {
176
277
  }
177
278
  }
178
279
 
179
- async databaseOperation(/** @type {string} */ databaseIdentifier, /** @type {import("../configuration-types.js").DatabaseConfigurationType} */ databaseConfiguration, /** @type {() => Promise<ReturnType<typeof JSON.parse>>} */ callback) {
180
- if (!this.entries.has(this.key(databaseIdentifier, databaseConfiguration))) return await callback()
280
+ /**
281
+ * Atomically validates readiness, captures the schema generation, and pins
282
+ * one lifecycle entry before starting database work.
283
+ * @param {string} databaseIdentifier - Logical database identifier.
284
+ * @param {import("../configuration-types.js").DatabaseConfigurationType} databaseConfiguration - Captured physical configuration.
285
+ * @param {{requireReady: boolean, schemaGeneration?: string}} options - Operation readiness requirements.
286
+ * @param {(schemaGeneration: string | undefined) => Promise<ReturnType<typeof JSON.parse>>} callback - Pinned operation callback.
287
+ * @returns {Promise<ReturnType<typeof JSON.parse>>} - Operation result.
288
+ */
289
+ async databaseOperation(databaseIdentifier, databaseConfiguration, {requireReady, schemaGeneration}, callback) {
290
+ if (databaseConfiguration.type !== "sqlite") return await callback(schemaGeneration)
181
291
 
182
- return await this.withPin(databaseIdentifier, databaseConfiguration, async () => {
183
- try {
184
- return await callback()
185
- } finally {
186
- const dirty = this.configuration.getDatabasePool(databaseIdentifier).capturedConnectionHasPendingWrites(databaseConfiguration)
187
- await this.serialize(async () => {
188
- const entry = this.entry(databaseIdentifier, databaseConfiguration)
189
- entry.dirty ||= dirty
190
- entry.lastUsed = ++this.sequence
191
- })
292
+ const entry = await this.serialize(async () => {
293
+ const operationEntry = this.entries.get(this.key(databaseIdentifier, databaseConfiguration))
294
+
295
+ if (requireReady && databaseConfiguration.tenantOnly && databaseConfiguration.migrations && !operationEntry?.ready) {
296
+ const generation = operationEntry?.schemaGeneration ? ` for schema generation ${JSON.stringify(operationEntry.schemaGeneration)}` : ""
297
+
298
+ throw new Error(`Frontend tenant database ${JSON.stringify(databaseIdentifier)} is not ready${generation}`)
299
+ }
300
+ if (!operationEntry) return undefined
301
+ if (schemaGeneration && operationEntry.schemaGeneration && schemaGeneration !== operationEntry.schemaGeneration) {
302
+ throw new Error(`Frontend tenant database ${JSON.stringify(databaseIdentifier)} is on schema generation ${JSON.stringify(operationEntry.schemaGeneration)}, not ${JSON.stringify(schemaGeneration)}`)
192
303
  }
304
+ if (operationEntry.state !== "open") await this.openUnlocked(operationEntry)
305
+
306
+ operationEntry.pinCount++
307
+ operationEntry.lastUsed = ++this.sequence
308
+
309
+ return operationEntry
193
310
  })
311
+ const operationSchemaGeneration = schemaGeneration || entry?.schemaGeneration
312
+
313
+ if (!entry) return await callback(operationSchemaGeneration)
314
+
315
+ try {
316
+ return await callback(operationSchemaGeneration)
317
+ } finally {
318
+ const dirty = this.configuration.getDatabasePool(databaseIdentifier).capturedConnectionHasPendingWrites(databaseConfiguration)
319
+
320
+ await this.serialize(async () => {
321
+ entry.dirty ||= dirty
322
+ entry.lastUsed = ++this.sequence
323
+ entry.pinCount--
324
+ })
325
+ }
194
326
  }
195
327
 
196
328
  async openUnlocked(/** @type {LifecycleEntry} */ entry) {
@@ -222,6 +354,9 @@ export default class FrontendTenantSqliteLifecycle {
222
354
  const victim = candidates[0]
223
355
  if (!victim) throw new Error(`Frontend tenant SQLite handle capacity ${this.maxOpenHandles} reached; every handle is dirty, pinned, or in use`)
224
356
  await this.configuration.getDatabasePool(victim.databaseIdentifier).closeCapturedConnection(victim.databaseConfiguration)
357
+ this.configuration.clearRecordMetadataForDatabaseIdentity(this.key(victim.databaseIdentifier, victim.databaseConfiguration))
358
+ victim.ready = false
359
+ victim.schemaGeneration = undefined
225
360
  victim.state = "closed"
226
361
  this.entries.delete(this.key(victim.databaseIdentifier, victim.databaseConfiguration))
227
362
  }
@@ -1,11 +1,13 @@
1
1
  // @ts-check
2
2
 
3
+ import Migrator from "../database/migrator.js"
4
+
3
5
  /**
4
6
  * TenantDescriptorValue type.
5
7
  * @typedef {null | boolean | number | string | TenantDescriptorValue[] | {[key: string]: TenantDescriptorValue}} TenantDescriptorValue
6
8
  */
7
9
  /** @typedef {{[key: string]: TenantDescriptorValue}} TenantDescriptor */
8
- /** @typedef {{databaseIdentifier: string, dirty: boolean, lastUsed: number, pinCount: number, state: "closed" | "closing" | "deleting" | "open" | "opening"}} TenantSqliteLifecycleSnapshot */
10
+ /** @typedef {{databaseIdentifier: string, dirty: boolean, lastUsed: number, pinCount: number, ready: boolean, schemaGeneration: string | undefined, state: "closed" | "closing" | "deleting" | "open" | "opening"}} TenantSqliteLifecycleSnapshot */
9
11
 
10
12
  /**
11
13
  * Returns a readable path for a captured descriptor/configuration value.
@@ -204,6 +206,17 @@ export default class TenantHandle {
204
206
  return this._tenant
205
207
  }
206
208
 
209
+ /**
210
+ * Rejects using this handle with a different Configuration instance.
211
+ * @param {import("../configuration.js").default} configuration - Expected owner.
212
+ * @returns {void}
213
+ */
214
+ assertConfiguration(configuration) {
215
+ if (configuration !== this._configuration) {
216
+ throw new Error("Tenant handle belongs to a different Velocious configuration")
217
+ }
218
+ }
219
+
207
220
  /**
208
221
  * Returns the captured physical configuration for an active identifier.
209
222
  * @param {string} databaseIdentifier - Logical database identifier.
@@ -229,6 +242,58 @@ export default class TenantHandle {
229
242
  return await this._configuration.getFrontendTenantSqliteLifecycle().open(databaseIdentifier, this.databaseConfiguration(databaseIdentifier))
230
243
  }
231
244
 
245
+ /**
246
+ * Opens, migrates, and initializes record metadata for one captured physical
247
+ * tenant SQLite database and application schema generation.
248
+ * @param {object} options - Initialization options.
249
+ * @param {string} options.databaseIdentifier - Tenant-only logical database identifier.
250
+ * @param {import("../database/migrator/types.js").RequireMigrationContextType} options.migrations - Frontend migration require context.
251
+ * @param {string} options.schemaGeneration - Stable application schema generation.
252
+ * @returns {Promise<Readonly<TenantSqliteLifecycleSnapshot>>} - Ready lifecycle snapshot.
253
+ */
254
+ async initialize({databaseIdentifier, migrations, schemaGeneration}) {
255
+ if (!migrations || typeof migrations !== "function" || typeof migrations.keys !== "function") {
256
+ throw new TypeError("TenantHandle.initialize requires a migrations require context")
257
+ }
258
+
259
+ const databaseConfiguration = this.databaseConfiguration(databaseIdentifier)
260
+ const lifecycle = this._configuration.getFrontendTenantSqliteLifecycle()
261
+
262
+ return await lifecycle.initialize(databaseIdentifier, databaseConfiguration, schemaGeneration, async () => {
263
+ await this._databaseOperation({
264
+ databaseIdentifier,
265
+ name: `Initialize frontend tenant database: ${databaseIdentifier}`,
266
+ requireReady: false,
267
+ schemaGeneration
268
+ }, async (operation) => {
269
+ const migrator = new Migrator({configuration: this._configuration, databaseIdentifiers: [databaseIdentifier]})
270
+
271
+ operation.connection().clearSchemaCache()
272
+ await migrator.migrateRequireContextForDatabase({
273
+ databaseConfiguration,
274
+ databaseIdentifier,
275
+ db: operation.connection(),
276
+ requireContext: migrations
277
+ })
278
+ await this._configuration.initializeModels({type: "frontend-tenant"})
279
+
280
+ for (const modelClass of Object.values(this._configuration.getModelClasses())) {
281
+ if (modelClass.getDatabaseIdentifier({tenant: this._tenant}) !== databaseIdentifier) continue
282
+ const table = await operation.connection().getTableByName(modelClass.tableName(), {throwError: false})
283
+
284
+ if (!table && !modelClass.getEagerLoadRecordMetadata()) continue
285
+ if (Object.keys(modelClass.getTranslationsMap()).length > 0) {
286
+ const translationsTable = await operation.connection().getTableByName(modelClass.getTranslationsTableName(), {throwError: false})
287
+
288
+ if (!translationsTable && !modelClass.getEagerLoadRecordMetadata()) continue
289
+ }
290
+
291
+ await operation.ensureModelInitialized(modelClass)
292
+ }
293
+ })
294
+ })
295
+ }
296
+
232
297
  /**
233
298
  * Flushes this captured SQLite identity.
234
299
  * @param {{databaseIdentifier: string}} options - Lifecycle options.
@@ -290,13 +355,32 @@ export default class TenantHandle {
290
355
  * @returns {Promise<T>} - Callback result.
291
356
  */
292
357
  async databaseOperation({databaseIdentifier, name = "TenantHandle.databaseOperation"}, callback) {
358
+ return await this._databaseOperation({databaseIdentifier, name, requireReady: true}, callback)
359
+ }
360
+
361
+ /**
362
+ * Runs a captured operation with explicit readiness policy. Initialization is
363
+ * the only caller allowed to enter an unready schema generation.
364
+ * @template T
365
+ * @param {{databaseIdentifier: string, name: string, requireReady: boolean, schemaGeneration?: string}} options - Internal operation options.
366
+ * @param {(operation: import("../database/operation.js").default) => Promise<T>} callback - Operation callback.
367
+ * @returns {Promise<T>} - Callback result.
368
+ */
369
+ async _databaseOperation({databaseIdentifier, name, requireReady, schemaGeneration}, callback) {
293
370
  const databaseConfiguration = this.databaseConfiguration(databaseIdentifier)
294
- return await this._configuration.getFrontendTenantSqliteLifecycle().databaseOperation(databaseIdentifier, databaseConfiguration, async () => await this._configuration.withDatabaseOperation({
295
- databaseConfiguration,
371
+
372
+ return await this._configuration.getFrontendTenantSqliteLifecycle().databaseOperation(
296
373
  databaseIdentifier,
297
- name,
298
- tenant: this._tenant
299
- }, callback))
374
+ databaseConfiguration,
375
+ {requireReady, schemaGeneration},
376
+ async (operationSchemaGeneration) => await this._configuration.withDatabaseOperation({
377
+ databaseConfiguration,
378
+ databaseIdentifier,
379
+ name,
380
+ schemaGeneration: operationSchemaGeneration,
381
+ tenant: this._tenant
382
+ }, callback)
383
+ )
300
384
  }
301
385
 
302
386
  /**
@@ -3,6 +3,7 @@
3
3
  import SystemTestBrowserHelper from "system-testing/build/system-test-browser-helper.js"
4
4
  import BrowserEnvironmentHandler from "../environment-handlers/browser.js"
5
5
  import runFrontendModelEventHookScenario from "./browser-frontend-model-event-hook-scenarios.js"
6
+ import runUseDatabaseSelectionTransitionScenario from "./browser-use-database-hook-scenarios.js"
6
7
 
7
8
  const root = document.getElementById("root") || (() => {
8
9
  const element = document.createElement("div")
@@ -28,5 +29,6 @@ systemTestBrowserHelper.enableOnBrowser()
28
29
  Object.assign(globalThis, {velociousBrowserTest: {
29
30
  BrowserEnvironmentHandler,
30
31
  runFrontendModelEventHookScenario,
32
+ runUseDatabaseSelectionTransitionScenario,
31
33
  systemTestBrowserHelper
32
34
  }})
@@ -0,0 +1,204 @@
1
+ // @ts-check
2
+
3
+ import React, { act } from "react"
4
+ import {createRoot} from "react-dom/client"
5
+
6
+ import Configuration, {CurrentConfigurationNotSetError} from "../configuration.js"
7
+ import BrowserEnvironmentHandler from "../environment-handlers/browser.js"
8
+ import Migration from "../database/migration/index.js"
9
+ import SingleMultiUsePool from "../database/pool/single-multi-use.js"
10
+ import SqliteWebDriver from "../database/drivers/sqlite/index.web.js"
11
+ import TenantHandle from "../tenants/tenant-handle.js"
12
+ import useDatabase from "../database/use-database.js"
13
+
14
+ /**
15
+ * Returns the current configuration without requiring one to exist.
16
+ * @returns {Configuration | undefined} - Current configuration when installed.
17
+ */
18
+ function currentConfigurationOrUndefined() {
19
+ try {
20
+ return Configuration.current()
21
+ } catch (error) {
22
+ if (!(error instanceof CurrentConfigurationNotSetError)) throw error
23
+
24
+ return undefined
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Builds the scoped browser configuration used by the real hook scenario.
30
+ * @returns {Configuration} - Browser test configuration.
31
+ */
32
+ function buildHookConfiguration() {
33
+ return new Configuration({
34
+ database: {
35
+ test: {
36
+ projectTenant: {
37
+ driver: SqliteWebDriver,
38
+ getConnection: async () => { throw new Error("Hook scenario must not open a physical database") },
39
+ migrations: true,
40
+ name: "use-database-hook-template",
41
+ poolType: SingleMultiUsePool,
42
+ tenantOnly: true,
43
+ type: "sqlite"
44
+ }
45
+ }
46
+ },
47
+ directory: "/use-database-hook-scenario",
48
+ environment: "test",
49
+ environmentHandler: new BrowserEnvironmentHandler(),
50
+ initializeModels: async () => {},
51
+ locale: "en",
52
+ localeFallbacks: {en: ["en"]},
53
+ locales: ["en"],
54
+ tenantDatabaseResolver: ({identifier, tenant}) => {
55
+ if (identifier !== "projectTenant" || !tenant || typeof tenant !== "object") return
56
+
57
+ return {name: `use-database-hook-${String(/** @type {{slug?: string}} */ (tenant).slug)}`}
58
+ }
59
+ })
60
+ }
61
+
62
+ /**
63
+ * Flushes real React hook transitions and reports synchronous selection state,
64
+ * inline loader stability, and stale completion suppression.
65
+ * @returns {Promise<{firstChangedLoaded: boolean, initialLoaded: boolean, loaderCalls: string[], loadedAfterInlineLoaderRerender: boolean, thirdAfterStaleCompletion: {error: string | null, loaded: boolean}}>} - Render observations.
66
+ */
67
+ export default async function runUseDatabaseSelectionTransitionScenario() {
68
+ const container = document.createElement("div")
69
+ document.body.appendChild(container)
70
+ const root = createRoot(container)
71
+ /** @type {{error: string | null, loaded: boolean, selection: "first" | "second" | "third"}[]} */
72
+ const renders = []
73
+ /** @type {string[]} */
74
+ const loaderCalls = []
75
+ /**
76
+ * Releases the deliberately stale second initialization.
77
+ * @type {() => void}
78
+ */
79
+ let releaseSecondInitialization = () => {}
80
+ const secondInitializationRelease = new Promise((resolve) => { releaseSecondInitialization = () => resolve(undefined) })
81
+ /**
82
+ * Signals that the deliberately stale second initialization started.
83
+ * @type {() => void}
84
+ */
85
+ let signalSecondInitialization = () => {}
86
+ const secondInitializationStarted = new Promise((resolve) => { signalSecondInitialization = () => resolve(undefined) })
87
+
88
+ class HookTenantHandle extends TenantHandle {
89
+ /**
90
+ * Runs test initialization without touching a physical database.
91
+ * @param {{migrations: import("../database/migrator/types.js").RequireMigrationContextType}} args - Hook initialization arguments.
92
+ * @returns {Promise<Readonly<ReturnType<TenantHandle["inspect"]>>>} - Ready test snapshot.
93
+ */
94
+ async initialize({migrations}) {
95
+ loaderCalls.push(migrations.id)
96
+
97
+ if (this.tenant().slug === "second") {
98
+ signalSecondInitialization()
99
+ await secondInitializationRelease
100
+ throw new Error("SECOND_SELECTION_FAILED")
101
+ }
102
+
103
+ return Object.freeze({databaseIdentifier: "projectTenant", dirty: false, lastUsed: 0, pinCount: 0, ready: true, schemaGeneration: "generation-1", state: "open"})
104
+ }
105
+ }
106
+ const previousConfiguration = currentConfigurationOrUndefined()
107
+ const restorationConfiguration = previousConfiguration || buildHookConfiguration()
108
+ const configuration = buildHookConfiguration()
109
+
110
+ configuration.setCurrent()
111
+
112
+ const firstHandle = new HookTenantHandle({configuration, tenant: {slug: "first"}})
113
+ const secondHandle = new HookTenantHandle({configuration, tenant: {slug: "second"}})
114
+ const thirdHandle = new HookTenantHandle({configuration, tenant: {slug: "third"}})
115
+ const renderCounts = {first: 0, second: 0, third: 0}
116
+
117
+ /**
118
+ * Runs hook probe.
119
+ * @param {{selection: "first" | "second" | "third", tenantHandle: HookTenantHandle}} props - Probe props.
120
+ * @returns {React.ReactElement} - Empty host element.
121
+ */
122
+ function HookProbe({selection, tenantHandle}) {
123
+ const state = useDatabase({
124
+ databaseIdentifier: "projectTenant",
125
+ migrationsRequireContextCallback: async () => {
126
+ /**
127
+ * Looks up an empty migration context entry recreated by every probe render.
128
+ * @param {string} fileName - Requested migration file.
129
+ * @returns {{default: typeof Migration}} - Placeholder migration module.
130
+ * @type {import("../database/migrator/types.js").RequireMigrationContextType}
131
+ */
132
+ const emptyMigrations = (fileName) => {
133
+ if (!fileName) throw new Error("Migration context lookup requires a file name")
134
+
135
+ return {default: Migration}
136
+ }
137
+
138
+ emptyMigrations.keys = () => []
139
+ emptyMigrations.id = selection
140
+
141
+ return emptyMigrations
142
+ },
143
+ schemaGeneration: "generation-1",
144
+ tenantHandle
145
+ })
146
+
147
+ renderCounts[selection]++
148
+ if (renderCounts[selection] > 20) throw new Error(`useDatabase render loop for ${selection}`)
149
+ renders.push({error: state.error?.message || null, loaded: state.loaded, selection})
150
+
151
+ return React.createElement("div")
152
+ }
153
+
154
+ try {
155
+ await act(async () => {
156
+ root.render(React.createElement(HookProbe, {selection: "first", tenantHandle: firstHandle}))
157
+ })
158
+
159
+ const initialRender = renders.at(-1)
160
+
161
+ await act(async () => {
162
+ root.render(React.createElement(HookProbe, {selection: "first", tenantHandle: firstHandle}))
163
+ })
164
+
165
+ const loadedAfterInlineLoaderRerender = renders.at(-1)?.loaded
166
+
167
+ act(() => {
168
+ root.render(React.createElement(HookProbe, {selection: "second", tenantHandle: secondHandle}))
169
+ })
170
+
171
+ const firstChangedRender = renders.find((render) => render.selection === "second")
172
+ await secondInitializationStarted
173
+
174
+ await act(async () => {
175
+ root.render(React.createElement(HookProbe, {selection: "third", tenantHandle: thirdHandle}))
176
+ })
177
+
178
+ releaseSecondInitialization()
179
+ await act(async () => { await secondInitializationRelease })
180
+
181
+ const thirdAfterStaleCompletion = renders.at(-1)
182
+
183
+ if (!initialRender || !firstChangedRender || !thirdAfterStaleCompletion || loadedAfterInlineLoaderRerender === undefined) {
184
+ throw new Error("useDatabase hook scenario did not render every selection")
185
+ }
186
+
187
+ return {
188
+ firstChangedLoaded: firstChangedRender.loaded,
189
+ initialLoaded: initialRender.loaded,
190
+ loaderCalls,
191
+ loadedAfterInlineLoaderRerender,
192
+ thirdAfterStaleCompletion: {
193
+ error: thirdAfterStaleCompletion.error,
194
+ loaded: thirdAfterStaleCompletion.loaded
195
+ }
196
+ }
197
+ } finally {
198
+ releaseSecondInitialization()
199
+ await act(async () => { root.unmount() })
200
+ container.remove()
201
+ restorationConfiguration.setCurrent()
202
+ await configuration.closeDatabaseConnections()
203
+ }
204
+ }
@@ -1456,14 +1456,15 @@ export default class TestRunner {
1456
1456
  * @type {Record<ConsoleMethodName, (...args: Array<ReturnType<typeof JSON.parse>>) => void>} */
1457
1457
  const consoleObject = /** @type {Record<ConsoleMethodName, (...args: Array<ReturnType<typeof JSON.parse>>) => void>} */ (console)
1458
1458
  /**
1459
- * Original console methods.
1459
+ * Original console methods captured as direct references so stopping restores
1460
+ * the exact method that was installed at capture start.
1460
1461
  * @type {Record<ConsoleMethodName, (...args: Array<ReturnType<typeof JSON.parse>>) => void>} */
1461
1462
  const originalConsoleMethods = {
1462
- debug: consoleObject.debug.bind(console),
1463
- error: consoleObject.error.bind(console),
1464
- info: consoleObject.info.bind(console),
1465
- log: consoleObject.log.bind(console),
1466
- warn: consoleObject.warn.bind(console)
1463
+ debug: consoleObject.debug,
1464
+ error: consoleObject.error,
1465
+ info: consoleObject.info,
1466
+ log: consoleObject.log,
1467
+ warn: consoleObject.warn
1467
1468
  }
1468
1469
  let stopped = false
1469
1470
  let outputText = ""
@@ -1473,7 +1474,7 @@ export default class TestRunner {
1473
1474
  lines.push(`[${new Date().toISOString()}] [${methodName}] ${format(...args)}`)
1474
1475
 
1475
1476
  if (passthrough) {
1476
- originalConsoleMethods[methodName](...args)
1477
+ originalConsoleMethods[methodName].apply(consoleObject, args)
1477
1478
  }
1478
1479
  }
1479
1480
  }