@vibesdotdev/localdb 0.0.2 → 1.0.0-rc.2

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 (198) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +123 -2
  3. package/dist/cli/commands/_shared.consumer.d.ts +1 -3
  4. package/dist/cli/commands/_shared.consumer.d.ts.map +1 -1
  5. package/dist/cli/commands/_shared.consumer.js +1 -11
  6. package/dist/cli/commands/_shared.consumer.js.map +1 -1
  7. package/dist/cli/commands/migrate/dev.localdb.migrate.cli-command.descriptor.js +1 -1
  8. package/dist/cli/commands/migrate/dev.localdb.migrate.cli-command.descriptor.js.map +1 -1
  9. package/dist/cli/commands/migrate/dev.localdb.migrate.cli-command.impl.consumer.d.ts +9 -3
  10. package/dist/cli/commands/migrate/dev.localdb.migrate.cli-command.impl.consumer.d.ts.map +1 -1
  11. package/dist/cli/commands/migrate/dev.localdb.migrate.cli-command.impl.consumer.js +59 -45
  12. package/dist/cli/commands/migrate/dev.localdb.migrate.cli-command.impl.consumer.js.map +1 -1
  13. package/dist/cli/commands/namespaces/dev.localdb.namespaces.cli-command.descriptor.js +1 -1
  14. package/dist/cli/commands/namespaces/dev.localdb.namespaces.cli-command.descriptor.js.map +1 -1
  15. package/dist/cli/commands/namespaces/dev.localdb.namespaces.cli-command.impl.d.ts +8 -3
  16. package/dist/cli/commands/namespaces/dev.localdb.namespaces.cli-command.impl.d.ts.map +1 -1
  17. package/dist/cli/commands/namespaces/dev.localdb.namespaces.cli-command.impl.js +12 -10
  18. package/dist/cli/commands/namespaces/dev.localdb.namespaces.cli-command.impl.js.map +1 -1
  19. package/dist/cli/commands/pools/dev.localdb.pools.cli-command.descriptor.js +1 -1
  20. package/dist/cli/commands/pools/dev.localdb.pools.cli-command.descriptor.js.map +1 -1
  21. package/dist/cli/commands/pools/dev.localdb.pools.cli-command.impl.d.ts +6 -3
  22. package/dist/cli/commands/pools/dev.localdb.pools.cli-command.impl.d.ts.map +1 -1
  23. package/dist/cli/commands/pools/dev.localdb.pools.cli-command.impl.js +8 -7
  24. package/dist/cli/commands/pools/dev.localdb.pools.cli-command.impl.js.map +1 -1
  25. package/dist/cli/commands/query/dev.localdb.query.cli-command.descriptor.js +1 -1
  26. package/dist/cli/commands/query/dev.localdb.query.cli-command.descriptor.js.map +1 -1
  27. package/dist/cli/commands/query/dev.localdb.query.cli-command.impl.consumer.d.ts +13 -3
  28. package/dist/cli/commands/query/dev.localdb.query.cli-command.impl.consumer.d.ts.map +1 -1
  29. package/dist/cli/commands/query/dev.localdb.query.cli-command.impl.consumer.js +66 -51
  30. package/dist/cli/commands/query/dev.localdb.query.cli-command.impl.consumer.js.map +1 -1
  31. package/dist/cli/commands/status/dev.localdb.status.cli-command.descriptor.js +1 -1
  32. package/dist/cli/commands/status/dev.localdb.status.cli-command.descriptor.js.map +1 -1
  33. package/dist/cli/commands/status/dev.localdb.status.cli-command.impl.consumer.d.ts +9 -3
  34. package/dist/cli/commands/status/dev.localdb.status.cli-command.impl.consumer.d.ts.map +1 -1
  35. package/dist/cli/commands/status/dev.localdb.status.cli-command.impl.consumer.js +41 -29
  36. package/dist/cli/commands/status/dev.localdb.status.cli-command.impl.consumer.js.map +1 -1
  37. package/dist/cli/inputs.d.ts +25 -0
  38. package/dist/cli/inputs.d.ts.map +1 -0
  39. package/dist/cli/inputs.js +81 -0
  40. package/dist/cli/inputs.js.map +1 -0
  41. package/dist/cli/types.d.ts +45 -0
  42. package/dist/cli/types.d.ts.map +1 -0
  43. package/dist/cli/types.js +14 -0
  44. package/dist/cli/types.js.map +1 -0
  45. package/dist/core/connection-pool.d.ts +204 -46
  46. package/dist/core/connection-pool.d.ts.map +1 -1
  47. package/dist/core/connection-pool.js +610 -101
  48. package/dist/core/connection-pool.js.map +1 -1
  49. package/dist/core/database.d.ts +243 -63
  50. package/dist/core/database.d.ts.map +1 -1
  51. package/dist/core/database.js +937 -203
  52. package/dist/core/database.js.map +1 -1
  53. package/dist/core/holders.d.ts +15 -0
  54. package/dist/core/holders.d.ts.map +1 -0
  55. package/dist/core/holders.js +4 -0
  56. package/dist/core/holders.js.map +1 -0
  57. package/dist/core/host-kinds.d.ts +22 -0
  58. package/dist/core/host-kinds.d.ts.map +1 -0
  59. package/dist/core/host-kinds.js +52 -0
  60. package/dist/core/host-kinds.js.map +1 -0
  61. package/dist/core/live-runtime.d.ts +17 -0
  62. package/dist/core/live-runtime.d.ts.map +1 -0
  63. package/dist/core/live-runtime.js +28 -0
  64. package/dist/core/live-runtime.js.map +1 -0
  65. package/dist/core/migration-registry.d.ts +115 -80
  66. package/dist/core/migration-registry.d.ts.map +1 -1
  67. package/dist/core/migration-registry.js +340 -199
  68. package/dist/core/migration-registry.js.map +1 -1
  69. package/dist/core/open-connection.d.ts +11 -0
  70. package/dist/core/open-connection.d.ts.map +1 -0
  71. package/dist/core/open-connection.js +70 -0
  72. package/dist/core/open-connection.js.map +1 -0
  73. package/dist/core/outcome.d.ts +21 -0
  74. package/dist/core/outcome.d.ts.map +1 -0
  75. package/dist/core/outcome.js +22 -0
  76. package/dist/core/outcome.js.map +1 -0
  77. package/dist/core/pool-api.d.ts +19 -0
  78. package/dist/core/pool-api.d.ts.map +1 -0
  79. package/dist/core/pool-api.js +59 -0
  80. package/dist/core/pool-api.js.map +1 -0
  81. package/dist/core/slow-statement.d.ts +81 -0
  82. package/dist/core/slow-statement.d.ts.map +1 -0
  83. package/dist/core/slow-statement.js +149 -0
  84. package/dist/core/slow-statement.js.map +1 -0
  85. package/dist/core/wal-policy.d.ts +86 -0
  86. package/dist/core/wal-policy.d.ts.map +1 -0
  87. package/dist/core/wal-policy.js +115 -0
  88. package/dist/core/wal-policy.js.map +1 -0
  89. package/dist/dev.localdb.cli-group.descriptor.d.ts +1 -1
  90. package/dist/dev.localdb.cli-group.descriptor.d.ts.map +1 -1
  91. package/dist/dev.localdb.context.descriptor.d.ts +15 -1
  92. package/dist/dev.localdb.context.descriptor.d.ts.map +1 -1
  93. package/dist/dev.localdb.context.descriptor.js +1 -1
  94. package/dist/dev.localdb.context.descriptor.js.map +1 -1
  95. package/dist/dev.localdb.context.impl.consumer.d.ts +12 -5
  96. package/dist/dev.localdb.context.impl.consumer.d.ts.map +1 -1
  97. package/dist/dev.localdb.context.impl.consumer.js +14 -5
  98. package/dist/dev.localdb.context.impl.consumer.js.map +1 -1
  99. package/dist/driver/sqlite-driver.base.d.ts +107 -0
  100. package/dist/driver/sqlite-driver.base.d.ts.map +1 -0
  101. package/dist/driver/sqlite-driver.base.js +426 -0
  102. package/dist/driver/sqlite-driver.base.js.map +1 -0
  103. package/dist/driver/sqlite-driver.d.ts +154 -0
  104. package/dist/driver/sqlite-driver.d.ts.map +1 -0
  105. package/dist/driver/sqlite-driver.js +103 -0
  106. package/dist/driver/sqlite-driver.js.map +1 -0
  107. package/dist/index.d.ts +11 -5
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +8 -4
  110. package/dist/index.js.map +1 -1
  111. package/dist/localdb.client.descriptor.d.ts +10 -0
  112. package/dist/localdb.client.descriptor.d.ts.map +1 -0
  113. package/dist/localdb.client.descriptor.js +13 -0
  114. package/dist/localdb.client.descriptor.js.map +1 -0
  115. package/dist/localdb.client.impl.consumer.d.ts +11 -0
  116. package/dist/localdb.client.impl.consumer.d.ts.map +1 -0
  117. package/dist/localdb.client.impl.consumer.js +11 -0
  118. package/dist/localdb.client.impl.consumer.js.map +1 -0
  119. package/dist/localdb.plugin.d.ts +2 -2
  120. package/dist/localdb.plugin.d.ts.map +1 -1
  121. package/dist/localdb.plugin.js +58 -10
  122. package/dist/localdb.plugin.js.map +1 -1
  123. package/dist/location.d.ts +6 -0
  124. package/dist/location.d.ts.map +1 -0
  125. package/dist/location.js +6 -0
  126. package/dist/location.js.map +1 -0
  127. package/dist/migrations/load-sql.d.ts +25 -21
  128. package/dist/migrations/load-sql.d.ts.map +1 -1
  129. package/dist/migrations/load-sql.js +91 -76
  130. package/dist/migrations/load-sql.js.map +1 -1
  131. package/dist/pool.d.ts +3 -0
  132. package/dist/pool.d.ts.map +1 -0
  133. package/dist/pool.js +3 -0
  134. package/dist/pool.js.map +1 -0
  135. package/dist/schemas/api.d.ts +133 -59
  136. package/dist/schemas/api.d.ts.map +1 -1
  137. package/dist/schemas/api.js +1 -1
  138. package/dist/schemas/api.js.map +1 -1
  139. package/dist/schemas/migrations.d.ts +123 -0
  140. package/dist/schemas/migrations.d.ts.map +1 -0
  141. package/dist/schemas/migrations.js +55 -0
  142. package/dist/schemas/migrations.js.map +1 -0
  143. package/dist/schemas/pool.d.ts +27 -0
  144. package/dist/schemas/pool.d.ts.map +1 -0
  145. package/dist/schemas/pool.js +7 -0
  146. package/dist/schemas/pool.js.map +1 -0
  147. package/dist/schemas/sqlite-driver-base.d.ts +50 -0
  148. package/dist/schemas/sqlite-driver-base.d.ts.map +1 -0
  149. package/dist/schemas/sqlite-driver-base.js +2 -0
  150. package/dist/schemas/sqlite-driver-base.js.map +1 -0
  151. package/dist/testing/sqlite-driver-conformance.d.ts +42 -0
  152. package/dist/testing/sqlite-driver-conformance.d.ts.map +1 -0
  153. package/dist/testing/sqlite-driver-conformance.js +471 -0
  154. package/dist/testing/sqlite-driver-conformance.js.map +1 -0
  155. package/package.json +78 -104
  156. package/dist/core/error-context.d.ts +0 -2
  157. package/dist/core/error-context.d.ts.map +0 -1
  158. package/dist/core/error-context.js +0 -17
  159. package/dist/core/error-context.js.map +0 -1
  160. package/dist/core/runtime.d.ts +0 -3
  161. package/dist/core/runtime.d.ts.map +0 -1
  162. package/dist/core/runtime.js +0 -17
  163. package/dist/core/runtime.js.map +0 -1
  164. package/dist/localdb.cloud.plugin.d.ts +0 -17
  165. package/dist/localdb.cloud.plugin.d.ts.map +0 -1
  166. package/dist/localdb.cloud.plugin.js +0 -23
  167. package/dist/localdb.cloud.plugin.js.map +0 -1
  168. package/src/cli/commands/_shared.consumer.ts +0 -20
  169. package/src/cli/commands/migrate/dev.localdb.migrate.cli-command.descriptor.ts +0 -20
  170. package/src/cli/commands/migrate/dev.localdb.migrate.cli-command.impl.consumer.ts +0 -97
  171. package/src/cli/commands/namespaces/dev.localdb.namespaces.cli-command.descriptor.ts +0 -17
  172. package/src/cli/commands/namespaces/dev.localdb.namespaces.cli-command.impl.ts +0 -46
  173. package/src/cli/commands/pools/dev.localdb.pools.cli-command.descriptor.ts +0 -16
  174. package/src/cli/commands/pools/dev.localdb.pools.cli-command.impl.ts +0 -37
  175. package/src/cli/commands/query/dev.localdb.query.cli-command.descriptor.ts +0 -21
  176. package/src/cli/commands/query/dev.localdb.query.cli-command.impl.consumer.ts +0 -69
  177. package/src/cli/commands/status/dev.localdb.status.cli-command.descriptor.ts +0 -20
  178. package/src/cli/commands/status/dev.localdb.status.cli-command.impl.consumer.ts +0 -93
  179. package/src/core/connection-pool.ts +0 -240
  180. package/src/core/database.ts +0 -419
  181. package/src/core/error-context.ts +0 -19
  182. package/src/core/migration-registry.ts +0 -321
  183. package/src/core/runtime.ts +0 -17
  184. package/src/dev.localdb.cli-group.descriptor.ts +0 -20
  185. package/src/dev.localdb.context.descriptor.ts +0 -13
  186. package/src/dev.localdb.context.impl.consumer.ts +0 -12
  187. package/src/index.ts +0 -28
  188. package/src/localdb.cloud.plugin.ts +0 -24
  189. package/src/localdb.plugin.ts +0 -43
  190. package/src/migrations/atlas/001-initial-schema.sql +0 -173
  191. package/src/migrations/atlas/002-lang-server-fields.sql +0 -12
  192. package/src/migrations/atlas/003-config-id-dedup.sql +0 -31
  193. package/src/migrations/atlas/004-fix-on-conflict-constraints.sql +0 -25
  194. package/src/migrations/atlas/005-diagnostics.sql +0 -66
  195. package/src/migrations/atlas/006-diagnostic-summaries.sql +0 -65
  196. package/src/migrations/atlas/007-diagnostic-slice.sql +0 -83
  197. package/src/migrations/load-sql.ts +0 -133
  198. package/src/schemas/api.ts +0 -92
@@ -7,140 +7,534 @@
7
7
  * Features:
8
8
  * - Singleton pattern per database path
9
9
  * - Automatic WAL mode and busy timeout configuration
10
+ * - A bounded WAL (D-WAL, see `wal-policy.ts`): `journal_size_limit`; one
11
+ * non-blocking checkpoint attempt per idle window (TRUNCATE, falling back to
12
+ * RESTART after a busy attempt) with busy attempts backing off by whole
13
+ * windows; an attempt on release to zero references and at close; observed
14
+ * `(busy, log, checkpointed)` results with busy outcomes logged; and a bound
15
+ * on how long a transaction may stay open
10
16
  * - Connection reuse across consumers
11
- * - Safe cleanup on process exit
17
+ * - Slow-statement reporting: a statement at or over `SLOW_STATEMENT_MS` on any
18
+ * pooled connection is recorded with its operation and table, and the tick
19
+ * reports it (see `slow-statement.ts`) — value-free, and never from the
20
+ * statement's own path
21
+ * - Explicit, host-owned cleanup
12
22
  * - Connection statistics and monitoring
23
+ *
24
+ * The pool never deletes a raw `-wal` or `-shm` file.
25
+ *
26
+ * Hosts sharing one pool have at most one connection per path: two connections to one
27
+ * file on the same thread can wait on each other inside SQLite's synchronous
28
+ * busy handler. A composition root that boots several runtimes in one process
29
+ * creates the pool explicitly (`createConnectionPool`) and hands it to every
30
+ * runtime it boots (`shareConnectionPool`), so those runtimes — including
31
+ * reload-generation forks, which inherit the registration — share the one
32
+ * connection per path. A process that boots a single runtime uses the
33
+ * runtime-owned default the LocalDB plugin registers instead. Each call
34
+ * reports on the runtime it was handed (C083a), never on an ambient default.
35
+ *
36
+ * Maintenance is owned by a runtime lifecycle (RC07). A connection records the
37
+ * runtimes that acquired it and runs its maintenance tick on the lifecycle of
38
+ * one of them that is live and persistent; when that lifecycle ends, the tick
39
+ * ends with it and moves to the next live persistent holder. The reload
40
+ * generation dispatcher reads whichever generation serves: it stays a holder
41
+ * while it serves a live one, so its tick follows it from a retiring
42
+ * generation to the next. A connection no persistent runtime
43
+ * holds gets no tick: it checkpoints on release to zero references and on
44
+ * close/shutdown instead, until a persistent runtime acquires it again.
13
45
  */
14
- import { Database } from 'bun:sqlite';
46
+ import { CapabilityError, createRuntimeKind } from '@vibesdotdev/kernel';
15
47
  import { getLogger } from '@vibesdotdev/logging';
16
- import { toErrorContext } from './error-context.js';
17
- const logger = getLogger('localdb:pool');
18
- class SQLiteConnectionPool {
48
+ import { PoolDescriptorSchema } from "../schemas/pool.js";
49
+ export { PoolDescriptorSchema } from "../schemas/pool.js";
50
+ import { sqliteDriverOf } from "./host-kinds.js";
51
+ import { assertOpenedConnectionLive, openConfiguredDatabase } from "./open-connection.js";
52
+ import { createSlowStatementRecorder, instrumentConnection, slowStatementFields, SLOW_STATEMENT_MS } from "./slow-statement.js";
53
+ import { assertRuntimeLive } from "./live-runtime.js";
54
+ import { releasesHolder } from "./holders.js";
55
+ import { attempt } from "./outcome.js";
56
+ import { applyLocalDatabasePragmas, checkpointBackoffWindows, checkpointWal, MAX_TRANSACTION_AGE_MS, nextCheckpointMode, WAL_BUSY_STARVATION_THRESHOLD, WAL_MAINTENANCE_INTERVAL_MS } from "./wal-policy.js";
57
+ /** The pool's Logging on the runtime one call serves (C083a). */
58
+ function poolLogger(runtime) {
59
+ return getLogger('localdb:pool', runtime);
60
+ }
61
+ export class SQLiteConnectionPool {
19
62
  connections = new Map();
20
- cleanupInterval;
63
+ /** Physical path custody spans every runtime-local manager using this pool. */
64
+ databasePathLocks = new Map();
21
65
  STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
22
- CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
23
- constructor() {
24
- // Periodic cleanup of stale connections
25
- this.startCleanup();
26
- // Ensure proper cleanup on process exit
27
- this.registerExitHandlers();
66
+ /** Opens in flight, by path: concurrent first acquisitions share one connection. */
67
+ opening = new Map();
68
+ /** Serialize managed initialization, migration, release and reset against the same physical file. */
69
+ async withDatabasePathLock(dbPath, task) {
70
+ const prior = this.databasePathLocks.get(dbPath) ?? Promise.resolve();
71
+ let release;
72
+ const current = new Promise(resolve => { release = resolve; });
73
+ const queued = prior.then(() => current);
74
+ this.databasePathLocks.set(dbPath, queued);
75
+ await prior;
76
+ try {
77
+ return await task();
78
+ }
79
+ finally {
80
+ release?.();
81
+ if (this.databasePathLocks.get(dbPath) === queued)
82
+ this.databasePathLocks.delete(dbPath);
83
+ }
28
84
  }
29
85
  /**
30
- * Get or create a database connection with optimal configuration
86
+ * Get or create a database connection with optimal configuration, opened
87
+ * through the SQLite driver the calling runtime's host registered. The
88
+ * calling runtime becomes a holder of the connection and reports the open.
89
+ * A runtime whose disposal began is refused before any connection opens
90
+ * (`CANCELLED` `runtime-disposed`), and again if it began while the open
91
+ * was in flight. Cancellation before publication closes the startup handle.
31
92
  */
32
- getConnection(dbPath, consumer = 'unknown') {
93
+ async getConnection(runtime, dbPath, consumer = 'unknown') {
94
+ const lifecycle = runtime.lifecycle;
95
+ assertRuntimeLive(runtime, 'connection', lifecycle);
33
96
  let pooled = this.connections.get(dbPath);
97
+ let created = false;
34
98
  if (!pooled) {
35
- const db = new Database(dbPath);
36
- // Configure for optimal concurrent access
37
- this.configureDatabase(db);
38
- pooled = {
39
- db,
40
- refCount: 0,
41
- lastUsed: Date.now(),
42
- createdAt: Date.now(),
43
- consumer
44
- };
45
- this.connections.set(dbPath, pooled);
46
- logger.debug(`Created connection: ${dbPath} (${consumer})`);
99
+ const pending = this.opening.get(dbPath);
100
+ if (pending) {
101
+ pooled = await pending;
102
+ }
103
+ else {
104
+ const opening = this.open(runtime, dbPath, consumer);
105
+ this.opening.set(dbPath, opening);
106
+ try {
107
+ pooled = await opening;
108
+ }
109
+ finally {
110
+ this.opening.delete(dbPath);
111
+ }
112
+ created = true;
113
+ }
114
+ assertRuntimeLive(runtime, 'connection', lifecycle);
115
+ // Disposal may have swept an unheld open while this borrower awaited it.
116
+ // A live borrower acquires the current physical handle rather than a closed one.
117
+ if (this.connections.get(dbPath) !== pooled)
118
+ return this.getConnection(runtime, dbPath, consumer);
47
119
  }
120
+ this.hold(runtime, dbPath, pooled);
48
121
  pooled.refCount++;
49
122
  pooled.lastUsed = Date.now();
123
+ if (created)
124
+ poolLogger(runtime).debug(`Created connection: ${dbPath} (${consumer})`);
50
125
  return pooled.db;
51
126
  }
127
+ /** Open and pool the connection at `dbPath` with the LocalDB pragmas, through the runtime's driver. */
128
+ async open(runtime, dbPath, consumer) {
129
+ const lifecycle = runtime.lifecycle;
130
+ const driver = await sqliteDriverOf(runtime);
131
+ const opened = await openConfiguredDatabase(runtime, () => driver.open(dbPath), applyLocalDatabasePragmas, lifecycle);
132
+ const slowStatements = createSlowStatementRecorder();
133
+ // Timed here, at the one place every statement in this process reaches, so
134
+ // the report names the consumer that opened the connection and the table
135
+ // that statement touched. Reported by the tick, never from here.
136
+ const db = instrumentConnection(opened, slowStatements);
137
+ const pooled = {
138
+ db,
139
+ slowStatements,
140
+ refCount: 0,
141
+ lastUsed: Date.now(),
142
+ createdAt: Date.now(),
143
+ consumer,
144
+ holders: new Set(),
145
+ consecutiveCheckpointBusy: 0,
146
+ nextCheckpointMode: 'TRUNCATE',
147
+ nextIdleCheckpointAt: Number.NEGATIVE_INFINITY
148
+ };
149
+ // Publication is synchronous with the captured-lifecycle check. A cancelled
150
+ // startup closes here and can never publish an unheld physical connection.
151
+ assertOpenedConnectionLive(runtime, opened, lifecycle);
152
+ this.connections.set(dbPath, pooled);
153
+ return pooled;
154
+ }
52
155
  /**
53
- * Configure database with optimal settings for concurrent access
156
+ * Record `runtime` as a holder of the pooled connection at `dbPath`, so its
157
+ * maintenance can run on (or move to) that runtime. No connection there:
158
+ * nothing to hold.
54
159
  */
55
- configureDatabase(db) {
56
- // WAL mode: Readers don't block writers, writers don't block readers
57
- db.exec('PRAGMA journal_mode = WAL;');
58
- // Busy timeout: Wait up to 5 seconds for locks instead of failing immediately
59
- db.exec('PRAGMA busy_timeout = 5000;');
60
- // Foreign keys: Enable referential integrity
61
- db.exec('PRAGMA foreign_keys = ON;');
62
- // Synchronous: NORMAL is safe with WAL and much faster
63
- db.exec('PRAGMA synchronous = NORMAL;');
64
- // Temp store: Use memory for temp tables (faster)
65
- db.exec('PRAGMA temp_store = MEMORY;');
160
+ retain(runtime, dbPath) {
161
+ const pooled = this.connections.get(dbPath);
162
+ if (pooled)
163
+ this.hold(runtime, dbPath, pooled);
164
+ }
165
+ hold(runtime, dbPath, pooled) {
166
+ // A runtime that ended holds nothing: drop it so a long-lived connection
167
+ // never retains the runtimes that passed through it.
168
+ for (const holder of pooled.holders) {
169
+ if (holder.lifecycle.signal.aborted)
170
+ pooled.holders.delete(holder);
171
+ }
172
+ pooled.holders.add(runtime);
173
+ this.ensureMaintenance(dbPath, pooled);
174
+ }
175
+ /**
176
+ * Keep the connection's tick on a live persistent holder. A tick whose
177
+ * lifecycle (the one it started on) is still live, on a runtime that still
178
+ * holds the connection, stays where it is;
179
+ * otherwise the first live persistent holder takes it, and holders that
180
+ * ended are dropped.
181
+ */
182
+ ensureMaintenance(dbPath, pooled) {
183
+ const current = pooled.maintenance;
184
+ // A live tick stays where it is while its runtime still holds the connection.
185
+ if (current && !current.signal.aborted && pooled.holders.has(current.runtime))
186
+ return;
187
+ current?.stop();
188
+ pooled.maintenance = undefined;
189
+ for (const holder of pooled.holders) {
190
+ if (holder.lifecycle.signal.aborted) {
191
+ pooled.holders.delete(holder);
192
+ continue;
193
+ }
194
+ // A finite host starts no background work (`lifecycle.interval` refuses it).
195
+ if (holder.scope.lifecycle === 'finite')
196
+ continue;
197
+ pooled.maintenance = this.maintainOn(holder, dbPath, pooled);
198
+ return;
199
+ }
200
+ }
201
+ /**
202
+ * Run the connection's tick on the lifecycle `runtime` reads now. The tick
203
+ * ends when that lifecycle ends (it clears every interval). At that moment
204
+ * the runtime is dropped as a holder unless it now reads a live lifecycle
205
+ * (the reload generation dispatcher, whose `lifecycle` follows whichever
206
+ * generation serves), and the tick moves to the next live persistent
207
+ * holder, that runtime included.
208
+ */
209
+ maintainOn(runtime, dbPath, pooled) {
210
+ const { lifecycle } = runtime;
211
+ const { signal } = lifecycle;
212
+ const stopTick = lifecycle.interval(() => this.tick(runtime, dbPath, pooled), WAL_MAINTENANCE_INTERVAL_MS);
213
+ const moveOn = () => {
214
+ // ensureMaintenance drops the holders that ended (a runtime whose lifecycle follows
215
+ // another, the dispatcher, still holds while it reads a live one) and moves the tick on.
216
+ if (this.connections.get(dbPath) === pooled)
217
+ this.ensureMaintenance(dbPath, pooled);
218
+ };
219
+ signal.addEventListener('abort', moveOn, { once: true });
220
+ return {
221
+ runtime,
222
+ signal,
223
+ stop: () => {
224
+ stopTick();
225
+ signal.removeEventListener('abort', moveOn);
226
+ }
227
+ };
228
+ }
229
+ /** One maintenance tick for one connection. It never throws into the timer: a failure is logged with its cause. */
230
+ tick(runtime, dbPath, pooled) {
231
+ const pass = attempt(() => this.maintainConnection(runtime, dbPath, pooled, Date.now()));
232
+ if (pass.failed) {
233
+ poolLogger(runtime).error('[LocalDB] Pool maintenance tick failed', {
234
+ reason: 'localdb-maintenance-failed',
235
+ dbPath,
236
+ consumer: pooled.consumer,
237
+ error: pass.cause
238
+ });
239
+ }
66
240
  }
67
241
  /**
68
242
  * Release a connection (decrement reference count)
69
243
  * Connection remains in pool for reuse
70
244
  */
71
- releaseConnection(dbPath) {
245
+ releaseConnection(runtime, dbPath) {
72
246
  const pooled = this.connections.get(dbPath);
73
247
  if (pooled) {
74
- pooled.refCount = Math.max(0, pooled.refCount - 1);
75
- pooled.lastUsed = Date.now();
248
+ const refCount = Math.max(0, pooled.refCount - 1);
249
+ const lastUsed = Date.now();
250
+ // Last reference gone: the WAL does not self-checkpoint when holders
251
+ // reach zero, so attempt a reset now — unless the connection is
252
+ // backing off after busy attempts; its next idle window will try.
253
+ if (refCount === 0 &&
254
+ !pooled.db.inTransaction &&
255
+ lastUsed >= pooled.nextIdleCheckpointAt) {
256
+ this.observeCheckpoint(runtime, dbPath, pooled, 'release', lastUsed);
257
+ }
258
+ // Commit the reference release only after its checkpoint/reporting work succeeds.
259
+ pooled.refCount = refCount;
260
+ pooled.lastUsed = lastUsed;
76
261
  }
77
262
  }
78
263
  /**
79
- * Close a specific connection immediately
80
- * Only closes if reference count is zero (unless forced)
264
+ * Run one observed, non-blocking checkpoint attempt and record its outcome,
265
+ * reported on `runtime`. A busy attempt is logged at warn (a run of
266
+ * {@link WAL_BUSY_STARVATION_THRESHOLD} at error, as starvation), switches
267
+ * the next attempt's mode, and backs the connection off by whole windows. A
268
+ * checkpoint SQLite itself rejects is logged at error with its cause and
269
+ * never blocks the caller's close or tick.
81
270
  */
82
- closeConnection(dbPath, force = false) {
83
- const pooled = this.connections.get(dbPath);
84
- if (!pooled)
85
- return false;
86
- if (force || pooled.refCount === 0) {
87
- try {
88
- pooled.db.close();
89
- this.connections.delete(dbPath);
90
- // Use stderr to avoid breaking MCP stdio protocol
91
- logger.debug(`[LocalDB] Closed connection: ${dbPath}`);
92
- return true;
271
+ observeCheckpoint(runtime, dbPath, pooled, trigger, now) {
272
+ const logger = poolLogger(runtime);
273
+ const attempted = attempt(() => checkpointWal(pooled.db, pooled.nextCheckpointMode));
274
+ if (attempted.failed) {
275
+ logger.error('[LocalDB] WAL checkpoint failed', {
276
+ reason: 'wal-checkpoint-failed',
277
+ dbPath,
278
+ trigger,
279
+ consumer: pooled.consumer,
280
+ error: attempted.cause
281
+ });
282
+ return;
283
+ }
284
+ const outcome = attempted.value;
285
+ pooled.lastCheckpoint = { ...outcome, at: now, trigger };
286
+ const { busy, log, checkpointed, mode } = outcome;
287
+ pooled.nextCheckpointMode = nextCheckpointMode(mode, outcome.status);
288
+ const fields = { dbPath, trigger, consumer: pooled.consumer, mode, busy, log, checkpointed };
289
+ if (outcome.status === 'busy') {
290
+ pooled.consecutiveCheckpointBusy++;
291
+ const consecutive = pooled.consecutiveCheckpointBusy;
292
+ pooled.nextIdleCheckpointAt = now + checkpointBackoffWindows(consecutive) * WAL_MAINTENANCE_INTERVAL_MS;
293
+ if (consecutive >= WAL_BUSY_STARVATION_THRESHOLD) {
294
+ logger.error('[LocalDB] WAL checkpoint starved: another holder keeps frames pinned', {
295
+ reason: 'wal-checkpoint-starved',
296
+ consecutiveBusy: consecutive,
297
+ ...fields
298
+ });
93
299
  }
94
- catch (error) {
95
- logger.error(`[LocalDB] Error closing ${dbPath}:`, toErrorContext(error));
96
- return false;
300
+ else {
301
+ // Every frame copied back and only the truncate/restart was blocked by another
302
+ // process's reader: routine on a shared file. Frames left behind are worth a warning.
303
+ const report = checkpointed >= log ? logger.debug.bind(logger) : logger.warn.bind(logger);
304
+ report('[LocalDB] WAL checkpoint busy', {
305
+ reason: 'wal-checkpoint-busy',
306
+ consecutiveBusy: consecutive,
307
+ ...fields
308
+ });
97
309
  }
98
310
  }
311
+ else {
312
+ pooled.consecutiveCheckpointBusy = 0;
313
+ pooled.nextIdleCheckpointAt = Number.NEGATIVE_INFINITY;
314
+ logger.debug('[LocalDB] WAL checkpoint', { status: outcome.status, ...fields });
315
+ }
316
+ }
317
+ /**
318
+ * Bound how long a pooled connection may sit inside a transaction. SQLite
319
+ * is synchronous, so a transaction a tick can observe spans an `await`; one
320
+ * older than {@link MAX_TRANSACTION_AGE_MS} pins every WAL frame written
321
+ * after it began, in every process. It is rolled back and reported; the
322
+ * owner's later COMMIT then fails loudly rather than the WAL growing silently.
323
+ *
324
+ * @returns whether the connection is still inside a transaction.
325
+ */
326
+ enforceTransactionBound(runtime, dbPath, pooled, now) {
327
+ if (!pooled.db.inTransaction) {
328
+ pooled.transactionObservedAt = undefined;
329
+ return false;
330
+ }
331
+ pooled.transactionObservedAt ??= now;
332
+ const ageMs = now - pooled.transactionObservedAt;
333
+ if (ageMs < MAX_TRANSACTION_AGE_MS)
334
+ return true;
335
+ const logger = poolLogger(runtime);
336
+ const rolledBack = attempt(() => pooled.db.exec('ROLLBACK;'));
337
+ if (rolledBack.failed) {
338
+ logger.error('[LocalDB] Failed to roll back an expired transaction', {
339
+ reason: 'localdb-transaction-expiry-failed',
340
+ dbPath,
341
+ consumer: pooled.consumer,
342
+ observedAgeMs: ageMs,
343
+ error: rolledBack.cause
344
+ });
345
+ return true;
346
+ }
347
+ pooled.transactionObservedAt = undefined;
348
+ logger.error('[LocalDB] Rolled back a transaction held open past its bound', {
349
+ reason: 'localdb-transaction-expired',
350
+ dbPath,
351
+ consumer: pooled.consumer,
352
+ observedAgeMs: ageMs,
353
+ maxTransactionAgeMs: MAX_TRANSACTION_AGE_MS
354
+ });
99
355
  return false;
100
356
  }
101
357
  /**
102
- * Clean up stale connections (unused for 30+ minutes with zero references)
358
+ * Report the statements this window recorded as slow, on `runtime` and once
359
+ * per connection. Each report carries the connection's consumer and database
360
+ * with the statement's operation and table; a window that recorded more than
361
+ * it kept says so rather than reading as a complete count.
103
362
  */
104
- cleanupStale() {
105
- const now = Date.now();
106
- for (const [dbPath, pooled] of this.connections.entries()) {
107
- const age = now - pooled.lastUsed;
108
- if (pooled.refCount === 0 && age > this.STALE_THRESHOLD_MS) {
109
- this.closeConnection(dbPath, true);
363
+ reportSlowStatements(runtime, dbPath, pooled) {
364
+ const { reports, dropped } = pooled.slowStatements.drain();
365
+ if (reports.length === 0 && dropped === 0)
366
+ return;
367
+ poolLogger(runtime).warn('[LocalDB] Slow statements held the thread', {
368
+ reason: 'localdb-slow-statement',
369
+ dbPath,
370
+ consumer: pooled.consumer,
371
+ thresholdMs: SLOW_STATEMENT_MS,
372
+ dropped,
373
+ statements: reports.map(slowStatementFields)
374
+ });
375
+ }
376
+ /**
377
+ * One idle window for one connection: report what it recorded as slow, bound
378
+ * an open transaction, close the connection when it has been unused for 30+
379
+ * minutes with zero references, or make at most one observed checkpoint
380
+ * attempt when it is not inside a transaction and not backing off. Every step
381
+ * reports its own failure.
382
+ */
383
+ maintainConnection(runtime, dbPath, pooled, now) {
384
+ // First, so a window that returns early below still reports what it saw.
385
+ this.reportSlowStatements(runtime, dbPath, pooled);
386
+ const inTransaction = this.enforceTransactionBound(runtime, dbPath, pooled, now);
387
+ if (inTransaction)
388
+ return;
389
+ if (pooled.refCount === 0 && now - pooled.lastUsed > this.STALE_THRESHOLD_MS) {
390
+ // The close runs its own observed checkpoint.
391
+ const closed = attempt(() => this.closePooled(runtime, dbPath, pooled, 'close'));
392
+ if (closed.failed) {
393
+ poolLogger(runtime).error('[LocalDB] Failed to close a stale connection', {
394
+ reason: 'localdb-stale-close-failed',
395
+ dbPath,
396
+ consumer: pooled.consumer,
397
+ error: closed.cause
398
+ });
110
399
  }
400
+ return;
111
401
  }
402
+ if (now < pooled.nextIdleCheckpointAt)
403
+ return;
404
+ this.observeCheckpoint(runtime, dbPath, pooled, 'idle', now);
112
405
  }
113
406
  /**
114
- * Start periodic cleanup
407
+ * One maintenance pass over every pooled connection, reported on `runtime`.
408
+ * Each connection's lifecycle tick runs the same window for that connection
409
+ * alone; hosts and tests may call this directly.
115
410
  */
116
- startCleanup() {
117
- this.cleanupInterval = setInterval(() => this.cleanupStale(), this.CLEANUP_INTERVAL_MS);
411
+ maintain(runtime, now = Date.now()) {
412
+ for (const [dbPath, pooled] of this.connections)
413
+ this.maintainConnection(runtime, dbPath, pooled, now);
118
414
  }
119
415
  /**
120
- * Stop periodic cleanup
416
+ * Close one pooled connection and remove it from the pool, reported on
417
+ * `runtime`. A connection still inside a transaction is closed without a
418
+ * checkpoint, and SQLite discards that transaction: the discard is
419
+ * reported, never silent. A close SQLite refuses throws
420
+ * `localdb-close-failed` (the original error as `cause`) and the connection
421
+ * stays pooled, still maintained, for a retry.
121
422
  */
122
- stopCleanup() {
123
- if (this.cleanupInterval) {
124
- clearInterval(this.cleanupInterval);
125
- this.cleanupInterval = undefined;
423
+ closePooled(runtime, dbPath, pooled, trigger) {
424
+ const logger = poolLogger(runtime);
425
+ if (pooled.db.inTransaction) {
426
+ logger.error('[LocalDB] Closing a connection inside an open transaction discards that transaction', {
427
+ reason: 'localdb-transaction-discarded',
428
+ dbPath,
429
+ trigger,
430
+ consumer: pooled.consumer
431
+ });
126
432
  }
433
+ else {
434
+ // Observed and logged; a busy or failed checkpoint never blocks close.
435
+ this.observeCheckpoint(runtime, dbPath, pooled, trigger, Date.now());
436
+ }
437
+ const closed = attempt(() => pooled.db.close());
438
+ if (closed.failed) {
439
+ throw new CapabilityError(`Failed to close LocalDB connection '${dbPath}'`, 'INTERNAL', {
440
+ reason: 'localdb-close-failed',
441
+ retryable: true,
442
+ details: { dbPath, trigger },
443
+ cause: closed.cause
444
+ });
445
+ }
446
+ pooled.maintenance?.stop();
447
+ pooled.maintenance = undefined;
448
+ pooled.holders.clear();
449
+ this.connections.delete(dbPath);
450
+ logger.debug(`[LocalDB] Closed connection (${trigger}): ${dbPath}`);
451
+ }
452
+ /**
453
+ * Refuse destructive replacement while another lifecycle or pooled reference
454
+ * holds the file. Only the requesting instance's actual open connection may
455
+ * account for one reference; legacy/raw borrowers keep their custody too.
456
+ * Call under the database path lock, before releasing or closing anything.
457
+ */
458
+ assertCanReplaceDatabase(runtime, dbPath, ownedConnection) {
459
+ const pooled = this.connections.get(dbPath);
460
+ if (!pooled)
461
+ return;
462
+ const signal = runtime.lifecycle.signal;
463
+ const foreignHolders = [...pooled.holders].filter(holder => !holder.lifecycle.signal.aborted && holder.lifecycle.signal !== signal).length;
464
+ const ownedReferences = pooled.db === ownedConnection ? 1 : 0;
465
+ if (foreignHolders === 0 && pooled.refCount <= ownedReferences)
466
+ return;
467
+ throw new CapabilityError('LocalDB cannot replace a database with outstanding borrowers.', 'CONFLICT', {
468
+ reason: 'localdb-database-in-use',
469
+ retryable: false,
470
+ details: { dbPath, foreignHolders, references: pooled.refCount, ownedReferences },
471
+ recoveryHint: 'Release the other database borrowers before requesting reset or recovery.'
472
+ });
473
+ }
474
+ /**
475
+ * Give back one pooled reference no live runtime holds: every recorded
476
+ * holder ended (or none is recorded) while the reference count is still
477
+ * nonzero — typically a client of a runtime that ended without releasing.
478
+ * Returns whether a reference was given back. A connection another live
479
+ * runtime still holds is never touched.
480
+ */
481
+ releaseOrphanedConnection(dbPath) {
482
+ const pooled = this.connections.get(dbPath);
483
+ if (!pooled || pooled.refCount === 0)
484
+ return false;
485
+ if ([...pooled.holders].some((holder) => !holder.lifecycle.signal.aborted))
486
+ return false;
487
+ pooled.refCount = Math.max(0, pooled.refCount - 1);
488
+ pooled.lastUsed = Date.now();
489
+ return true;
127
490
  }
128
491
  /**
129
- * Close all connections (typically on shutdown)
492
+ * Close a specific connection immediately. Only closes if the reference
493
+ * count is zero, unless forced. Returns whether it closed; a close SQLite
494
+ * refuses throws `localdb-close-failed` and leaves the connection pooled.
130
495
  */
131
- closeAll() {
132
- this.stopCleanup();
133
- for (const [dbPath, pooled] of this.connections.entries()) {
134
- try {
135
- pooled.db.close();
136
- // Use stderr to avoid breaking MCP stdio protocol
137
- logger.debug(`[LocalDB] Closed connection on shutdown: ${dbPath}`);
496
+ closeConnection(runtime, dbPath, force = false) {
497
+ const pooled = this.connections.get(dbPath);
498
+ if (!pooled)
499
+ return false;
500
+ if (!force && pooled.refCount > 0)
501
+ return false;
502
+ this.closePooled(runtime, dbPath, pooled, 'close');
503
+ return true;
504
+ }
505
+ /**
506
+ * Give back every connection `runtime` holds (typically at its disposal),
507
+ * reported on `runtime`; runtimes that ended hold nothing. A connection
508
+ * another live runtime still holds (another root, or the root a disposing
509
+ * reload generation was forked from) stays pooled for it, its maintenance moving to that
510
+ * holder; every other connection closes. Every close is attempted; the ones
511
+ * SQLite refuses stay pooled and are reported together in one AggregateError
512
+ * so the runtime owner can report them and retry.
513
+ */
514
+ closeAll(runtime, retainedPaths = new Set()) {
515
+ const failures = [];
516
+ for (const [dbPath, pooled] of this.connections) {
517
+ if (retainedPaths.has(dbPath))
518
+ continue;
519
+ for (const holder of pooled.holders) {
520
+ if (releasesHolder(holder, runtime) || holder.lifecycle.signal.aborted)
521
+ pooled.holders.delete(holder);
138
522
  }
139
- catch (error) {
140
- logger.error(`[LocalDB] Error closing ${dbPath}:`, toErrorContext(error));
523
+ if (pooled.holders.size > 0) {
524
+ this.ensureMaintenance(dbPath, pooled);
525
+ continue;
141
526
  }
527
+ const closed = attempt(() => this.closePooled(runtime, dbPath, pooled, 'shutdown'));
528
+ if (closed.failed)
529
+ failures.push(closed.cause);
142
530
  }
143
- this.connections.clear();
531
+ if (failures.length > 0) {
532
+ throw new AggregateError(failures, `Failed to close ${failures.length} LocalDB connection${failures.length === 1 ? '' : 's'}`);
533
+ }
534
+ }
535
+ /** Whether a managed handle still names the pooled connection at this path. */
536
+ ownsConnection(dbPath, db) {
537
+ return this.connections.get(dbPath)?.db === db;
144
538
  }
145
539
  /**
146
540
  * Get pool statistics
@@ -150,19 +544,35 @@ class SQLiteConnectionPool {
150
544
  let idle = 0;
151
545
  const connections = [];
152
546
  const now = Date.now();
153
- for (const [path, pooled] of this.connections.entries()) {
547
+ for (const [path, pooled] of this.connections) {
154
548
  if (pooled.refCount > 0) {
155
549
  active++;
156
550
  }
157
551
  else {
158
552
  idle++;
159
553
  }
160
- connections.push({
554
+ const entry = {
161
555
  path,
162
556
  refCount: pooled.refCount,
163
557
  ageMs: now - pooled.createdAt,
164
- consumer: pooled.consumer
165
- });
558
+ consumer: pooled.consumer,
559
+ maintained: pooled.maintenance !== undefined,
560
+ holders: pooled.holders.size,
561
+ consecutiveCheckpointBusy: pooled.consecutiveCheckpointBusy
562
+ };
563
+ const last = pooled.lastCheckpoint;
564
+ if (last) {
565
+ entry.lastCheckpoint = {
566
+ at: last.at,
567
+ trigger: last.trigger,
568
+ status: last.status,
569
+ mode: last.mode,
570
+ busy: last.busy,
571
+ log: last.log,
572
+ checkpointed: last.checkpointed
573
+ };
574
+ }
575
+ connections.push(entry);
166
576
  }
167
577
  return {
168
578
  total: this.connections.size,
@@ -171,21 +581,120 @@ class SQLiteConnectionPool {
171
581
  connections
172
582
  };
173
583
  }
174
- /**
175
- * Register process exit handlers for cleanup
176
- */
177
- registerExitHandlers() {
178
- process.on('beforeExit', () => this.closeAll());
179
- process.on('SIGINT', () => {
180
- this.closeAll();
181
- process.exit(0);
182
- });
183
- process.on('SIGTERM', () => {
184
- this.closeAll();
185
- process.exit(0);
584
+ }
585
+ /**
586
+ * The `localdb/pool` kind: the process's physical SQLite connections, owned
587
+ * through the kernel. The pool holds live host handles, so — unlike migration
588
+ * declarations, which each runtime owns separately — runtimes that must open
589
+ * the same file share one pool instance resolved from the runtime they are
590
+ * handed. No module-level pool instance exists: every instance is created
591
+ * explicitly (`createConnectionPool`, the plugin default, or a shared owner a
592
+ * composition root registers) and resolved through the kernel below.
593
+ */
594
+ export const LOCALDB_POOL_KIND = 'localdb/pool';
595
+ /** The runtime-owned default pool, created per root runtime by the LocalDB plugin. */
596
+ export const DEFAULT_POOL_ID = 'pool';
597
+ /**
598
+ * An explicitly shared pool, handed to each runtime by the composition root
599
+ * that boots them. Preferred over the default whenever it is registered.
600
+ */
601
+ export const SHARED_POOL_ID = 'shared';
602
+ /** The kind LocalDB registers; an implementation that is not a pool is refused `implementation-invalid`. */
603
+ export const poolKind = createRuntimeKind({
604
+ id: LOCALDB_POOL_KIND,
605
+ descriptorSchema: PoolDescriptorSchema,
606
+ validateImplementation: (impl) => impl instanceof SQLiteConnectionPool
607
+ });
608
+ /**
609
+ * Create the physical connection pool a composition root hands to the runtimes
610
+ * it boots. One pool per process per database set: every runtime handed the
611
+ * same pool shares the one connection per path.
612
+ */
613
+ export function createConnectionPool() {
614
+ return new SQLiteConnectionPool();
615
+ }
616
+ /** `UNAVAILABLE localdb-pool-unavailable`: the runtime registered no connection pool. */
617
+ function poolUnavailable() {
618
+ return new CapabilityError('This runtime registered no LocalDB connection pool (localdb/pool).', 'UNAVAILABLE', {
619
+ reason: 'localdb-pool-unavailable',
620
+ retryable: false,
621
+ recoveryHint: 'Compose the LocalDB plugin, which registers a runtime-owned default pool.'
622
+ });
623
+ }
624
+ /**
625
+ * Resolve the runtime's physical connection pool through the kernel on every
626
+ * call, so current selection, invalidation, and implementation caching remain
627
+ * owned by the runtime: an explicitly shared pool when the composition root
628
+ * registered one, else the runtime-owned default the LocalDB plugin
629
+ * registered. Anything else (a refused loader, an invalid implementation)
630
+ * propagates unwrapped.
631
+ */
632
+ export async function poolOf(runtime) {
633
+ if (!runtime.hasKind(LOCALDB_POOL_KIND))
634
+ throw poolUnavailable();
635
+ // SAFETY: kernel RuntimeRef.query returns AssetQuery; its minimal plugin port omits the public sortBy method.
636
+ const selected = await runtime.query(LOCALDB_POOL_KIND)
637
+ .sortBy('priority', 'desc').select();
638
+ if (!selected)
639
+ throw poolUnavailable();
640
+ return selected.impl;
641
+ }
642
+ /**
643
+ * The already-resolved pool, or `undefined` when no resolution warmed the
644
+ * kernel cache yet. Synchronous cover for call sites that hold no async
645
+ * context after an async call resolved the pool first.
646
+ */
647
+ export function tryPoolOf(runtime) {
648
+ if (!runtime.hasKind(LOCALDB_POOL_KIND))
649
+ return undefined;
650
+ // SAFETY: kernel RuntimeRef.query returns AssetQuery; its minimal plugin port omits the public sortBy method.
651
+ return runtime.query(LOCALDB_POOL_KIND).sortBy('priority', 'desc').peek?.();
652
+ }
653
+ const DEFAULT_POOL_DESCRIPTION = 'Runtime-owned default LocalDB connection pool';
654
+ const SHARED_POOL_DESCRIPTION = 'Explicitly shared LocalDB connection pool for every runtime one composition root boots';
655
+ /**
656
+ * Register the runtime-owned default pool, unless one is already registered:
657
+ * reload-generation forks inherit their root's registrations, so they keep
658
+ * sharing the root's pool instead of minting a second connection per path.
659
+ */
660
+ export function ensureDefaultPool(runtime) {
661
+ if (!runtime.hasKind(LOCALDB_POOL_KIND))
662
+ runtime.registerKind(poolKind);
663
+ if (runtime.query(LOCALDB_POOL_KIND).withId(DEFAULT_POOL_ID).descriptors().length > 0)
664
+ return;
665
+ runtime.registerDescriptor(LOCALDB_POOL_KIND, {
666
+ kind: LOCALDB_POOL_KIND,
667
+ id: DEFAULT_POOL_ID,
668
+ description: DEFAULT_POOL_DESCRIPTION
669
+ });
670
+ // The registration owns the instance: inherited loaders must return the
671
+ // same physical owner to forks, rather than construct one per client cache.
672
+ const pool = new SQLiteConnectionPool();
673
+ runtime.registerLoader(LOCALDB_POOL_KIND, DEFAULT_POOL_ID, async () => ({ impl: pool }));
674
+ }
675
+ /**
676
+ * Hand one explicitly created pool to a runtime: every runtime the
677
+ * composition root boots against the same database files shares the one
678
+ * connection per path. Fork generations inherit the registration, so they
679
+ * share it too.
680
+ */
681
+ export function shareConnectionPool(runtime, pool) {
682
+ if (!runtime.hasKind(LOCALDB_POOL_KIND))
683
+ runtime.registerKind(poolKind);
684
+ const shared = runtime.query(LOCALDB_POOL_KIND).withId(SHARED_POOL_ID);
685
+ if (shared.peek?.() === pool)
686
+ return;
687
+ if (shared.descriptors().length > 0 || tryPoolOf(runtime)) {
688
+ throw new CapabilityError('The LocalDB pool owner has already been selected.', 'CONFLICT', {
689
+ reason: 'localdb-pool-owner-selected', retryable: false,
690
+ recoveryHint: 'Register the shared owner during composition, before LocalDB activation or pool resolution.'
186
691
  });
187
692
  }
693
+ runtime.registerDescriptor(LOCALDB_POOL_KIND, {
694
+ kind: LOCALDB_POOL_KIND,
695
+ id: SHARED_POOL_ID,
696
+ description: SHARED_POOL_DESCRIPTION
697
+ });
698
+ runtime.registerLoader(LOCALDB_POOL_KIND, SHARED_POOL_ID, async () => ({ impl: pool }), { priority: 100 });
188
699
  }
189
- // Singleton instance
190
- export const connectionPool = new SQLiteConnectionPool();
191
700
  //# sourceMappingURL=connection-pool.js.map