@vibesdotdev/localdb 0.0.2 → 1.0.0-rc.3

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
@@ -5,343 +5,1077 @@
5
5
  * Provides connection pooling, migration management, and consistent configuration.
6
6
  *
7
7
  * Usage:
8
- * const db = localdb.getDatabase('atlas', '.vibes/atlas/project.db');
8
+ * const db = await localdb.getDatabase(runtime, 'atlas', '<workspace>/.vibes/atlas/<projectId>.db');
9
9
  * // db is ready to use with WAL mode, busy timeout, and migrations applied
10
+ *
11
+ * Every call that opens, registers, releases or closes takes the runtime it
12
+ * serves: that runtime's Logging reports it (C083a), never an ambient default.
10
13
  */
11
- import { Database } from 'bun:sqlite';
14
+ import { CapabilityError, CaughtFailureSchema, isCapabilityError } from '@vibesdotdev/kernel';
15
+ import { BaseRuntimeClient, getVibesClient, tryGetVibesClient, RuntimeClientDescriptorSchema } from '@vibesdotdev/kernel/client';
16
+ import { runtimeClientPlugin } from '@vibesdotdev/kernel/client/plugin';
17
+ import { releasesHolder } from "./holders.js";
12
18
  import { getLogger } from '@vibesdotdev/logging';
13
- import { connectionPool } from "./connection-pool.js";
14
- import { migrationRegistry } from "./migration-registry.js";
15
- import { toErrorContext } from './error-context.js';
16
- import { isLocaldbServerRuntime } from "./runtime.js";
17
- const logger = getLogger('localdb:database');
18
- let mkdir;
19
- let copyFile;
20
- let unlink;
21
- let readdir;
22
- let dirname;
23
- let basename;
19
+ import { assertOpenedConnectionLive, openConfiguredDatabase } from "./open-connection.js";
20
+ import { ensureDefaultPool, poolOf, tryPoolOf } from "./connection-pool.js";
21
+ import { assertRuntimeLive } from "./live-runtime.js";
22
+ import { migrationRegistry, MigrationRegistry } from "./migration-registry.js";
23
+ import { attempt, settle } from "./outcome.js";
24
+ import { applyLocalDatabasePragmas, checkpointWal } from "./wal-policy.js";
25
+ import { fileSystemOf, sqliteDriverOf } from "./host-kinds.js";
26
+ /** The manager's Logging on the runtime one call serves (C083a). */
27
+ function databaseLogger(runtime) {
28
+ return getLogger('localdb:database', runtime);
29
+ }
24
30
  const BACKUP_RETENTION_COUNT = 12;
25
- const isServerRuntime = isLocaldbServerRuntime();
26
- if (isServerRuntime) {
27
- ({ mkdir, copyFile, unlink, readdir } = await import('node:fs/promises'));
28
- ({ dirname, basename } = await import('node:path'));
31
+ const DATABASE_SIDECARS = ['-wal', '-shm'];
32
+ /**
33
+ * The backup name for `filename` archived at `now`: `<file>.bak-<ms>`, moved
34
+ * one millisecond later past any set that already holds that name, so an
35
+ * archive never replaces an earlier backup set.
36
+ */
37
+ export function nextBackupName(filename, existing, now) {
38
+ let stamp = now;
39
+ while (existing.has(`${filename}.bak-${stamp}`))
40
+ stamp += 1;
41
+ return `${filename}.bak-${stamp}`;
42
+ }
43
+ /**
44
+ * Prune `<db>.bak-*` backup SETS (main + -wal + -shm sharing one timestamp) down
45
+ * to {@link BACKUP_RETENTION_COUNT}, newest first. Grouping by timestamp ensures a
46
+ * retained WAL is never stranded without its main file. The single owned home for
47
+ * local-db backup retention — consumers must not hand-roll their own.
48
+ *
49
+ * Pruning never fails the recovery that triggered it: a backup file that cannot
50
+ * be removed is reported with its cause, and the next prune tries it again.
51
+ */
52
+ /** The entry names in `directory`, through the runtime's filesystem. */
53
+ async function entryNames(files, directory) {
54
+ return (await files.list(directory)).map((entry) => entry.name);
55
+ }
56
+ async function pruneDatabaseBackups(runtime, files, directory, filename) {
57
+ const backupPrefix = `${filename}.bak-`;
58
+ const groups = new Map();
59
+ for (const entry of await entryNames(files, directory)) {
60
+ if (!entry.startsWith(backupPrefix))
61
+ continue;
62
+ const timestamp = entry.slice(backupPrefix.length).match(/^(\d+)/)?.[1];
63
+ if (!timestamp)
64
+ continue;
65
+ const group = groups.get(timestamp) ?? [];
66
+ group.push(entry);
67
+ groups.set(timestamp, group);
68
+ }
69
+ const ordered = [...groups.entries()].sort((a, b) => Number(b[0]) - Number(a[0]));
70
+ const failures = [];
71
+ for (const [, entries] of ordered.slice(BACKUP_RETENTION_COUNT)) {
72
+ for (const entry of entries) {
73
+ const removed = await settle(files.remove(files.join(directory, entry)));
74
+ if (removed.failed)
75
+ failures.push(removed.cause);
76
+ }
77
+ }
78
+ if (failures.length > 0) {
79
+ databaseLogger(runtime).warn('[LocalDB] Failed to prune old backup files; the next prune retries them', {
80
+ reason: 'localdb-backup-prune-failed',
81
+ directory,
82
+ error: new AggregateError(failures, `Failed to remove ${failures.length} backup file(s)`)
83
+ });
84
+ }
85
+ }
86
+ /**
87
+ * Move a database file and its WAL/SHM sidecars aside as ONE timestamped backup
88
+ * set (via rename — preserves un-checkpointed WAL rows, removes the originals),
89
+ * then prune old backups. The single owned primitive for archiving a broken local
90
+ * DB; both migration recovery and {@link LocalDBManager.resetDatabase} use it so
91
+ * nobody reimplements it (e.g. a copyFile-based, WAL-dropping, unbounded variant).
92
+ *
93
+ * The directory listing is the fact it acts on: no database file means nothing
94
+ * to archive, and a sidecar moves only when it is present. A rename that fails
95
+ * for any other reason throws, because a database that cannot be moved aside
96
+ * cannot be recovered.
97
+ */
98
+ async function archiveDatabaseFiles(runtime, dbPath) {
99
+ const logger = databaseLogger(runtime);
100
+ const files = await fileSystemOf(runtime);
101
+ const directory = files.dirname(dbPath);
102
+ const filename = files.basename(dbPath);
103
+ await files.mkdir(directory, { recursive: true });
104
+ const entries = new Set(await entryNames(files, directory));
105
+ if (!entries.has(filename)) {
106
+ logger.debug('[LocalDB] No database file to archive; opening a fresh one', { dbPath });
107
+ return;
108
+ }
109
+ const backupPath = files.join(directory, nextBackupName(filename, entries, Date.now()));
110
+ await files.rename(dbPath, backupPath);
111
+ for (const sidecar of DATABASE_SIDECARS) {
112
+ if (!entries.has(`${filename}${sidecar}`))
113
+ continue;
114
+ const moved = await settle(files.rename(`${dbPath}${sidecar}`, `${backupPath}${sidecar}`));
115
+ // A sidecar SQLite removed after the listing (its last connection closed) is
116
+ // confirmed absent by listing again; anything else is a failed move.
117
+ if (moved.failed && (await entryNames(files, directory)).includes(`${filename}${sidecar}`))
118
+ throw moved.cause;
119
+ }
120
+ logger.info(`[LocalDB] Moved database (with WAL/SHM) to: ${backupPath}`);
121
+ await pruneDatabaseBackups(runtime, files, directory, filename);
29
122
  }
30
- function isFileMissingError(error) {
31
- if (!error || typeof error !== 'object')
32
- return false;
33
- return error.code === 'ENOENT';
123
+ /**
124
+ * Thrown when a database's tracked schema version is NEWER than what this
125
+ * build knows — i.e. the running code is older than the data. Destroying the
126
+ * database here would let any stale binary wipe a newer store (this happened:
127
+ * a Feb-era compiled CLI trapdoored the presence inner-world DB). Namespaces
128
+ * that hold only derived, rebuildable data may opt into auto-reset via
129
+ * `registerMigrations(runtime, ns, migrations, { autoReset: true })`.
130
+ */
131
+ export class IncompatibleDatabaseError extends CapabilityError {
132
+ namespace;
133
+ dbPath;
134
+ trackedVersion;
135
+ currentVersion;
136
+ constructor(namespace, dbPath, trackedVersion, currentVersion) {
137
+ super(`Database '${dbPath}' tracks schema version ${trackedVersion} for namespace '${namespace}', ` +
138
+ `newer than this build's max version ${currentVersion}. Refusing destructive recovery — ` +
139
+ `this process is likely running outdated code; upgrade or rebuild the binary. ` +
140
+ `(Derived-cache namespaces can opt into auto-reset via registerMigrations options.)`, 'CONFLICT', {
141
+ reason: 'localdb-incompatible-database',
142
+ retryable: false,
143
+ recoveryHint: 'Upgrade or rebuild the binary that opens this database.',
144
+ details: { namespace, dbPath, trackedVersion, currentVersion }
145
+ });
146
+ this.namespace = namespace;
147
+ this.dbPath = dbPath;
148
+ this.trackedVersion = trackedVersion;
149
+ this.currentVersion = currentVersion;
150
+ this.name = 'IncompatibleDatabaseError';
151
+ }
152
+ }
153
+ /**
154
+ * `UNAVAILABLE localdb-database-closed`: the handle was never initialized, or
155
+ * it closed (every runtime holding it released it, or ended and a disposal closed it).
156
+ */
157
+ function notInitialized(dbPath) {
158
+ return new CapabilityError('The LocalDB database is not open: initialize it, or open it again.', 'UNAVAILABLE', {
159
+ reason: 'localdb-database-closed',
160
+ retryable: false,
161
+ details: { dbPath },
162
+ recoveryHint: 'Open the database again with localdb.getDatabase() on a live runtime.'
163
+ });
164
+ }
165
+ /**
166
+ * `UNAVAILABLE localdb-pool-unavailable`: a pooled call before any pool was
167
+ * handed over — neither the constructor's pool nor a runtime resolution.
168
+ */
169
+ function missingPool() {
170
+ return new CapabilityError('The LocalDB connection pool is not available on this runtime yet.', 'UNAVAILABLE', {
171
+ reason: 'localdb-pool-unavailable',
172
+ retryable: false,
173
+ recoveryHint: 'Open the database through a runtime whose composition registered a pool (the LocalDB plugin does).'
174
+ });
34
175
  }
35
176
  export class LocalDatabase {
177
+ runtime;
178
+ migrations;
36
179
  db;
37
180
  options;
38
181
  initialized = false;
39
- constructor(options) {
182
+ /** Whether this instance holds a connection: a pooled reference or an open direct handle. */
183
+ connected = false;
184
+ /** The explicitly shared physical pool, once resolved from the opening runtime. */
185
+ physicalPool;
186
+ /**
187
+ * @param runtime the opening caller's runtime: it reports the open,
188
+ * migrations and any recovery, and a pooled connection it opens is
189
+ * maintained on its lifecycle when it is persistent.
190
+ * @param pool the explicitly shared physical pool; when omitted it is
191
+ * resolved from the opening runtime on `initialize()`.
192
+ */
193
+ constructor(runtime, options, migrations = migrationRegistry, pool) {
194
+ this.runtime = runtime;
195
+ this.migrations = migrations;
40
196
  this.options = {
41
197
  consumer: 'unknown',
42
198
  skipMigrations: false,
43
199
  usePool: true,
44
200
  ...options
45
201
  };
202
+ this.physicalPool = pool;
203
+ }
204
+ /**
205
+ * The explicitly shared physical pool: the constructor's pool, else the
206
+ * runtime's pool once an async call resolved it. A synchronous use before
207
+ * any resolution refuses typed instead of opening a second connection.
208
+ */
209
+ requirePool() {
210
+ const resolved = this.physicalPool ?? tryPoolOf(this.runtime);
211
+ if (!resolved)
212
+ throw missingPool();
213
+ return (this.physicalPool = resolved);
46
214
  }
47
215
  /**
48
216
  * Initialize the database connection
49
217
  * - Creates directory if needed
50
218
  * - Gets/creates pooled connection
51
219
  * - Runs pending migrations
220
+ *
221
+ * Ownership decides, not the `initialized` flag: a database that acquired
222
+ * a connection and then failed to initialize keeps that connection, so its
223
+ * cleanup stays reachable through `close()`. Retrying `initialize()` on
224
+ * such a handle refuses `CONFLICT localdb-database-open` naming the
225
+ * pending cleanup instead of silently replacing the physical connection;
226
+ * close it (or release the failure) first.
227
+ *
228
+ * A database that fails to initialize keeps no connection when its
229
+ * cleanup succeeds: the pooled reference is released (a direct connection
230
+ * closed) before the failure propagates, and this handle may initialize
231
+ * again.
52
232
  */
53
233
  async initialize() {
54
234
  if (this.initialized)
55
235
  return;
56
- // Ensure directory exists
57
- if (!isServerRuntime) {
58
- throw new Error('LocalDatabase is not available in browser builds');
59
- }
60
- await mkdir(dirname(this.options.dbPath), { recursive: true });
61
- // Get connection (pooled or direct)
62
- this.establishConnection();
63
- // Run migrations if needed
236
+ if (this.connected)
237
+ throw pendingCleanup();
238
+ this.physicalPool ??= await poolOf(this.runtime);
239
+ const files = await fileSystemOf(this.runtime);
240
+ await files.mkdir(files.dirname(this.options.dbPath), { recursive: true });
241
+ await this.establishConnection();
64
242
  if (!this.options.skipMigrations) {
65
- try {
66
- // Check for incompatible database from old migration system
67
- if (migrationRegistry.isIncompatibleDatabase(this.db, this.options.namespace)) {
68
- logger.error(`[LocalDB] Detected incompatible database for ${this.options.namespace} (from legacy migration system). Initiating recovery...`);
69
- await this.performRecovery();
70
- }
71
- else if (migrationRegistry.needsMigration(this.db, this.options.namespace)) {
72
- await migrationRegistry.runMigrations(this.db, this.options.namespace);
73
- }
74
- }
75
- catch (error) {
76
- logger.error(`[LocalDB] Migration failed for ${this.options.namespace}. Initiating automatic recovery/trapdoor...`, toErrorContext(error));
77
- await this.performRecovery();
243
+ const prepared = await settle(this.prepareSchema());
244
+ if (prepared.failed) {
245
+ this.releaseConnection(this.runtime);
246
+ throw prepared.cause;
78
247
  }
79
248
  }
80
249
  this.initialized = true;
81
250
  }
251
+ /**
252
+ * Bring the namespace's schema up to date. A failure fails closed (data
253
+ * preserved) unless the namespace opted into `autoReset`; then the
254
+ * database is archived and rebuilt from version 0.
255
+ */
256
+ async prepareSchema() {
257
+ const { namespace } = this.options;
258
+ const logger = databaseLogger(this.runtime);
259
+ const autoReset = this.migrations.allowsAutoReset(this.runtime, namespace);
260
+ const migrated = await settle(this.migrateInPlace(autoReset));
261
+ if (!migrated.failed) {
262
+ if (migrated.value.state === 'migrated')
263
+ return;
264
+ const { versions } = migrated.value;
265
+ logger.error(`[LocalDB] Incompatible database for ${namespace} (tracked v${versions.tracked} > known v${versions.current}); namespace allows auto-reset. Initiating recovery...`);
266
+ await this.performRecovery();
267
+ return;
268
+ }
269
+ if (migrated.cause instanceof IncompatibleDatabaseError)
270
+ throw migrated.cause;
271
+ if (!autoReset) {
272
+ logger.error(`[LocalDB] Migration failed for ${namespace}; auto-reset is disabled for this namespace, failing closed (data preserved).`, { reason: 'localdb-migration-failed', dbPath: this.options.dbPath, error: migrated.cause });
273
+ throw migrated.cause;
274
+ }
275
+ logger.error(`[LocalDB] Migration failed for ${namespace}. Initiating automatic recovery/trapdoor...`, {
276
+ reason: 'localdb-migration-failed',
277
+ dbPath: this.options.dbPath,
278
+ error: migrated.cause
279
+ });
280
+ await this.performRecovery();
281
+ }
282
+ /** Migrate in place; a newer database is refused, or reported for a reset when the namespace allows one. */
283
+ async migrateInPlace(autoReset) {
284
+ const { namespace, dbPath } = this.options;
285
+ // Database tracks a NEWER schema version than this build knows.
286
+ if (this.migrations.isIncompatibleDatabase(this.runtime, this.db, namespace)) {
287
+ const versions = this.migrations.describeVersions(this.runtime, this.db, namespace);
288
+ if (!autoReset)
289
+ throw new IncompatibleDatabaseError(namespace, dbPath, versions.tracked, versions.current);
290
+ return { state: 'incompatible', versions };
291
+ }
292
+ if (this.migrations.needsMigration(this.runtime, this.db, namespace)) {
293
+ await this.migrations.runMigrations(this.runtime, this.db, namespace);
294
+ }
295
+ return { state: 'migrated' };
296
+ }
82
297
  /**
83
298
  * Establish initial connection
84
299
  */
85
- establishConnection() {
300
+ async establishConnection() {
301
+ if (this.options.usePool) {
302
+ this.db = await this.requirePool().getConnection(this.runtime, this.options.dbPath, this.options.consumer);
303
+ }
304
+ else {
305
+ // Direct connection (for special cases), through the same host driver.
306
+ const lifecycle = this.runtime.lifecycle;
307
+ const driver = await sqliteDriverOf(this.runtime);
308
+ const opened = await openConfiguredDatabase(this.runtime, () => driver.open(this.options.dbPath), applyLocalDatabasePragmas, lifecycle);
309
+ assertOpenedConnectionLive(this.runtime, opened, lifecycle);
310
+ this.db = opened;
311
+ }
312
+ this.connected = true;
313
+ }
314
+ /** Give the connection back, reported on `runtime`: release the pooled reference, or checkpoint and close a direct one. */
315
+ releaseConnection(runtime) {
316
+ if (!this.connected)
317
+ return;
86
318
  if (this.options.usePool) {
87
- this.db = connectionPool.getConnection(this.options.dbPath, this.options.consumer);
319
+ this.requirePool().releaseConnection(runtime, this.options.dbPath);
88
320
  }
89
321
  else {
90
- // Direct connection (for special cases)
91
- this.db = new Database(this.options.dbPath);
92
- this.configureDatabase(this.db);
322
+ this.checkpointBeforeDirectClose(runtime);
323
+ this.db.close();
93
324
  }
325
+ this.connected = false;
94
326
  }
95
327
  /**
96
328
  * Perform recovery from migration failure
97
329
  * 1. Close connection
98
- * 2. Backup corrupted DB
99
- * 3. Delete corrupted DB
100
- * 4. Re-initialize fresh DB
101
- * 5. Re-run migrations
330
+ * 2. Move the broken DB + WAL/SHM sidecars into a complete backup set
331
+ * 3. Re-initialize fresh DB
332
+ * 4. Re-run migrations
333
+ *
334
+ * The backup MUST include the WAL: un-checkpointed rows live only there,
335
+ * and a main-file-only copy silently drops them (the presence inner-world
336
+ * incident lost a multi-day WAL window exactly this way). A connection that
337
+ * cannot be closed stops the recovery before any file moves.
102
338
  */
103
339
  async performRecovery() {
340
+ assertRuntimeLive(this.runtime, 'database recovery');
341
+ this.assertCanReplace(this.runtime);
104
342
  // 1. Close the broken database
343
+ const logger = databaseLogger(this.runtime);
105
344
  if (this.options.usePool) {
106
- connectionPool.closeConnection(this.options.dbPath, true);
345
+ this.requirePool().closeConnection(this.runtime, this.options.dbPath, true);
107
346
  }
108
347
  else {
109
348
  this.db.close();
110
349
  }
111
- // 2. Backup the broken file
112
- const backupPath = `${this.options.dbPath}.bak-${Date.now()}`;
113
- try {
114
- await copyFile(this.options.dbPath, backupPath);
115
- logger.info(`[LocalDB] Backed up corrupted database to: ${backupPath}`);
116
- await this.pruneBackupFiles();
117
- }
118
- catch (backupError) {
119
- logger.error('[LocalDB] Failed to backup corrupted database:', toErrorContext(backupError));
120
- }
121
- // 3. Delete the broken file and WAL artifacts
122
- try {
123
- await unlink(this.options.dbPath);
124
- await unlink(`${this.options.dbPath}-wal`).catch(() => { });
125
- await unlink(`${this.options.dbPath}-shm`).catch(() => { });
126
- logger.info('[LocalDB] Deleted corrupted database file.');
127
- }
128
- catch (deleteError) {
129
- if (isFileMissingError(deleteError)) {
130
- logger.info('[LocalDB] Corrupted database file already removed during recovery. Continuing...');
131
- }
132
- else {
133
- logger.error('[LocalDB] Failed to delete corrupted database:', toErrorContext(deleteError));
134
- throw deleteError; // If we can't delete, we can't recover.
135
- }
136
- }
137
- // 4. Re-establish connection (creates new DB file)
350
+ this.connected = false;
351
+ // 2. Move the broken file AND its WAL/SHM sidecars aside as one backup set
352
+ // (owned primitive — preserves the WAL, prunes old backups).
353
+ await archiveDatabaseFiles(this.runtime, this.options.dbPath);
354
+ // 3. Re-establish connection (creates new DB file)
138
355
  logger.info('[LocalDB] Re-initializing fresh database...');
139
- this.establishConnection();
140
- // 5. Re-run migrations on fresh DB
141
- try {
142
- if (migrationRegistry.needsMigration(this.db, this.options.namespace)) {
143
- await migrationRegistry.runMigrations(this.db, this.options.namespace);
144
- }
145
- logger.info('[LocalDB] Recovery successful. Database reset.');
146
- }
147
- catch (retryError) {
148
- logger.error('[LocalDB] Recovery failed. Fresh database migration failed:', toErrorContext(retryError));
149
- throw retryError;
150
- }
151
- }
152
- async pruneBackupFiles() {
153
- const directory = dirname(this.options.dbPath);
154
- const filename = basename(this.options.dbPath);
155
- const backupPrefix = `${filename}.bak-`;
156
- const files = await readdir(directory).catch(() => []);
157
- const backups = files
158
- .filter((entry) => entry.startsWith(backupPrefix))
159
- .map((entry) => ({ entry, timestamp: Number(entry.slice(backupPrefix.length)) }))
160
- .sort((a, b) => {
161
- if (Number.isNaN(a.timestamp) || Number.isNaN(b.timestamp)) {
162
- return b.entry.localeCompare(a.entry);
163
- }
164
- return b.timestamp - a.timestamp;
165
- });
166
- for (const backup of backups.slice(BACKUP_RETENTION_COUNT)) {
167
- await unlink(`${directory}/${backup.entry}`).catch(() => { });
356
+ await this.establishConnection();
357
+ // 4. Re-run migrations on fresh DB
358
+ const remigrated = await settle(this.runPendingMigrations());
359
+ if (remigrated.failed) {
360
+ logger.error('[LocalDB] Recovery failed. Fresh database migration failed', {
361
+ reason: 'localdb-recovery-failed',
362
+ dbPath: this.options.dbPath,
363
+ error: remigrated.cause
364
+ });
365
+ throw remigrated.cause;
366
+ }
367
+ logger.info('[LocalDB] Recovery successful. Database reset.');
368
+ }
369
+ async runPendingMigrations() {
370
+ if (this.migrations.needsMigration(this.runtime, this.db, this.options.namespace)) {
371
+ await this.migrations.runMigrations(this.runtime, this.db, this.options.namespace);
168
372
  }
169
373
  }
170
374
  /**
171
- * Configure database with optimal settings (for non-pooled connections)
375
+ * Observed checkpoint before a direct (non-pooled) connection closes,
376
+ * reported on `runtime`. Busy outcomes are logged at warn; a SQLite failure
377
+ * is logged at error and never blocks the close.
172
378
  */
173
- configureDatabase(db) {
174
- db.exec('PRAGMA journal_mode = WAL;');
175
- db.exec('PRAGMA busy_timeout = 5000;');
176
- db.exec('PRAGMA foreign_keys = ON;');
177
- db.exec('PRAGMA synchronous = NORMAL;');
178
- db.exec('PRAGMA temp_store = MEMORY;');
379
+ checkpointBeforeDirectClose(runtime) {
380
+ if (this.db.inTransaction)
381
+ return;
382
+ const logger = databaseLogger(runtime);
383
+ const attempted = attempt(() => checkpointWal(this.db));
384
+ if (attempted.failed) {
385
+ logger.error('[LocalDB] WAL checkpoint failed', {
386
+ reason: 'wal-checkpoint-failed',
387
+ trigger: 'close',
388
+ dbPath: this.options.dbPath,
389
+ consumer: this.options.consumer,
390
+ error: attempted.cause
391
+ });
392
+ return;
393
+ }
394
+ const { status, ...outcome } = attempted.value;
395
+ const fields = { dbPath: this.options.dbPath, consumer: this.options.consumer, ...outcome };
396
+ if (status === 'busy') {
397
+ logger.warn('[LocalDB] WAL checkpoint busy', { reason: 'wal-checkpoint-busy', trigger: 'close', ...fields });
398
+ }
399
+ else {
400
+ logger.debug('[LocalDB] WAL checkpoint', { status, trigger: 'close', ...fields });
401
+ }
179
402
  }
180
403
  /**
181
404
  * Get the underlying Database instance
182
405
  */
183
406
  getDatabase() {
184
- if (!this.initialized) {
185
- throw new Error('Database not initialized. Call initialize() first.');
407
+ if (!this.initialized || (this.options.usePool && !this.requirePool().ownsConnection(this.options.dbPath, this.db))) {
408
+ throw notInitialized(this.options.dbPath);
186
409
  }
187
410
  return this.db;
188
411
  }
412
+ /** Preserve every other physical borrower before this instance is reset or recovered. */
413
+ assertCanReplace(runtime) {
414
+ this.requirePool().assertCanReplaceDatabase(runtime, this.options.dbPath, this.connected && this.options.usePool ? this.db : undefined);
415
+ }
189
416
  /**
190
- * Close the database connection
417
+ * Close the database connection, reported on the releasing runtime (which
418
+ * may differ from the runtime that opened it: the manager shares one
419
+ * instance per `namespace:path`).
420
+ *
421
+ * Cleanup runs whenever this handle owns a connection, including a handle
422
+ * whose initialization failed: its acquired connection stays reachable for
423
+ * cleanup instead of being abandoned (close before `initialized` became
424
+ * true was previously a no-op).
191
425
  */
192
- close() {
193
- if (!this.initialized)
194
- return;
195
- if (this.options.usePool) {
196
- // Release back to pool
197
- connectionPool.releaseConnection(this.options.dbPath);
198
- }
199
- else {
200
- // Direct close
201
- this.db.close();
202
- }
426
+ close(runtime) {
427
+ this.releaseConnection(runtime);
203
428
  this.initialized = false;
204
429
  }
205
430
  /**
206
- * Check if database is healthy
431
+ * Check if database is healthy. A health probe answers false, with the
432
+ * reason logged on the probing runtime.
207
433
  */
208
- healthCheck() {
434
+ healthCheck(runtime) {
209
435
  if (!this.initialized)
210
436
  return false;
211
- try {
212
- const result = this.db.query('SELECT 1').get();
213
- return result !== null;
214
- }
215
- catch {
437
+ const probe = attempt(() => this.db.prepare('SELECT 1 AS ok').get());
438
+ if (probe.failed) {
439
+ databaseLogger(runtime).warn('[LocalDB] Health check query failed', {
440
+ reason: 'localdb-health-check-failed',
441
+ dbPath: this.options.dbPath,
442
+ error: probe.cause
443
+ });
216
444
  return false;
217
445
  }
446
+ return probe.value !== null;
218
447
  }
219
448
  /**
220
449
  * Get migration status for this database
221
450
  */
222
451
  getMigrationStatus() {
223
- if (!this.initialized) {
224
- throw new Error('Database not initialized');
452
+ if (!this.initialized || (this.options.usePool && !this.requirePool().ownsConnection(this.options.dbPath, this.db))) {
453
+ throw notInitialized(this.options.dbPath);
225
454
  }
226
- return migrationRegistry.getDatabaseStatus(this.db, this.options.namespace);
455
+ return this.migrations.getDatabaseStatus(this.runtime, this.db, this.options.namespace);
227
456
  }
228
457
  }
458
+ /** Whether `holder` is `runtime` or reads its lifecycle (a view of it). */
459
+ function sameHolder(holder, runtime) {
460
+ return releasesHolder(holder, runtime);
461
+ }
462
+ /** A runtime that ended holds nothing: drop it, as the pool drops ended holders. */
463
+ function dropEnded(holders) {
464
+ for (const holder of holders.keys()) {
465
+ if (holder.lifecycle.signal.aborted)
466
+ holders.delete(holder);
467
+ }
468
+ }
469
+ /** Record one more reference for `runtime` (dropping runtimes that ended); returns `holders`. */
470
+ function hold(holders, runtime) {
471
+ dropEnded(holders);
472
+ holders.set(runtime, (holders.get(runtime) ?? 0) + 1);
473
+ return holders;
474
+ }
475
+ /**
476
+ * Give back one of `runtime`'s references: its own, or one taken by a view of
477
+ * it (any runtime object reading the same lifecycle). A fork or reload
478
+ * generation of the same root has its own lifecycle and never releases the
479
+ * root's reference; a runtime that holds nothing releases nothing.
480
+ */
481
+ function release(holders, runtime) {
482
+ dropEnded(holders);
483
+ const holder = [...holders.keys()].find((candidate) => sameHolder(candidate, runtime));
484
+ if (holder === undefined)
485
+ return;
486
+ const count = holders.get(holder) ?? 0;
487
+ if (count > 1)
488
+ holders.set(holder, count - 1);
489
+ else
490
+ holders.delete(holder);
491
+ }
492
+ /** Give back every reference `runtime` releases ({@link releasesHolder}), and drop runtimes that ended. */
493
+ function releaseAll(holders, runtime) {
494
+ for (const holder of holders.keys()) {
495
+ if (releasesHolder(holder, runtime))
496
+ holders.delete(holder);
497
+ }
498
+ dropEnded(holders);
499
+ }
500
+ /** Whether `value` is a kernel `runtime/client` descriptor (rather than a registry). */
501
+ function isClientDescriptor(value) {
502
+ return RuntimeClientDescriptorSchema.safeParse(value).success;
503
+ }
504
+ /**
505
+ * `CONFLICT localdb-database-open`: the handle still owns a connection from a
506
+ * failed initialization, so reinitializing would abandon it. Close the handle
507
+ * (or release the failure) first.
508
+ */
509
+ function pendingCleanup() {
510
+ return new CapabilityError('The LocalDB database still holds a connection from a failed initialization; close it before initializing again.', 'CONFLICT', {
511
+ reason: 'localdb-database-open',
512
+ retryable: false,
513
+ recoveryHint: 'Call close(runtime) on this handle first, or retry close until the driver accepts it.'
514
+ });
515
+ }
516
+ /** The typed refusal when LocalDB is asked to act without the runtime that owns the call. */
517
+ function missingRuntime() {
518
+ return new CapabilityError('A runtime is required for LocalDB access', 'INVALID_INPUT', {
519
+ reason: 'localdb-runtime-required',
520
+ retryable: false,
521
+ recoveryHint: 'Resolve dev/localdb on the runtime making this call.'
522
+ });
523
+ }
229
524
  /**
230
525
  * LocalDB Manager
231
- * Main entry point for accessing local databases
526
+ *
527
+ * The runtime-owned logical owner of LocalDB access: one instance per
528
+ * runtime, resolved through the kernel as the `localdb` `runtime/client`
529
+ * (see `src/localdb.client.descriptor.ts`) via `getVibesClient` (async) or
530
+ * `tryGetVibesClient` (synchronously, once activation warmed the cache).
531
+ * The `dev/localdb` context resolves to this same instance. Physical
532
+ * connections stay shared per path in the runtime's pool — an explicitly
533
+ * shared pool when the composition root registered one, else the plugin's
534
+ * runtime-owned default; each client only tracks its own runtime's logical
535
+ * holdings, so one runtime's release or disposal never closes a database
536
+ * another live runtime holds.
232
537
  */
233
- class LocalDBManager {
234
- databases = new Map();
235
- dbPathLocks = new Map();
236
- async withDbPathLock(dbPath, task) {
237
- const prior = this.dbPathLocks.get(dbPath) ?? Promise.resolve();
238
- let release;
239
- const current = new Promise((resolve) => {
240
- release = resolve;
241
- });
242
- const queued = prior.then(() => current);
243
- this.dbPathLocks.set(dbPath, queued);
244
- await prior;
538
+ export class LocalDBManager extends BaseRuntimeClient {
539
+ poolResolution;
540
+ migrations;
541
+ owner;
542
+ /**
543
+ * The runtime's physical pool, once resolved: an explicitly shared pool
544
+ * when the composition root registered one, else the runtime-owned
545
+ * default. Cached because disposal paths must reach it without
546
+ * re-resolving through a runtime whose lifecycle already ended.
547
+ */
548
+ physicalPool;
549
+ constructor(descriptorOrMigrations = migrationRegistry, contextOrOwner, runtime) {
550
+ if (isClientDescriptor(descriptorOrMigrations)) {
551
+ // SAFETY: a runtime/client descriptor selects the kernel constructor overload, whose second argument is KindContext.
552
+ super(descriptorOrMigrations, contextOrOwner, runtime);
553
+ this.migrations = migrationRegistry;
554
+ this.owner = this.runtime;
555
+ // Full lifecycle integration belongs to runtimes that composed the
556
+ // plugin: a root runtime releases in inverse plugin dependency order via
557
+ // localdb.plugin.ts onDispose (so dependent plugins like browser-automation
558
+ // finish their disposal writes first); reload-generation forks whose
559
+ // borrowed plugin lifetimes finalize once on the root release via
560
+ // releaseAtDisposal. Runtimes reached only through the compatibility
561
+ // fallback below keep the legacy behavior — their disposal closes nothing.
562
+ this.ensureBorrowedDisposal(this.runtime);
563
+ }
564
+ else {
565
+ // SAFETY: the legacy registry overload accepts its explicit RuntimeRef owner as the second argument.
566
+ const owner = contextOrOwner;
567
+ if (!owner)
568
+ throw missingRuntime();
569
+ super({ id: 'localdb', kind: 'runtime/client', description: 'Runtime-owned logical LocalDB access' }, undefined, owner);
570
+ this.migrations = descriptorOrMigrations;
571
+ this.owner = owner;
572
+ }
573
+ }
574
+ ensureBorrowedDisposal(runtime) {
575
+ if (runtime.hasPlugin('localdb') && !hasDirectPluginDisposal(runtime)) {
576
+ releaseAtDisposal(runtime, this);
577
+ }
578
+ }
579
+ requireOwner() {
580
+ return this.owner;
581
+ }
582
+ /** A kernel-owned API serves its runtime, a view of that same lifecycle, or a reload generation sharing this retained client. */
583
+ assertOwner(runtime) {
584
+ if (this.owner &&
585
+ this.owner.lifecycle.signal !== runtime.lifecycle.signal &&
586
+ cachedPoolClient(runtime) !== this) {
587
+ throw new CapabilityError('The LocalDB API belongs to another runtime.', 'CONFLICT', {
588
+ reason: 'localdb-runtime-mismatch', retryable: false,
589
+ recoveryHint: 'Resolve dev/localdb on the runtime making this call.'
590
+ });
591
+ }
592
+ }
593
+ /**
594
+ * Resolve the owning runtime's physical pool through the kernel and cache
595
+ * it. Always resolved from the owner — never from the calling view — so
596
+ * lifecycle views of the owner share the owner's pool. The first resolved
597
+ * physical owner stays captured for this client, including disposal, which must
598
+ * reach the pool without re-resolving through a runtime whose lifecycle
599
+ * already ended.
600
+ */
601
+ async ensurePool() {
602
+ if (this.physicalPool)
603
+ return this.physicalPool;
604
+ this.poolResolution ??= poolOf(this.requireOwner());
245
605
  try {
246
- return await task();
606
+ this.physicalPool ??= await this.poolResolution;
607
+ return this.physicalPool;
247
608
  }
248
609
  finally {
249
- release?.();
250
- if (this.dbPathLocks.get(dbPath) === queued) {
251
- this.dbPathLocks.delete(dbPath);
252
- }
610
+ this.poolResolution = undefined;
253
611
  }
254
612
  }
613
+ /** Physical owner shared by managed access and the public pool facade. */
614
+ getConnectionPool() {
615
+ return this.requirePool();
616
+ }
617
+ /**
618
+ * The cached pool, else the runtime's already-resolved pool. A use before
619
+ * any resolution refuses typed instead of opening a second connection.
620
+ */
621
+ requirePool() {
622
+ const resolved = this.physicalPool ?? tryPoolOf(this.requireOwner());
623
+ if (!resolved)
624
+ throw missingPool();
625
+ return (this.physicalPool = resolved);
626
+ }
627
+ /**
628
+ * Managed databases per `namespace:path` within this manager. The kernel's
629
+ * context instance owns a manager and its registrations. `holders` counts open references, so
630
+ * one runtime's release or disposal (a root, or a reload generation forked
631
+ * from it) never closes a database another live runtime holds.
632
+ */
633
+ databases = new Map();
634
+ /** In-flight `closeAll` per runtime: concurrent calls for one runtime share it; two runtimes never do. */
635
+ closeAllPromises = new Map();
636
+ /**
637
+ * Register migrations for a namespace, reported on the registering runtime.
638
+ * Must be called before getDatabase() for that namespace.
639
+ */
640
+ registerMigrations(runtime, namespace, migrations, options) {
641
+ this.assertOwner(runtime);
642
+ this.migrations.register(runtime, namespace, migrations, options);
643
+ }
255
644
  /**
256
- * Register migrations for a namespace
257
- * Must be called before getDatabase() for that namespace
645
+ * Guarded repair for a namespace whose registered migration set is known to
646
+ * be incomplete. The caller must provide the exact ids it observed.
258
647
  */
259
- registerMigrations(namespace, migrations) {
260
- migrationRegistry.register(namespace, migrations);
648
+ replaceMigrations(runtime, namespace, migrations, options) {
649
+ this.assertOwner(runtime);
650
+ return this.migrations.replaceMigrations(runtime, namespace, migrations, options);
261
651
  }
262
652
  /**
263
653
  * Get or create a database instance
264
654
  *
655
+ * @param runtime The calling runtime: it reports the open, and a persistent
656
+ * one can carry the connection's maintenance
265
657
  * @param namespace Migration namespace (e.g., 'atlas', 'session')
266
658
  * @param dbPath Path to database file
267
659
  * @param consumer Consumer identifier for debugging
268
- * @returns Initialized database instance
660
+ * @returns Initialized database instance; a runtime whose disposal began
661
+ * (checked once the path's lock is held) is refused `CANCELLED`
662
+ * `runtime-disposed` and holds nothing
269
663
  */
270
- getDatabase(namespace, dbPath, consumer) {
664
+ async getDatabase(runtime, namespace, dbPath, consumer) {
271
665
  const key = `${namespace}:${dbPath}`;
272
- return this.withDbPathLock(dbPath, async () => {
666
+ const pool = await this.ensurePool();
667
+ return pool.withDatabasePathLock(dbPath, async () => {
668
+ this.assertOwner(runtime);
669
+ assertRuntimeLive(runtime, 'database open');
670
+ this.ensureBorrowedDisposal(runtime);
273
671
  const existing = this.databases.get(key);
274
672
  if (existing) {
275
- existing.references += 1;
673
+ hold(existing.holders, runtime);
674
+ // Another runtime may have opened it: this caller holds it now too.
675
+ pool.retain(runtime, dbPath);
276
676
  return existing.database;
277
677
  }
278
- if (!this.databases.has(key)) {
279
- const db = new LocalDatabase({
280
- namespace,
281
- dbPath,
282
- consumer
283
- });
284
- await db.initialize();
285
- this.databases.set(key, {
286
- database: db,
287
- references: 1
288
- });
289
- }
290
- return this.databases.get(key).database;
678
+ const database = new LocalDatabase(runtime, {
679
+ namespace,
680
+ dbPath,
681
+ consumer
682
+ }, this.migrations, pool);
683
+ await database.initialize();
684
+ this.databases.set(key, { database, dbPath, holders: hold(new Map(), runtime) });
685
+ return database;
291
686
  });
292
687
  }
293
688
  /**
294
689
  * Get a raw Database instance (auto-configured with WAL, etc.)
295
690
  * For when you don't need migration management
296
691
  */
297
- getRawDatabase(dbPath, consumer = 'raw') {
298
- return connectionPool.getConnection(dbPath, consumer);
692
+ async getRawDatabase(runtime, dbPath, consumer = 'raw') {
693
+ const pool = await this.ensurePool();
694
+ return pool.withDatabasePathLock(dbPath, async () => {
695
+ this.assertOwner(runtime);
696
+ this.ensureBorrowedDisposal(runtime);
697
+ return pool.getConnection(runtime, dbPath, consumer);
698
+ });
299
699
  }
300
700
  /**
301
- * Release a database instance
701
+ * Release a database instance, reported on the releasing runtime
302
702
  */
303
- async releaseDatabase(namespace, dbPath) {
703
+ async releaseDatabase(runtime, namespace, dbPath) {
704
+ this.assertOwner(runtime);
304
705
  const key = `${namespace}:${dbPath}`;
305
- await this.withDbPathLock(dbPath, async () => {
706
+ const pool = await this.ensurePool();
707
+ await pool.withDatabasePathLock(dbPath, async () => {
306
708
  const entry = this.databases.get(key);
307
- if (!entry)
709
+ if (!entry) {
710
+ // Another runtime's client may have been the only owner and
711
+ // ended since: give back one reference no live runtime holds
712
+ // so a dead holder's pooled reference cannot leak.
713
+ pool.releaseOrphanedConnection(dbPath);
308
714
  return;
309
- entry.references = Math.max(0, entry.references - 1);
310
- if (entry.references === 0) {
311
- await entry.database.close();
715
+ }
716
+ release(entry.holders, runtime);
717
+ if (entry.holders.size === 0) {
718
+ entry.database.close(runtime);
719
+ this.databases.delete(key);
720
+ }
721
+ });
722
+ }
723
+ /**
724
+ * Reset a database: force-close any open handle, archive the existing file and
725
+ * its WAL/SHM sidecars aside as one pruned backup set, then re-open a fresh DB
726
+ * (re-running the namespace's migrations). This is the owned recovery path for
727
+ * consumers that detect app-level corruption the migration layer can't see
728
+ * (e.g. a missing core table) — they delegate here instead of hand-rolling
729
+ * copy/remove of the file, which drops the WAL and never prunes.
730
+ * Another live lifecycle or raw reference refuses before any handle closes
731
+ * or file moves. A handle that cannot be closed also stops the reset.
732
+ *
733
+ * @returns the freshly re-initialized database instance (references = 1). A
734
+ * runtime whose disposal began is refused before anything closes or moves.
735
+ */
736
+ async resetDatabase(runtime, namespace, dbPath, consumer) {
737
+ const key = `${namespace}:${dbPath}`;
738
+ const pool = await this.ensurePool();
739
+ return pool.withDatabasePathLock(dbPath, async () => {
740
+ this.assertOwner(runtime);
741
+ assertRuntimeLive(runtime, 'database reset');
742
+ this.ensureBorrowedDisposal(runtime);
743
+ const existing = this.databases.get(key);
744
+ if (existing)
745
+ existing.database.assertCanReplace(runtime);
746
+ else
747
+ pool.assertCanReplaceDatabase(runtime, dbPath);
748
+ if (existing) {
749
+ existing.database.close(runtime);
312
750
  this.databases.delete(key);
313
751
  }
752
+ // Force-evict any pooled connection for this path: close() only RELEASES a
753
+ // pooled connection, which would leave a live handle on the file we are
754
+ // about to rename aside and let getDatabase() re-use it (pool is keyed by
755
+ // path). performRecovery() force-closes for the same reason.
756
+ pool.closeConnection(runtime, dbPath, true);
757
+ await archiveDatabaseFiles(runtime, dbPath);
758
+ const db = new LocalDatabase(runtime, { namespace, dbPath, consumer }, this.migrations, pool);
759
+ await db.initialize();
760
+ this.databases.set(key, { database: db, dbPath, holders: hold(new Map(), runtime) });
761
+ return db;
314
762
  });
315
763
  }
316
764
  /**
317
- * Get connection pool statistics
765
+ * Get connection pool statistics for the calling runtime's pool.
318
766
  */
319
- getPoolStats() {
320
- return connectionPool.getStats();
767
+ getPoolStats(runtime = this.requireOwner()) {
768
+ this.assertOwner(runtime);
769
+ return this.requirePool().getStats();
321
770
  }
322
771
  /**
323
772
  * Get migration registry status
324
773
  */
325
- getMigrationStatus() {
326
- return migrationRegistry.getStatus();
774
+ getMigrationStatus(runtime = this.requireOwner()) {
775
+ this.assertOwner(runtime);
776
+ return this.migrations.getStatus(runtime);
327
777
  }
328
778
  /**
329
779
  * List all registered migration namespaces
330
780
  */
331
- listNamespaces() {
332
- return migrationRegistry.listNamespaces();
781
+ listNamespaces(runtime = this.requireOwner()) {
782
+ this.assertOwner(runtime);
783
+ return this.migrations.listNamespaces(runtime);
784
+ }
785
+ /**
786
+ * Resolve the owner-declared database location for a namespace (see
787
+ * `RegisterMigrationsOptions.location`). Undefined when undeclared;
788
+ * propagates resolver throws.
789
+ */
790
+ getNamespaceLocation(namespace, runtime = this.requireOwner()) {
791
+ this.assertOwner(runtime);
792
+ return this.migrations.getNamespaceLocation(runtime, namespace);
333
793
  }
334
794
  /**
335
- * Close all database connections
795
+ * Give back every database and connection the closing runtime holds,
796
+ * reported on that runtime, and close those no live runtime holds. Ones
797
+ * another live runtime holds (another root, or a root and its reload
798
+ * generations) stay open for it.
799
+ *
800
+ * Concurrent calls for one runtime share one operation; calls for two
801
+ * runtimes never do. Successful entries are
802
+ * removed as they close; failed entries remain registered so a later call can
803
+ * retry them.
804
+ */
805
+ closeAll(runtime) {
806
+ this.assertOwner(runtime);
807
+ const inFlight = this.closeAllPromises.get(runtime.lifecycle.signal);
808
+ if (inFlight)
809
+ return inFlight;
810
+ const closing = this.closeEverything(runtime).finally(() => {
811
+ this.closeAllPromises.delete(runtime.lifecycle.signal);
812
+ });
813
+ this.closeAllPromises.set(runtime.lifecycle.signal, closing);
814
+ return closing;
815
+ }
816
+ /**
817
+ * Give back every reference `runtime` holds and drop runtimes that ended,
818
+ * then close each database no live runtime holds, under its path's lock.
819
+ * The pool then does the same for its connections.
336
820
  */
337
- async closeAll() {
338
- for (const entry of this.databases.values()) {
339
- await entry.database.close();
821
+ async closeEverything(runtime) {
822
+ // Release what this runtime holds, drop runtimes that ended, then close every
823
+ // database no live runtime holds — including one orphaned by a runtime that
824
+ // ended without disposing LocalDB, so no later caller reuses a closed handle.
825
+ for (const entry of this.databases.values())
826
+ releaseAll(entry.holders, runtime);
827
+ const pool = this.physicalPool ?? tryPoolOf(this.requireOwner());
828
+ if (!pool && this.databases.size === 0)
829
+ return;
830
+ const activePool = pool ?? this.requirePool();
831
+ const settled = await Promise.all([...this.databases.entries()]
832
+ .filter(([, entry]) => entry.holders.size === 0)
833
+ .map(([key, entry]) =>
834
+ // Under the path's lock, as every open is: an open that took a holding meanwhile keeps the entry.
835
+ activePool.withDatabasePathLock(entry.dbPath, async () => {
836
+ if (this.databases.get(key) !== entry || entry.holders.size > 0)
837
+ return undefined;
838
+ const closed = await settle(Promise.resolve().then(() => entry.database.close(runtime)));
839
+ if (!closed.failed)
840
+ this.databases.delete(key);
841
+ return { key, dbPath: entry.dbPath, closed };
842
+ })));
843
+ const results = settled.filter((result) => result !== undefined);
844
+ const failures = [];
845
+ const retainedPaths = new Set();
846
+ for (const { key, dbPath, closed } of results) {
847
+ if (closed.failed) {
848
+ retainedPaths.add(dbPath);
849
+ failures.push(new Error(`Failed to release LocalDB database '${key}'`, { cause: closed.cause }));
850
+ }
851
+ }
852
+ const closed = attempt(() => activePool.closeAll(runtime, retainedPaths));
853
+ if (closed.failed) {
854
+ failures.push(new Error('Failed to close the LocalDB connection pool', { cause: closed.cause }));
855
+ }
856
+ if (failures.length > 0) {
857
+ throw new AggregateError(failures, `LocalDB disposal failed for ${failures.length} resource${failures.length === 1 ? '' : 's'}`);
858
+ }
859
+ }
860
+ }
861
+ /**
862
+ * Register the `localdb` client owner on runtimes that never composed the
863
+ * plugin (storage-adapter-style callers), so async entrypoints still resolve
864
+ * exactly one kernel-cached client per runtime instead of guessing an owner.
865
+ * The check and the registration are synchronous once the `runtime/client`
866
+ * kind exists, so concurrent first calls cannot register twice.
867
+ */
868
+ async function ensureClientOwner(runtime) {
869
+ if (!runtime.hasKind('runtime/client'))
870
+ await runtime.registerPlugin(runtimeClientPlugin);
871
+ // Storage-adapter-style callers never composed the plugin: they still get
872
+ // the runtime-owned default pool, so their opens share one connection per
873
+ // path exactly like plugin-composed runtimes.
874
+ ensureDefaultPool(runtime);
875
+ if (runtime.query('runtime/client').withId('localdb').descriptors().length > 0)
876
+ return;
877
+ runtime.registerDescriptor('runtime/client', {
878
+ kind: 'runtime/client',
879
+ id: 'localdb',
880
+ description: 'Runtime-owned logical LocalDB access'
881
+ });
882
+ runtime.registerLoader('runtime/client', 'localdb', async () => ({ impl: LocalDBManager }));
883
+ }
884
+ /** Per-lifecycle LocalDBManager for reload-generation forks whose retained kernel client belongs to their root. */
885
+ const forkClientCache = new WeakMap();
886
+ /** Resolve (registering the owner first when needed) the calling runtime's owned LocalDB client. */
887
+ export async function resolveLocaldbClient(runtime) {
888
+ await ensureClientOwner(runtime);
889
+ const client = await getVibesClient('localdb', runtime);
890
+ if (client.runtime.lifecycle.signal === runtime.lifecycle.signal)
891
+ return client;
892
+ let forkClient = forkClientCache.get(runtime.lifecycle.signal);
893
+ if (!forkClient) {
894
+ forkClient = new LocalDBManager({ id: 'localdb', kind: 'runtime/client', description: 'Runtime-owned logical LocalDB access' }, undefined, runtime);
895
+ forkClientCache.set(runtime.lifecycle.signal, forkClient);
896
+ }
897
+ return forkClient;
898
+ }
899
+ const loadedClient = resolveLocaldbClient;
900
+ /** The captured client remains reachable through the kernel while disposal finalizes. */
901
+ function cachedPoolClient(runtime) {
902
+ const forked = forkClientCache.get(runtime.lifecycle.signal);
903
+ if (forked)
904
+ return forked;
905
+ if (runtime.lifecycle.signal.aborted && runtime.hasKind('runtime/client')) {
906
+ const peeked = runtime.query('runtime/client').withId('localdb').peek?.();
907
+ return peeked?.runtime.lifecycle.signal === runtime.lifecycle.signal ? peeked : undefined;
908
+ }
909
+ const resolved = tryGetVibesClient('localdb', runtime);
910
+ return resolved?.runtime.lifecycle.signal === runtime.lifecycle.signal ? resolved : undefined;
911
+ }
912
+ /** Resolve the facade through the same semantic owner as managed databases. */
913
+ export async function ownedPoolOf(runtime) {
914
+ const cached = cachedPoolClient(runtime);
915
+ if (cached)
916
+ return cached.ensurePool();
917
+ // Preserve pool registration, loader and validation refusals before adding
918
+ // the compatibility client for hosts that composed only the pool kind.
919
+ await poolOf(runtime);
920
+ return (await loadedClient(runtime)).ensurePool();
921
+ }
922
+ /** Synchronous facade access never reselects a captured physical owner. */
923
+ export function tryOwnedPoolOf(runtime) {
924
+ return cachedPoolClient(runtime)?.getConnectionPool();
925
+ }
926
+ /**
927
+ * Stateless runtime-backed compatibility access to the calling runtime's
928
+ * owned LocalDB client. Every method takes the runtime it serves first and
929
+ * delegates to that runtime's `localdb` client — no ambient manager, no
930
+ * ambient runtime, no per-runtime cache beside the kernel's own. Synchronous
931
+ * methods query the runtime's migration descriptors directly; async methods
932
+ * resolve the kernel-owned client (registering its owner when needed) on demand.
933
+ * Runtime-optional migration reads refuse `INVALID_INPUT localdb-runtime-required`
934
+ * when no runtime is supplied; they never choose an owner.
935
+ */
936
+ export const localdb = {
937
+ registerMigrations(runtime, namespace, migrations, options) {
938
+ migrationRegistry.register(runtime, namespace, migrations, options);
939
+ },
940
+ getNamespaceLocation(namespace, runtime) {
941
+ if (!runtime)
942
+ throw missingRuntime();
943
+ return migrationRegistry.getNamespaceLocation(runtime, namespace);
944
+ },
945
+ replaceMigrations(runtime, namespace, migrations, options) {
946
+ return migrationRegistry.replaceMigrations(runtime, namespace, migrations, options);
947
+ },
948
+ async getDatabase(runtime, namespace, dbPath, consumer) {
949
+ // Refuse before kernel client resolution, whose own disposed-runtime
950
+ // refusal would otherwise surface a different code for this condition.
951
+ assertRuntimeLive(runtime, 'database open');
952
+ const client = await loadedClient(runtime);
953
+ return client.getDatabase(runtime, namespace, dbPath, consumer);
954
+ },
955
+ async getRawDatabase(runtime, dbPath, consumer) {
956
+ assertRuntimeLive(runtime, 'connection');
957
+ const client = await loadedClient(runtime);
958
+ return client.getRawDatabase(runtime, dbPath, consumer);
959
+ },
960
+ async releaseDatabase(runtime, namespace, dbPath) {
961
+ const cached = cachedPoolClient(runtime);
962
+ if (cached)
963
+ return cached.releaseDatabase(runtime, namespace, dbPath);
964
+ if (runtime.lifecycle.signal.aborted) {
965
+ const pool = abortedPool(runtime);
966
+ if (!pool)
967
+ return;
968
+ return pool.withDatabasePathLock(dbPath, async () => {
969
+ pool.releaseOrphanedConnection(dbPath);
970
+ });
971
+ }
972
+ return (await loadedClient(runtime)).releaseDatabase(runtime, namespace, dbPath);
973
+ },
974
+ async resetDatabase(runtime, namespace, dbPath, consumer) {
975
+ assertRuntimeLive(runtime, 'database reset');
976
+ const client = await loadedClient(runtime);
977
+ return client.resetDatabase(runtime, namespace, dbPath, consumer);
978
+ },
979
+ getPoolStats(runtime) {
980
+ const client = runtime ? cachedPoolClient(runtime) : undefined;
981
+ if (!client)
982
+ throw runtime ? missingPool() : missingRuntime();
983
+ return client.getPoolStats(runtime);
984
+ },
985
+ getMigrationStatus(runtime) {
986
+ if (!runtime)
987
+ throw missingRuntime();
988
+ return migrationRegistry.getStatus(runtime);
989
+ },
990
+ listNamespaces(runtime) {
991
+ if (!runtime)
992
+ throw missingRuntime();
993
+ return migrationRegistry.listNamespaces(runtime);
994
+ },
995
+ closeAll(runtime) {
996
+ if (!runtime.lifecycle.signal.aborted) {
997
+ const cached = cachedPoolClient(runtime);
998
+ if (cached)
999
+ return cached.closeAll(runtime);
1000
+ return loadedClient(runtime).then(client => client.closeAll(runtime));
340
1001
  }
341
- this.databases.clear();
342
- connectionPool.closeAll();
1002
+ return Promise.resolve().then(() => {
1003
+ const cached = cachedPoolClient(runtime);
1004
+ if (cached)
1005
+ return cached.closeAll(runtime);
1006
+ abortedPool(runtime)?.closeAll(runtime);
1007
+ });
343
1008
  }
1009
+ };
1010
+ /**
1011
+ * The runtime's pool on a lifecycle that already ended: the kernel cache when
1012
+ * it is still reachable, else `undefined` (disposal already gave back what
1013
+ * that runtime held, so there is nothing left to release).
1014
+ */
1015
+ function abortedPool(runtime) {
1016
+ return tryOwnedPoolOf(runtime);
1017
+ }
1018
+ /** Runtimes whose own LocalDB plugin registration still owns `onDispose` at plugin finalization time. */
1019
+ const directPluginDisposalSignals = new WeakSet();
1020
+ const releaseAtDisposalSignals = new WeakSet();
1021
+ export function markDirectPluginDisposal(runtime) {
1022
+ directPluginDisposalSignals.add(runtime.lifecycle.signal);
1023
+ }
1024
+ export function clearDirectPluginDisposal(runtime) {
1025
+ directPluginDisposalSignals.delete(runtime.lifecycle.signal);
1026
+ }
1027
+ function hasDirectPluginDisposal(runtime) {
1028
+ return directPluginDisposalSignals.has(runtime.lifecycle.signal);
1029
+ }
1030
+ /** The longest a deferred release waits for its runtime's disposal: the largest delay a host timer honours. */
1031
+ const RELEASE_AT_DISPOSAL_WAIT_MS = 2_147_483_647;
1032
+ /**
1033
+ * Give back what `runtime` holds when its lifecycle ends, as work that
1034
+ * lifecycle owns and joins at its disposal. The LocalDB plugin is disposed on a
1035
+ * live lifecycle when a reload retires it from a runtime that lives on (the
1036
+ * kernel then never disposes it again): the runtime's holdings are released
1037
+ * when that runtime itself is disposed, reported on it.
1038
+ */
1039
+ export function releaseAtDisposal(runtime, api = localdb) {
1040
+ const { signal } = runtime.lifecycle;
1041
+ if (releaseAtDisposalSignals.has(signal))
1042
+ return;
1043
+ releaseAtDisposalSignals.add(signal);
1044
+ const ended = new Promise((resolve) => {
1045
+ signal.addEventListener('abort', () => resolve(), { once: true });
1046
+ });
1047
+ const waiting = runtime.lifecycle.wait(async () => {
1048
+ await ended;
1049
+ const released = await settle(api.closeAll(runtime));
1050
+ if (released.failed) {
1051
+ databaseLogger(runtime).error('[LocalDB] Failed to release databases at disposal', {
1052
+ reason: 'localdb-release-at-disposal-failed',
1053
+ error: released.cause
1054
+ });
1055
+ }
1056
+ }, { timeoutMs: RELEASE_AT_DISPOSAL_WAIT_MS, reason: 'localdb-release-at-disposal' });
1057
+ // The wait ends (cancelled) when disposal begins; the work then runs and disposal joins it.
1058
+ void observeReleaseWait(runtime, waiting);
1059
+ }
1060
+ /** A cancelled wait is the expected end (disposal began); any other end of the wait is reported. */
1061
+ async function observeReleaseWait(runtime, waiting) {
1062
+ const waited = await settle(waiting);
1063
+ if (!waited.failed)
1064
+ return;
1065
+ const caught = CaughtFailureSchema.parse(waited.cause);
1066
+ if (isCapabilityError(caught) && caught.code === 'CANCELLED')
1067
+ return;
1068
+ databaseLogger(runtime).warn('[LocalDB] The release at disposal stopped waiting', {
1069
+ reason: 'localdb-release-at-disposal-unwaited',
1070
+ error: caught
1071
+ });
1072
+ }
1073
+ /**
1074
+ * Release every managed and raw LocalDB connection without assuming ownership
1075
+ * of the process, reported on `runtime`. Runtime plugins and executable hosts
1076
+ * call this from their lifecycle owner.
1077
+ */
1078
+ export function disposeLocalDatabaseConnections(runtime) {
1079
+ return localdb.closeAll(runtime);
344
1080
  }
345
- // Singleton instance
346
- export const localdb = new LocalDBManager();
347
1081
  //# sourceMappingURL=database.js.map