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.
- package/README.md +3 -2
- package/build/configuration.js +109 -14
- package/build/database/drivers/base.js +30 -0
- package/build/database/migrator.js +90 -24
- package/build/database/operation.js +55 -4
- package/build/database/query/join-object.js +7 -4
- package/build/database/query/model-class-query.js +38 -7
- package/build/database/query/preloader/belongs-to.js +9 -6
- package/build/database/query/preloader/has-many.js +25 -14
- package/build/database/query/preloader/has-one.js +7 -4
- package/build/database/query/preloader/query-for-model.js +16 -1
- package/build/database/query/preloader.js +6 -4
- package/build/database/query/query-data.js +3 -3
- package/build/database/query/where-model-class-hash.js +8 -4
- package/build/database/query/with-count.js +4 -3
- package/build/database/record/index.js +148 -5
- package/build/database/record/instance-relationships/base.js +13 -0
- package/build/database/record/instance-relationships/belongs-to.js +1 -1
- package/build/database/record/instance-relationships/has-many.js +5 -2
- package/build/database/record/instance-relationships/has-one.js +1 -1
- package/build/database/record/relationships/base.js +17 -0
- package/build/database/use-database.js +81 -28
- package/build/src/configuration.d.ts +31 -4
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +88 -17
- package/build/src/database/drivers/base.d.ts +11 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +28 -1
- package/build/src/database/migrator.d.ts +34 -0
- package/build/src/database/migrator.d.ts.map +1 -1
- package/build/src/database/migrator.js +83 -23
- package/build/src/database/operation.d.ts +15 -1
- package/build/src/database/operation.d.ts.map +1 -1
- package/build/src/database/operation.js +52 -5
- package/build/src/database/query/join-object.d.ts.map +1 -1
- package/build/src/database/query/join-object.js +7 -5
- package/build/src/database/query/model-class-query.d.ts +6 -0
- package/build/src/database/query/model-class-query.d.ts.map +1 -1
- package/build/src/database/query/model-class-query.js +33 -8
- package/build/src/database/query/preloader/belongs-to.d.ts.map +1 -1
- package/build/src/database/query/preloader/belongs-to.js +9 -7
- package/build/src/database/query/preloader/has-many.d.ts.map +1 -1
- package/build/src/database/query/preloader/has-many.js +23 -16
- package/build/src/database/query/preloader/has-one.d.ts.map +1 -1
- package/build/src/database/query/preloader/has-one.js +7 -5
- package/build/src/database/query/preloader/query-for-model.d.ts +8 -0
- package/build/src/database/query/preloader/query-for-model.d.ts.map +1 -1
- package/build/src/database/query/preloader/query-for-model.js +15 -2
- package/build/src/database/query/preloader.d.ts.map +1 -1
- package/build/src/database/query/preloader.js +6 -5
- package/build/src/database/query/query-data.js +4 -4
- package/build/src/database/query/where-model-class-hash.d.ts.map +1 -1
- package/build/src/database/query/where-model-class-hash.js +7 -5
- package/build/src/database/query/with-count.js +5 -4
- package/build/src/database/record/index.d.ts +95 -0
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +126 -6
- package/build/src/database/record/instance-relationships/base.d.ts +6 -0
- package/build/src/database/record/instance-relationships/base.d.ts.map +1 -1
- package/build/src/database/record/instance-relationships/base.js +12 -1
- package/build/src/database/record/instance-relationships/belongs-to.js +2 -2
- package/build/src/database/record/instance-relationships/has-many.d.ts.map +1 -1
- package/build/src/database/record/instance-relationships/has-many.js +6 -3
- package/build/src/database/record/instance-relationships/has-one.js +2 -2
- package/build/src/database/record/relationships/base.d.ts +11 -0
- package/build/src/database/record/relationships/base.d.ts.map +1 -1
- package/build/src/database/record/relationships/base.js +15 -1
- package/build/src/database/use-database.d.ts +33 -5
- package/build/src/database/use-database.d.ts.map +1 -1
- package/build/src/database/use-database.js +81 -30
- package/build/src/sync/server-change-feed.d.ts +14 -2
- package/build/src/sync/server-change-feed.d.ts.map +1 -1
- package/build/src/sync/server-change-feed.js +50 -10
- package/build/src/sync/sync-scope-store.d.ts +13 -2
- package/build/src/sync/sync-scope-store.d.ts.map +1 -1
- package/build/src/sync/sync-scope-store.js +48 -7
- package/build/src/tenants/frontend-tenant-sqlite-lifecycle.d.ts +43 -1
- package/build/src/tenants/frontend-tenant-sqlite-lifecycle.d.ts.map +1 -1
- package/build/src/tenants/frontend-tenant-sqlite-lifecycle.js +141 -17
- package/build/src/tenants/tenant-handle.d.ts +36 -0
- package/build/src/tenants/tenant-handle.d.ts.map +1 -1
- package/build/src/tenants/tenant-handle.js +73 -3
- package/build/src/testing/browser-test-app.js +3 -1
- package/build/src/testing/browser-use-database-hook-scenarios.d.ts +16 -0
- package/build/src/testing/browser-use-database-hook-scenarios.d.ts.map +1 -0
- package/build/src/testing/browser-use-database-hook-scenarios.js +182 -0
- package/build/src/testing/test-runner.d.ts.map +1 -1
- package/build/src/testing/test-runner.js +9 -8
- package/build/src/utils/model-scope.d.ts +2 -2
- package/build/src/utils/model-scope.d.ts.map +1 -1
- package/build/src/utils/model-scope.js +5 -3
- package/build/src/utils/ransack.d.ts.map +1 -1
- package/build/src/utils/ransack.js +5 -3
- package/build/sync/server-change-feed.js +58 -8
- package/build/sync/sync-scope-store.js +55 -5
- package/build/tenants/frontend-tenant-sqlite-lifecycle.js +150 -15
- package/build/tenants/tenant-handle.js +90 -6
- package/build/testing/browser-test-app.js +2 -0
- package/build/testing/browser-use-database-hook-scenarios.js +204 -0
- package/build/testing/test-runner.js +8 -7
- package/build/tsconfig.tsbuildinfo +1 -1
- package/build/utils/model-scope.js +5 -2
- package/build/utils/ransack.js +5 -2
- package/package.json +2 -1
- package/scripts/browser-test-session.js +60 -0
- package/scripts/test-browser.js +7 -7
- package/src/configuration.js +109 -14
- package/src/database/drivers/base.js +30 -0
- package/src/database/migrator.js +90 -24
- package/src/database/operation.js +55 -4
- package/src/database/query/join-object.js +7 -4
- package/src/database/query/model-class-query.js +38 -7
- package/src/database/query/preloader/belongs-to.js +9 -6
- package/src/database/query/preloader/has-many.js +25 -14
- package/src/database/query/preloader/has-one.js +7 -4
- package/src/database/query/preloader/query-for-model.js +16 -1
- package/src/database/query/preloader.js +6 -4
- package/src/database/query/query-data.js +3 -3
- package/src/database/query/where-model-class-hash.js +8 -4
- package/src/database/query/with-count.js +4 -3
- package/src/database/record/index.js +148 -5
- package/src/database/record/instance-relationships/base.js +13 -0
- package/src/database/record/instance-relationships/belongs-to.js +1 -1
- package/src/database/record/instance-relationships/has-many.js +5 -2
- package/src/database/record/instance-relationships/has-one.js +1 -1
- package/src/database/record/relationships/base.js +17 -0
- package/src/database/use-database.js +81 -28
- package/src/sync/server-change-feed.js +58 -8
- package/src/sync/sync-scope-store.js +55 -5
- package/src/tenants/frontend-tenant-sqlite-lifecycle.js +150 -15
- package/src/tenants/tenant-handle.js +90 -6
- package/src/testing/browser-test-app.js +2 -0
- package/src/testing/browser-use-database-hook-scenarios.js +204 -0
- package/src/testing/test-runner.js +8 -7
- package/src/utils/model-scope.js +5 -2
- 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 = {
|
|
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() {
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
295
|
-
|
|
371
|
+
|
|
372
|
+
return await this._configuration.getFrontendTenantSqliteLifecycle().databaseOperation(
|
|
296
373
|
databaseIdentifier,
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
1463
|
-
error: consoleObject.error
|
|
1464
|
-
info: consoleObject.info
|
|
1465
|
-
log: consoleObject.log
|
|
1466
|
-
warn: consoleObject.warn
|
|
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](
|
|
1477
|
+
originalConsoleMethods[methodName].apply(consoleObject, args)
|
|
1477
1478
|
}
|
|
1478
1479
|
}
|
|
1479
1480
|
}
|