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,41 +8,94 @@ import Migrator from "./migrator.js"
8
8
  import restArgsError from "../utils/rest-args-error.js"
9
9
 
10
10
  /**
11
- * Runs load migrations.
12
- * @param {object} args - Options object.
13
- * @param {() => Promise<import("./migrator/types.js").RequireMigrationContextType>} args.migrationsRequireContextCallback - Migrations require context callback.
14
- * @returns {{loaded: boolean}} - Whether the database was loaded by this call.
11
+ * Initializes either the legacy ambient frontend database or one immutable
12
+ * tenant-bound physical database generation.
13
+ * @param {object} args - Initialization options.
14
+ * @param {import("../configuration.js").default} args.configuration - Current frontend configuration.
15
+ * @param {string} [args.databaseIdentifier] - Tenant-only logical database identifier.
16
+ * @param {() => Promise<import("./migrator/types.js").RequireMigrationContextType>} args.migrationsRequireContextCallback - Migrations loader.
17
+ * @param {string} [args.schemaGeneration] - Tenant schema generation.
18
+ * @param {import("../tenants/tenant-handle.js").default} [args.tenantHandle] - Immutable tenant handle.
19
+ * @returns {Promise<void>} - Resolves when the selected database is ready.
15
20
  */
16
- export default function loadMigrations({migrationsRequireContextCallback, ...restArgs}) {
17
- const instance = React.useMemo(() => ({running: false}), [])
21
+ export async function initializeFrontendDatabase({configuration, databaseIdentifier, migrationsRequireContextCallback, schemaGeneration, tenantHandle}) {
22
+ if (tenantHandle) {
23
+ tenantHandle.assertConfiguration(configuration)
24
+ if (!databaseIdentifier) throw new Error("Tenant frontend database initialization requires databaseIdentifier")
25
+ if (!schemaGeneration) throw new Error("Tenant frontend database initialization requires schemaGeneration")
26
+
27
+ await tenantHandle.initialize({
28
+ databaseIdentifier,
29
+ migrations: await migrationsRequireContextCallback(),
30
+ schemaGeneration
31
+ })
32
+ await configuration.initialize()
33
+ return
34
+ }
35
+ if (databaseIdentifier || schemaGeneration) {
36
+ throw new Error("Tenant frontend database initialization requires tenantHandle")
37
+ }
38
+
39
+ await configuration.ensureConnections({name: "React database migration loader"}, async () => {
40
+ const migrator = new Migrator({configuration})
41
+
42
+ await migrator.prepare()
43
+ await migrator.migrateFilesFromRequireContext(await migrationsRequireContextCallback())
44
+ })
45
+
46
+ await configuration.initialize()
47
+ }
48
+
49
+ /**
50
+ * React lifecycle hook for frontend database readiness. With `tenantHandle`,
51
+ * readiness follows that immutable physical tenant plus `schemaGeneration`;
52
+ * changing either cancels the stale render result without cancelling shared
53
+ * lifecycle work needed by another caller.
54
+ * @param {object} args - Hook options.
55
+ * @param {string} [args.databaseIdentifier] - Tenant-only logical database identifier.
56
+ * @param {() => Promise<import("./migrator/types.js").RequireMigrationContextType>} args.migrationsRequireContextCallback - Migrations loader.
57
+ * @param {string} [args.schemaGeneration] - Tenant schema generation.
58
+ * @param {import("../tenants/tenant-handle.js").default} [args.tenantHandle] - Immutable tenant handle.
59
+ * @returns {{error: Error | null, loaded: boolean}} - Selected database readiness.
60
+ */
61
+ export default function useDatabase({databaseIdentifier, migrationsRequireContextCallback, schemaGeneration, tenantHandle, ...restArgs}) {
62
+ restArgsError(restArgs)
63
+
18
64
  const {isServer} = useEnvSense()
19
- const [loaded, setLoaded] = React.useState(false)
65
+ const migrationsRequireContextCallbackRef = React.useRef(migrationsRequireContextCallback)
20
66
 
21
- const loadDatabase = React.useCallback(async () => {
22
- instance.running = true
67
+ migrationsRequireContextCallbackRef.current = migrationsRequireContextCallback
23
68
 
24
- try {
25
- await Configuration.current().ensureConnections({name: "React database migration loader"}, async () => {
26
- const migrator = new Migrator({configuration: Configuration.current()})
69
+ const selection = React.useMemo(() => {
70
+ if (isServer) return undefined
27
71
 
28
- await migrator.prepare()
29
- await migrator.migrateFilesFromRequireContext(await migrationsRequireContextCallback())
30
- })
72
+ return {configuration: Configuration.current(), databaseIdentifier, schemaGeneration, tenantHandle}
73
+ }, [isServer, databaseIdentifier, schemaGeneration, tenantHandle])
74
+ const [state, setState] = React.useState({error: /** @type {Error | null} */ (null), loaded: false, selection})
31
75
 
32
- await Configuration.current().initialize()
33
- setLoaded(true)
34
- } finally {
35
- instance.running = false
36
- }
37
- }, [])
76
+ React.useEffect(() => {
77
+ if (!selection) return undefined
38
78
 
39
- React.useMemo(() => {
40
- if (!loaded && !isServer && !instance.running) {
41
- loadDatabase()
42
- }
43
- }, [loaded])
79
+ let current = true
80
+ const migrationsLoader = migrationsRequireContextCallbackRef.current
44
81
 
45
- restArgsError(restArgs)
82
+ setState({error: null, loaded: false, selection})
83
+ void initializeFrontendDatabase({
84
+ configuration: selection.configuration,
85
+ databaseIdentifier: selection.databaseIdentifier,
86
+ migrationsRequireContextCallback: migrationsLoader,
87
+ schemaGeneration: selection.schemaGeneration,
88
+ tenantHandle: selection.tenantHandle
89
+ }).then(() => {
90
+ if (current) setState({error: null, loaded: true, selection})
91
+ }, (error) => {
92
+ if (current) setState({error: error instanceof Error ? error : new Error(String(error)), loaded: false, selection})
93
+ })
94
+
95
+ return () => { current = false }
96
+ }, [selection])
97
+
98
+ if (state.selection !== selection) return {error: null, loaded: false}
46
99
 
47
- return {loaded}
100
+ return {error: state.error, loaded: state.loaded}
48
101
  }
@@ -76,7 +76,10 @@ export default class ServerChangeFeedStore {
76
76
  this._memoryChanges = []
77
77
  this._memorySequence = 0
78
78
  this._isReady = false
79
+ /** @type {Promise<void> | null} */
79
80
  this._readyPromise = null
81
+ /** @type {WeakMap<import("../database/drivers/base.js").default, {completion: Promise<void>, promise: Promise<void>}>} */
82
+ this._transactionReadyPromises = new WeakMap()
80
83
  }
81
84
 
82
85
  /**
@@ -90,15 +93,60 @@ export default class ServerChangeFeedStore {
90
93
  }
91
94
 
92
95
  if (await this._schemaReady()) return
93
- if (this._readyPromise) return await this._readyPromise
94
96
 
95
- this._readyPromise = (async () => {
96
- this.configuration.setCurrent()
97
- await this._withDb(async (db) => {
98
- await this._ensureChangesTable(db)
97
+ this.configuration.setCurrent()
98
+ await this._withDb(async (db) => await this._ensureReadyWithDb(db))
99
+ }
100
+
101
+ /**
102
+ * Coordinates durable and transaction-local readiness on one connection.
103
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
104
+ * @returns {Promise<void>} Resolves when this caller can use the table.
105
+ */
106
+ async _ensureReadyWithDb(db) {
107
+ if (this._isReady) return
108
+
109
+ const transactionCompletion = db.insideTransaction() ? db.transactionCompletion() : null
110
+ const transactionReady = this._transactionReadyPromises.get(db)
111
+
112
+ if (transactionCompletion && transactionReady?.completion === transactionCompletion) {
113
+ await transactionReady.promise
114
+ return
115
+ }
116
+
117
+ if (this._readyPromise) {
118
+ const readyPromise = this._readyPromise
119
+
120
+ await readyPromise
121
+ if (this._readyPromise === readyPromise) this._readyPromise = null
122
+ if (this._isReady) return
123
+
124
+ await this.ensureReady()
125
+ return
126
+ }
127
+
128
+ if (transactionCompletion) {
129
+ const tableReadyPromise = this._ensureChangesTable(db)
130
+ const transactionReadyPromise = tableReadyPromise.then(() => undefined)
131
+
132
+ const durableReadyPromise = tableReadyPromise.then(async (created) => {
133
+ if (!created) {
134
+ this._isReady = true
135
+ return
136
+ }
137
+
138
+ await transactionCompletion
99
139
  })
140
+
141
+ this._transactionReadyPromises.set(db, {completion: transactionCompletion, promise: transactionReadyPromise})
142
+ this._readyPromise = durableReadyPromise
143
+ await transactionReadyPromise
144
+ return
145
+ }
146
+
147
+ this._readyPromise = this._ensureChangesTable(db).then(() => {
100
148
  this._isReady = true
101
- })()
149
+ })
102
150
 
103
151
  try {
104
152
  await this._readyPromise
@@ -257,10 +305,10 @@ export default class ServerChangeFeedStore {
257
305
  /**
258
306
  * Ensures changes table exists.
259
307
  * @param {import("../database/drivers/base.js").default} db - Database connection.
260
- * @returns {Promise<void>} - Resolves when complete.
308
+ * @returns {Promise<boolean>} - Whether the table had to be created.
261
309
  */
262
310
  async _ensureChangesTable(db) {
263
- if (await db.tableExists(TABLE_NAME)) return
311
+ if (await db.tableExists(TABLE_NAME)) return false
264
312
 
265
313
  const table = new TableData(TABLE_NAME, {ifNotExists: true})
266
314
 
@@ -279,6 +327,8 @@ export default class ServerChangeFeedStore {
279
327
  table.datetime("created_at", {index: true, null: false})
280
328
 
281
329
  await db.createTable(table)
330
+
331
+ return true
282
332
  }
283
333
 
284
334
  /**
@@ -50,6 +50,8 @@ export default class SyncScopeStore {
50
50
  this._isReady = false
51
51
  /** @type {Promise<void> | null} */
52
52
  this._readyPromise = null
53
+ /** @type {WeakMap<import("../database/drivers/base.js").default, {completion: Promise<void>, promise: Promise<void>}>} */
54
+ this._transactionReadyPromises = new WeakMap()
53
55
  }
54
56
 
55
57
  /**
@@ -64,10 +66,56 @@ export default class SyncScopeStore {
64
66
  return
65
67
  }
66
68
 
67
- if (this._readyPromise) return await this._readyPromise
69
+ await this._withDb(async (db) => await this._ensureReadyWithDb(db))
70
+ }
71
+
72
+ /**
73
+ * Coordinates durable and transaction-local readiness on one connection.
74
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
75
+ * @returns {Promise<void>} Resolves when this caller can use the table.
76
+ */
77
+ async _ensureReadyWithDb(db) {
78
+ if (this._isReady) return
79
+
80
+ const transactionCompletion = db.insideTransaction() ? db.transactionCompletion() : null
81
+ const transactionReady = this._transactionReadyPromises.get(db)
68
82
 
69
- this._readyPromise = this._withDb(async (db) => {
70
- await this._ensureScopesTable(db)
83
+ if (transactionCompletion && transactionReady?.completion === transactionCompletion) {
84
+ await transactionReady.promise
85
+ return
86
+ }
87
+
88
+ if (this._readyPromise) {
89
+ const readyPromise = this._readyPromise
90
+
91
+ await readyPromise
92
+ if (this._readyPromise === readyPromise) this._readyPromise = null
93
+ if (this._isReady) return
94
+
95
+ await this.ensureReady()
96
+ return
97
+ }
98
+
99
+ if (transactionCompletion) {
100
+ const tableReadyPromise = this._ensureScopesTable(db)
101
+ const transactionReadyPromise = tableReadyPromise.then(() => undefined)
102
+
103
+ const durableReadyPromise = tableReadyPromise.then(async (created) => {
104
+ if (!created) {
105
+ this._isReady = true
106
+ return
107
+ }
108
+
109
+ await transactionCompletion
110
+ })
111
+
112
+ this._transactionReadyPromises.set(db, {completion: transactionCompletion, promise: transactionReadyPromise})
113
+ this._readyPromise = durableReadyPromise
114
+ await transactionReadyPromise
115
+ return
116
+ }
117
+
118
+ this._readyPromise = this._ensureScopesTable(db).then(() => {
71
119
  this._isReady = true
72
120
  })
73
121
 
@@ -274,10 +322,10 @@ export default class SyncScopeStore {
274
322
  /**
275
323
  * Ensures the scopes table exists.
276
324
  * @param {import("../database/drivers/base.js").default} db - Database connection.
277
- * @returns {Promise<void>}
325
+ * @returns {Promise<boolean>} Whether the table had to be created.
278
326
  */
279
327
  async _ensureScopesTable(db) {
280
- if (await db.tableExists(TABLE_NAME)) return
328
+ if (await db.tableExists(TABLE_NAME)) return false
281
329
 
282
330
  const table = new TableData(TABLE_NAME, {ifNotExists: true})
283
331
 
@@ -291,6 +339,8 @@ export default class SyncScopeStore {
291
339
  table.datetime("updated_at", {null: false})
292
340
 
293
341
  await db.createTable(table)
342
+
343
+ return true
294
344
  }
295
345
 
296
346
  /**
@@ -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
  }