@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.
- package/LICENSE +18 -0
- package/README.md +123 -2
- package/dist/cli/commands/_shared.consumer.d.ts +1 -3
- package/dist/cli/commands/_shared.consumer.d.ts.map +1 -1
- package/dist/cli/commands/_shared.consumer.js +1 -11
- package/dist/cli/commands/_shared.consumer.js.map +1 -1
- package/dist/cli/commands/migrate/dev.localdb.migrate.cli-command.descriptor.js +1 -1
- package/dist/cli/commands/migrate/dev.localdb.migrate.cli-command.descriptor.js.map +1 -1
- package/dist/cli/commands/migrate/dev.localdb.migrate.cli-command.impl.consumer.d.ts +9 -3
- package/dist/cli/commands/migrate/dev.localdb.migrate.cli-command.impl.consumer.d.ts.map +1 -1
- package/dist/cli/commands/migrate/dev.localdb.migrate.cli-command.impl.consumer.js +59 -45
- package/dist/cli/commands/migrate/dev.localdb.migrate.cli-command.impl.consumer.js.map +1 -1
- package/dist/cli/commands/namespaces/dev.localdb.namespaces.cli-command.descriptor.js +1 -1
- package/dist/cli/commands/namespaces/dev.localdb.namespaces.cli-command.descriptor.js.map +1 -1
- package/dist/cli/commands/namespaces/dev.localdb.namespaces.cli-command.impl.d.ts +8 -3
- package/dist/cli/commands/namespaces/dev.localdb.namespaces.cli-command.impl.d.ts.map +1 -1
- package/dist/cli/commands/namespaces/dev.localdb.namespaces.cli-command.impl.js +12 -10
- package/dist/cli/commands/namespaces/dev.localdb.namespaces.cli-command.impl.js.map +1 -1
- package/dist/cli/commands/pools/dev.localdb.pools.cli-command.descriptor.js +1 -1
- package/dist/cli/commands/pools/dev.localdb.pools.cli-command.descriptor.js.map +1 -1
- package/dist/cli/commands/pools/dev.localdb.pools.cli-command.impl.d.ts +6 -3
- package/dist/cli/commands/pools/dev.localdb.pools.cli-command.impl.d.ts.map +1 -1
- package/dist/cli/commands/pools/dev.localdb.pools.cli-command.impl.js +8 -7
- package/dist/cli/commands/pools/dev.localdb.pools.cli-command.impl.js.map +1 -1
- package/dist/cli/commands/query/dev.localdb.query.cli-command.descriptor.js +1 -1
- package/dist/cli/commands/query/dev.localdb.query.cli-command.descriptor.js.map +1 -1
- package/dist/cli/commands/query/dev.localdb.query.cli-command.impl.consumer.d.ts +13 -3
- package/dist/cli/commands/query/dev.localdb.query.cli-command.impl.consumer.d.ts.map +1 -1
- package/dist/cli/commands/query/dev.localdb.query.cli-command.impl.consumer.js +66 -51
- package/dist/cli/commands/query/dev.localdb.query.cli-command.impl.consumer.js.map +1 -1
- package/dist/cli/commands/status/dev.localdb.status.cli-command.descriptor.js +1 -1
- package/dist/cli/commands/status/dev.localdb.status.cli-command.descriptor.js.map +1 -1
- package/dist/cli/commands/status/dev.localdb.status.cli-command.impl.consumer.d.ts +9 -3
- package/dist/cli/commands/status/dev.localdb.status.cli-command.impl.consumer.d.ts.map +1 -1
- package/dist/cli/commands/status/dev.localdb.status.cli-command.impl.consumer.js +41 -29
- package/dist/cli/commands/status/dev.localdb.status.cli-command.impl.consumer.js.map +1 -1
- package/dist/cli/inputs.d.ts +25 -0
- package/dist/cli/inputs.d.ts.map +1 -0
- package/dist/cli/inputs.js +81 -0
- package/dist/cli/inputs.js.map +1 -0
- package/dist/cli/types.d.ts +45 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/cli/types.js +14 -0
- package/dist/cli/types.js.map +1 -0
- package/dist/core/connection-pool.d.ts +204 -46
- package/dist/core/connection-pool.d.ts.map +1 -1
- package/dist/core/connection-pool.js +610 -101
- package/dist/core/connection-pool.js.map +1 -1
- package/dist/core/database.d.ts +243 -63
- package/dist/core/database.d.ts.map +1 -1
- package/dist/core/database.js +937 -203
- package/dist/core/database.js.map +1 -1
- package/dist/core/holders.d.ts +15 -0
- package/dist/core/holders.d.ts.map +1 -0
- package/dist/core/holders.js +4 -0
- package/dist/core/holders.js.map +1 -0
- package/dist/core/host-kinds.d.ts +22 -0
- package/dist/core/host-kinds.d.ts.map +1 -0
- package/dist/core/host-kinds.js +52 -0
- package/dist/core/host-kinds.js.map +1 -0
- package/dist/core/live-runtime.d.ts +17 -0
- package/dist/core/live-runtime.d.ts.map +1 -0
- package/dist/core/live-runtime.js +28 -0
- package/dist/core/live-runtime.js.map +1 -0
- package/dist/core/migration-registry.d.ts +115 -80
- package/dist/core/migration-registry.d.ts.map +1 -1
- package/dist/core/migration-registry.js +340 -199
- package/dist/core/migration-registry.js.map +1 -1
- package/dist/core/open-connection.d.ts +11 -0
- package/dist/core/open-connection.d.ts.map +1 -0
- package/dist/core/open-connection.js +70 -0
- package/dist/core/open-connection.js.map +1 -0
- package/dist/core/outcome.d.ts +21 -0
- package/dist/core/outcome.d.ts.map +1 -0
- package/dist/core/outcome.js +22 -0
- package/dist/core/outcome.js.map +1 -0
- package/dist/core/pool-api.d.ts +19 -0
- package/dist/core/pool-api.d.ts.map +1 -0
- package/dist/core/pool-api.js +59 -0
- package/dist/core/pool-api.js.map +1 -0
- package/dist/core/slow-statement.d.ts +81 -0
- package/dist/core/slow-statement.d.ts.map +1 -0
- package/dist/core/slow-statement.js +149 -0
- package/dist/core/slow-statement.js.map +1 -0
- package/dist/core/wal-policy.d.ts +86 -0
- package/dist/core/wal-policy.d.ts.map +1 -0
- package/dist/core/wal-policy.js +115 -0
- package/dist/core/wal-policy.js.map +1 -0
- package/dist/dev.localdb.cli-group.descriptor.d.ts +1 -1
- package/dist/dev.localdb.cli-group.descriptor.d.ts.map +1 -1
- package/dist/dev.localdb.context.descriptor.d.ts +15 -1
- package/dist/dev.localdb.context.descriptor.d.ts.map +1 -1
- package/dist/dev.localdb.context.descriptor.js +1 -1
- package/dist/dev.localdb.context.descriptor.js.map +1 -1
- package/dist/dev.localdb.context.impl.consumer.d.ts +12 -5
- package/dist/dev.localdb.context.impl.consumer.d.ts.map +1 -1
- package/dist/dev.localdb.context.impl.consumer.js +14 -5
- package/dist/dev.localdb.context.impl.consumer.js.map +1 -1
- package/dist/driver/sqlite-driver.base.d.ts +107 -0
- package/dist/driver/sqlite-driver.base.d.ts.map +1 -0
- package/dist/driver/sqlite-driver.base.js +426 -0
- package/dist/driver/sqlite-driver.base.js.map +1 -0
- package/dist/driver/sqlite-driver.d.ts +154 -0
- package/dist/driver/sqlite-driver.d.ts.map +1 -0
- package/dist/driver/sqlite-driver.js +103 -0
- package/dist/driver/sqlite-driver.js.map +1 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/localdb.client.descriptor.d.ts +10 -0
- package/dist/localdb.client.descriptor.d.ts.map +1 -0
- package/dist/localdb.client.descriptor.js +13 -0
- package/dist/localdb.client.descriptor.js.map +1 -0
- package/dist/localdb.client.impl.consumer.d.ts +11 -0
- package/dist/localdb.client.impl.consumer.d.ts.map +1 -0
- package/dist/localdb.client.impl.consumer.js +11 -0
- package/dist/localdb.client.impl.consumer.js.map +1 -0
- package/dist/localdb.plugin.d.ts +2 -2
- package/dist/localdb.plugin.d.ts.map +1 -1
- package/dist/localdb.plugin.js +58 -10
- package/dist/localdb.plugin.js.map +1 -1
- package/dist/location.d.ts +6 -0
- package/dist/location.d.ts.map +1 -0
- package/dist/location.js +6 -0
- package/dist/location.js.map +1 -0
- package/dist/migrations/load-sql.d.ts +25 -21
- package/dist/migrations/load-sql.d.ts.map +1 -1
- package/dist/migrations/load-sql.js +91 -76
- package/dist/migrations/load-sql.js.map +1 -1
- package/dist/pool.d.ts +3 -0
- package/dist/pool.d.ts.map +1 -0
- package/dist/pool.js +3 -0
- package/dist/pool.js.map +1 -0
- package/dist/schemas/api.d.ts +133 -59
- package/dist/schemas/api.d.ts.map +1 -1
- package/dist/schemas/api.js +1 -1
- package/dist/schemas/api.js.map +1 -1
- package/dist/schemas/migrations.d.ts +123 -0
- package/dist/schemas/migrations.d.ts.map +1 -0
- package/dist/schemas/migrations.js +55 -0
- package/dist/schemas/migrations.js.map +1 -0
- package/dist/schemas/pool.d.ts +27 -0
- package/dist/schemas/pool.d.ts.map +1 -0
- package/dist/schemas/pool.js +7 -0
- package/dist/schemas/pool.js.map +1 -0
- package/dist/schemas/sqlite-driver-base.d.ts +50 -0
- package/dist/schemas/sqlite-driver-base.d.ts.map +1 -0
- package/dist/schemas/sqlite-driver-base.js +2 -0
- package/dist/schemas/sqlite-driver-base.js.map +1 -0
- package/dist/testing/sqlite-driver-conformance.d.ts +42 -0
- package/dist/testing/sqlite-driver-conformance.d.ts.map +1 -0
- package/dist/testing/sqlite-driver-conformance.js +471 -0
- package/dist/testing/sqlite-driver-conformance.js.map +1 -0
- package/package.json +78 -104
- package/dist/core/error-context.d.ts +0 -2
- package/dist/core/error-context.d.ts.map +0 -1
- package/dist/core/error-context.js +0 -17
- package/dist/core/error-context.js.map +0 -1
- package/dist/core/runtime.d.ts +0 -3
- package/dist/core/runtime.d.ts.map +0 -1
- package/dist/core/runtime.js +0 -17
- package/dist/core/runtime.js.map +0 -1
- package/dist/localdb.cloud.plugin.d.ts +0 -17
- package/dist/localdb.cloud.plugin.d.ts.map +0 -1
- package/dist/localdb.cloud.plugin.js +0 -23
- package/dist/localdb.cloud.plugin.js.map +0 -1
- package/src/cli/commands/_shared.consumer.ts +0 -20
- package/src/cli/commands/migrate/dev.localdb.migrate.cli-command.descriptor.ts +0 -20
- package/src/cli/commands/migrate/dev.localdb.migrate.cli-command.impl.consumer.ts +0 -97
- package/src/cli/commands/namespaces/dev.localdb.namespaces.cli-command.descriptor.ts +0 -17
- package/src/cli/commands/namespaces/dev.localdb.namespaces.cli-command.impl.ts +0 -46
- package/src/cli/commands/pools/dev.localdb.pools.cli-command.descriptor.ts +0 -16
- package/src/cli/commands/pools/dev.localdb.pools.cli-command.impl.ts +0 -37
- package/src/cli/commands/query/dev.localdb.query.cli-command.descriptor.ts +0 -21
- package/src/cli/commands/query/dev.localdb.query.cli-command.impl.consumer.ts +0 -69
- package/src/cli/commands/status/dev.localdb.status.cli-command.descriptor.ts +0 -20
- package/src/cli/commands/status/dev.localdb.status.cli-command.impl.consumer.ts +0 -93
- package/src/core/connection-pool.ts +0 -240
- package/src/core/database.ts +0 -419
- package/src/core/error-context.ts +0 -19
- package/src/core/migration-registry.ts +0 -321
- package/src/core/runtime.ts +0 -17
- package/src/dev.localdb.cli-group.descriptor.ts +0 -20
- package/src/dev.localdb.context.descriptor.ts +0 -13
- package/src/dev.localdb.context.impl.consumer.ts +0 -12
- package/src/index.ts +0 -28
- package/src/localdb.cloud.plugin.ts +0 -24
- package/src/localdb.plugin.ts +0 -43
- package/src/migrations/atlas/001-initial-schema.sql +0 -173
- package/src/migrations/atlas/002-lang-server-fields.sql +0 -12
- package/src/migrations/atlas/003-config-id-dedup.sql +0 -31
- package/src/migrations/atlas/004-fix-on-conflict-constraints.sql +0 -25
- package/src/migrations/atlas/005-diagnostics.sql +0 -66
- package/src/migrations/atlas/006-diagnostic-summaries.sql +0 -65
- package/src/migrations/atlas/007-diagnostic-slice.sql +0 -83
- package/src/migrations/load-sql.ts +0 -133
- package/src/schemas/api.ts +0 -92
package/dist/core/database.js
CHANGED
|
@@ -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', '
|
|
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 {
|
|
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 {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
await mkdir(dirname(this.options.dbPath), { recursive: true });
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
319
|
+
this.requirePool().releaseConnection(runtime, this.options.dbPath);
|
|
88
320
|
}
|
|
89
321
|
else {
|
|
90
|
-
|
|
91
|
-
this.db
|
|
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.
|
|
99
|
-
* 3.
|
|
100
|
-
* 4. Re-
|
|
101
|
-
*
|
|
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
|
-
|
|
345
|
+
this.requirePool().closeConnection(this.runtime, this.options.dbPath, true);
|
|
107
346
|
}
|
|
108
347
|
else {
|
|
109
348
|
this.db.close();
|
|
110
349
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
async
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
174
|
-
db.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
606
|
+
this.physicalPool ??= await this.poolResolution;
|
|
607
|
+
return this.physicalPool;
|
|
247
608
|
}
|
|
248
609
|
finally {
|
|
249
|
-
|
|
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
|
-
*
|
|
257
|
-
*
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|