@jarenjs/db 0.85.0 → 0.86.0

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/ARCHITECTURE.md CHANGED
@@ -1196,3 +1196,10 @@ SQLite-semantic and never enter the JSON residual evaluator. See
1196
1196
  does not import runtime owners. `/query`, `/model` and `/entity` expose those
1197
1197
  mechanisms without the root store import. `compileEntityModel` performs one model
1198
1198
  normalization for both entities and mapping; openStore reuses that result.
1199
+
1200
+ `drivers/worker-client.js` and `drivers/sqlite-endpoint.js` own the shared bounded
1201
+ RPC contract. Thread and process entries supply their transports. The process
1202
+ driver retains owner credits until OS exit, fences lost generations, and combines
1203
+ observed native transaction state with pending statement metadata to distinguish
1204
+ rollback from an unknown commit. Supervision never serializes callbacks or replays
1205
+ transactions; Store ownership remains in the existing driver/Store gates.
package/README.md CHANGED
@@ -888,7 +888,8 @@ oracle run on both. What differs is declared rather than discovered:
888
888
  | `ALTER TABLE` | additive only | full, so a rebuild is never needed |
889
889
 
890
890
  Neither backend has a **statement timeout** (`capabilities.statementTimeout`
891
- is `false` on both) or **row estimates**. There is no replication.
891
+ is `false` on both) or **row estimates**. Portable replication uses SQLite
892
+ change capture; PostgreSQL capture and replication remain unqualified.
892
893
  Cross-process concurrency on SQLite is its own story — WAL plus a busy
893
894
  timeout, both set and visible on `store.capabilities`; on PostgreSQL it
894
895
  is the server's, and a serialization failure or a deadlock arrives as
@@ -1211,16 +1212,18 @@ same report rows — with the one difference the PostgreSQL mapping
1211
1212
  states: `numeric` carries both JSON number types, so an `integer`
1212
1213
  member reads back as `number`.
1213
1214
 
1214
- ## Sync-readiness — what exists and what does not
1215
+ ## Replication and external-write boundaries
1215
1216
 
1216
- The change stream is an ordered log of RFC 6902 patches with a
1217
- monotonic sequence, and SQLite's own changeset/conflict primitives are
1218
- available which is what a replication protocol would be *built
1219
- from*. **No replication is shipped.** There is no conflict resolution,
1220
- no site identity, no causal ordering across writers, and no capture of
1221
- writes made by another connection (the coarse `dataVersion()` signal
1222
- is the honest mitigation, not a pretend fine-grained one). Building
1223
- replication on these primitives is a roadmap item, not a hint.
1217
+ The portable replication engine supplies replica identities, causal frontiers,
1218
+ bounded logical envelopes, durable replay receipts, conflict evidence and snapshot
1219
+ resets. Data and acknowledgements commit together; hosts supply transport and any
1220
+ conflict resolver. The default preserves both contenders and rejects the write.
1221
+ See [REPLICATION-FORMAT](docs/REPLICATION-FORMAT.md) for the current contract.
1222
+
1223
+ Capture observes participating Store writes on supported SQLite hosts. Arbitrary
1224
+ writes from another connection are not fine-grained capture events; `dataVersion()`
1225
+ provides coarse invalidation. Adopted triggers, unsupported layouts and PostgreSQL
1226
+ retain the explicit capture and replication qualification limits.
1224
1227
 
1225
1228
  ## Operating a store — configuration, maintenance, backup, cancellation, the queue
1226
1229
 
@@ -1311,10 +1314,11 @@ its side-effect-free status read are in
1311
1314
 
1312
1315
  ## What this is not — every non-claim in one place
1313
1316
 
1314
- - **SQLite only.** One backend (3.45+); the dialect seam is tested
1315
- against a double but no second dialect ships. No server.
1316
- - **No replication or sync engine** (see above). No cross-connection
1317
- change capture another connection's writes are invisible locally.
1317
+ - **SQLite and PostgreSQL have different capabilities.** Both backends ship;
1318
+ PostgreSQL does not provide the SQLite capture, replication, job queue or pragma
1319
+ maintenance capabilities. See the dialect table and normative host contracts.
1320
+ - **Replication transport is supplied by the host.** Portable logical replication
1321
+ ships; arbitrary external writes are not captured as local row events.
1318
1322
  - **No statement timeout** on SQLite (the drivers expose no interrupt;
1319
1323
  the capability slot is honestly `false`), no row estimates.
1320
1324
  - **Not safe for mutually hostile tenants** without the profile's
@@ -1359,6 +1363,7 @@ Every subpath a consumer can import, derived from the manifest by
1359
1363
  <!--fact:exports.db-->
1360
1364
  | Import | Kind | Declarations |
1361
1365
  |---|---|---|
1366
+ | `@jarenjs/db/node-process` | JavaScript | declared |
1362
1367
  | `@jarenjs/db/search` | JavaScript | declared |
1363
1368
  | `@jarenjs/db` | JavaScript | declared |
1364
1369
  | `@jarenjs/db/node` | JavaScript | declared |
@@ -1442,3 +1447,8 @@ partial conflicts, raw-text JSON queries and byte-valued operations use
1442
1447
  `snapshotDatabase(connection, newPath)` for a disk-backed committed-WAL copy.
1443
1448
  Existing-connection consumers can import `/query`, `/model` and `/entity`;
1444
1449
  `compileEntityModel` shares one normalization between queries and mapping.
1450
+
1451
+ `@jarenjs/db/node-process` adds supervised native execution with finite owner
1452
+ admission, caller deadlines, generation fencing and separate process-exit and
1453
+ transaction-fate observations. See [execution hosts](docs/HOSTS.md#supervised-node-processes)
1454
+ for Store composition, receipt reconciliation and operating-system limits.
package/docs/HOSTS.md CHANGED
@@ -77,6 +77,132 @@ run on the caller. Synchronous Store methods and live queries are unavailable on
77
77
  these asynchronous connections. Bun can import both subpaths; opening a Node
78
78
  SQLite worker there reports the named unavailable-binding failure (`JD0003`).
79
79
 
80
+ ## Supervised Node processes
81
+
82
+ `nodeProcessDriver` from `@jarenjs/db/node-process` implements the same asynchronous
83
+ Driver and Connection contracts using one Node child process per connection. It
84
+ shares the worker protocol, cursor credits, transaction scopes and error handling
85
+ with the thread driver. SQLite and its native calls execute in the child; callbacks
86
+ and decoded query residuals still execute in the parent. No function is serialized.
87
+ Node.js 24 or newer is required. Bun can import the entry but `open` refuses with
88
+ `JD0003`. The native-call and process-lifecycle fixtures qualify Node 24.20.0 on
89
+ Linux; other supported operating systems require their own timing qualification.
90
+
91
+ ```js
92
+ import { nodeProcessDriver } from '@jarenjs/db/node-process';
93
+
94
+ const driver = nodeProcessDriver({ maxOwners: 2, timeoutMs: 250 });
95
+ const owner = await driver.open('application.sqlite', { timeout: 50, queueTimeout: 100 });
96
+ try {
97
+ const result = await owner.supervise(async (connection) => {
98
+ return connection.transaction(async (scope) => {
99
+ const statement = await scope.prepare('SELECT value FROM settings WHERE key = ?');
100
+ return statement.get(['theme']);
101
+ });
102
+ }, { signal: requestSignal, timeoutMs: 250 });
103
+ useResult(result);
104
+ } catch (error) {
105
+ if (error.code === 'JD2097' || error.code === 'JD2090') {
106
+ const observation = owner.settlement();
107
+ // This snapshot can still say quarantined / safeToReplace:false.
108
+ recordOwnerObservation(observation);
109
+ const exited = await owner.settled();
110
+ // Before repeating an uncertain write, inspect its durable receipt.
111
+ reconcileBeforeRetry(exited);
112
+ } else throw error;
113
+ } finally {
114
+ // Preserve the original operation outcome if closing a fenced generation fails.
115
+ await owner.close().catch(recordCleanupFailure);
116
+ }
117
+ ```
118
+
119
+ `supervise(body,{signal,timeoutMs})` admits one supervised callback per connection.
120
+ An overlapping callback refuses with `JD2091`; an already-aborted call runs no
121
+ callback and leaves the owner healthy. A deadline or abort rejects with `JD2097`,
122
+ fences all pending calls and signals process termination. Later operations on the
123
+ old generation fail with `JD2090`. A normal callback failure propagates unchanged
124
+ and does not terminate a healthy owner. `cancel(reason)` explicitly fences the
125
+ whole connection and returns its cancellation error. It affects every call sharing
126
+ that owner, so independent request lifetimes should use separate owners.
127
+
128
+ This is response cancellation and owner termination, not a SQLite statement
129
+ interrupt. `capabilities.process` and `ownerTermination` are true, while
130
+ `capabilities.cancellation.midStatement` remains false. The supervision timer cannot
131
+ preempt synchronous parent JavaScript or bound operating-system scheduling. Keep
132
+ callbacks/residuals bounded and await all admitted work. Native fixtures use a
133
+ short deadline and independently enforce a one-second caller-response ceiling;
134
+ that tested ceiling is not a universal scheduling guarantee.
135
+
136
+ `settlement()` reports the generation, PID, health, transaction fate and
137
+ `safeToReplace`. `settled()` resolves only after the child's OS exit event. An
138
+ owner awaiting termination is quarantined and still consumes its credit. If the
139
+ OS cannot settle a process promptly, the response can finish while the owner
140
+ remains quarantined; neither capacity nor writer ownership is recycled. `restart()`
141
+ waits for confirmed exit and returns a new generation; old Store handles remain
142
+ invalid. No statement, transaction or callback is automatically replayed.
143
+
144
+ The transaction observation is conservative:
145
+
146
+ - A live, acknowledged native transaction is `active`.
147
+ - An acknowledged single-statement completion is `committed`, or `rolled-back`
148
+ after rollback. A multi-statement program with no open transaction remains
149
+ `unknown`: an acknowledgement alone cannot distinguish its internal boundaries.
150
+ - Confirmed process death rolls back an observed open transaction only when no
151
+ pending operation could have committed it.
152
+ - A lost commit reply, multi-statement program or uncertain autocommit mutation
153
+ is `unknown`. Native transaction-state support is observed, never inferred from
154
+ a close promise. Reopen, verify integrity and reconcile a durable receipt before
155
+ retrying a write with an unknown outcome.
156
+
157
+ Autocommit mutation cursors remain `unknown` until their final native frame is
158
+ acknowledged. Creating an iterator or receiving an intermediate `RETURNING` row
159
+ does not acknowledge completion; early cursor return still requires reconciliation.
160
+
161
+ Driver `metrics()` reports capacity, owners, healthy owners and quarantine. A
162
+ file path already owned by the same driver cannot be opened again until exit;
163
+ this reservation uses its resolved path spelling, not filesystem inode identity.
164
+ Different driver instances, aliases and external processes remain the host's
165
+ coordination responsibility. Use one supervisor per ownership domain and retain
166
+ finite SQLite busy and transaction-queue timeouts. The host's service manager must
167
+ also own its process group: an uncatchable parent-process death is not a promise
168
+ that JavaScript can reap every descendant. Child crashes and restart are covered
169
+ by the file/receipt fixtures; power loss and service-manager policy are separate.
170
+
171
+ The ordinary Store composition uses `openStore(model,{driver,path})`. To supervise
172
+ Store calls, retain the connection in a small driver wrapper's `open` method, then
173
+ call `owner.supervise(() => store.transaction(body), options)`. This uses the same
174
+ Store and transaction APIs. After owner loss, close the invalid Store, await owner
175
+ exit and explicitly reopen/reconcile. A failed-generation `close()` may reject;
176
+ its rejection never substitutes for the separate exit observation.
177
+
178
+ All options below are positive safe integers. Row/byte/statement/cursor limits
179
+ retain the thread worker meanings. `maxOwners` includes startup and quarantine;
180
+ `timeoutMs` is the default supervised response deadline. `maxRequestBytes` bounds
181
+ each outgoing request before IPC; parameters must be SQLite scalars or byte arrays.
182
+ The process close deadline
183
+ and startup deadline can reject while termination is still pending. The reserved
184
+ owner credit remains until exit. Direct calls without `supervise`
185
+ retain the ordinary row, queue and step cancellation boundaries.
186
+
187
+ <!--fact:db.execution-options-->
188
+
189
+ | Option | Thread worker | Process owner |
190
+ |---|---:|---:|
191
+ | `windowRows` | 64 | 64 |
192
+ | `windowBytes` | 1048576 | 1048576 |
193
+ | `maxPending` | 64 | 64 |
194
+ | `maxStatements` | 1024 | 1024 |
195
+ | `maxCursors` | 64 | 64 |
196
+ | `allMaxRows` | 100000 | 100000 |
197
+ | `allMaxBytes` | 16777216 | 16777216 |
198
+ | `closeTimeoutMs` | 5000 | 1000 |
199
+ | `startupTimeoutMs` | 10000 | 10000 |
200
+ | `maxOwners` | — | 4 |
201
+ | `timeoutMs` | — | 250 |
202
+ | `maxRequestBytes` | — | 1048576 |
203
+
204
+ <!--/fact-->
205
+
80
206
  ## WAL pool policy
81
207
 
82
208
  A file pool opens exactly one writer and `readers` read-only workers, verifies WAL,
@@ -283,9 +409,10 @@ sync twin. Physical adoption on PostgreSQL refuses pending a separate mapping
283
409
  and codec qualification. Unknown application-trigger effects do not qualify
284
410
  capture or replication; those combinations refuse before an adoption claim.
285
411
 
286
- Node backups use the built-in online snapshot. Bun uses `Database.serialize()`
287
- under the store gate, writes and flushes a sibling temporary, then uses the shared
288
- atomic publisher. Both include committed WAL. Bun's snapshot holds the whole
289
- image in memory and cancellation takes effect between phases. Process-kill tests
412
+ Node backups use the built-in online snapshot. Bun uses disk-backed `VACUUM INTO`
413
+ under the store gate, flushes a sibling temporary, then uses the shared atomic
414
+ publisher. Both include committed WAL. Bun allocates no whole-database JavaScript
415
+ image; native SQLite caches and temporary storage govern working memory, and
416
+ cancellation takes effect between phases. Process-kill tests
290
417
  cover rebuild copy, table drop, commit and backup publication on both hosts;
291
418
  these tests do not establish power-loss durability or native executable packaging.
@@ -1101,6 +1101,7 @@ error.
1101
1101
  | `JD2094` | invalid or uncommitted durable snapshot; reopen the last committed version |
1102
1102
  | `JD2095` | trusted SQL or synchronous callback authority refused |
1103
1103
  | `JD2096` | persistence invariant rejected the mutation; constraint class |
1104
+ | `JD2097` | supervised native operation cancelled or past its response deadline; owner exit and transaction fate are reported separately |
1104
1105
  | `JD0060` | a replication envelope or snapshot is invalid |
1105
1106
  | `JD2100` | a replica sequence or causal dependency has a gap |
1106
1107
  | `JD2101` | an envelope identity names different content or an unknown local origin |
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@jarenjs/db",
3
3
  "private": false,
4
- "version": "0.85.0",
4
+ "version": "0.86.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./types/index.d.ts",
8
8
  "sideEffects": false,
9
9
  "exports": {
10
+ "./node-process": {
11
+ "types": "./types/node-process.d.ts",
12
+ "default": "./src/drivers/node-process.js"
13
+ },
10
14
  "./search": {
11
15
  "types": "./types/search.d.ts",
12
16
  "default": "./src/search.js"
@@ -104,9 +108,9 @@
104
108
  "prepack": "npm run build:types"
105
109
  },
106
110
  "dependencies": {
107
- "@jarenjs/core": "^0.85.0",
108
- "@jarenjs/json": "^0.85.0",
109
- "@jarenjs/validate": "^0.85.0"
111
+ "@jarenjs/core": "^0.86.0",
112
+ "@jarenjs/json": "^0.86.0",
113
+ "@jarenjs/validate": "^0.86.0"
110
114
  },
111
115
  "bin": {
112
116
  "jaren-db": "./src/cli.js"
package/src/driver.js CHANGED
@@ -292,6 +292,8 @@ export function baseCapabilities() {
292
292
  sessions: false,
293
293
  sessionReason: null,
294
294
  worker: false,
295
+ process: false,
296
+ ownerTermination: false,
295
297
  pooling: false,
296
298
  poolReaders: 0,
297
299
  poolWriters: 0,
@@ -809,6 +811,7 @@ export function finishConnection(raw, dialect, synchronous, capabilities, queueT
809
811
  if (pending.some(isThenable)) return closeResult = Promise.allSettled(pending).then(() => raw.close());
810
812
  return closeResult = raw.close();
811
813
  },
814
+ transactionState: () => raw.transactionState?.() ?? null,
812
815
  registerFunction: typeof raw.registerFunction === 'function'
813
816
  ? (name, functionOptions, fn) => { requireOpen(); return raw.registerFunction(name, functionOptions, fn); }
814
817
  : null,
@@ -0,0 +1,13 @@
1
+ //@ts-check
2
+ /** The child owns SQLite; only bounded protocol frames cross IPC. */
3
+ import { serveSqliteEndpoint } from './sqlite-endpoint.js';
4
+
5
+ process.once('message', (configuration) => {
6
+ const port = {
7
+ postMessage: (message) => { if (process.connected) process.send(message); },
8
+ on: (name, listener) => process.on(name, listener),
9
+ close: () => { if (process.connected) process.disconnect(); },
10
+ };
11
+ serveSqliteEndpoint(port, configuration);
12
+ });
13
+ process.once('disconnect', () => process.exit(0));
@@ -0,0 +1,177 @@
1
+ //@ts-check
2
+ /** Supervised process ownership; response cancellation never implies SQL rollback. */
3
+ import { sqliteDialect } from '../dialects/sqlite.js';
4
+ import { sqlTokens } from '../dialects/check-read.js';
5
+ import { DbCompileError, DbRuntimeError } from '../errors.js';
6
+ import { createWorkerConnection } from './worker-client.js';
7
+ import { positiveOption, queueFailure, rowBytes, workerSettings, PROCESS_DEFAULTS } from './worker-protocol.js';
8
+
9
+ /**
10
+ * Finite owned SQLite processes behind the ordinary Driver contract.
11
+ * A quarantined process retains its admission credit until its exit event.
12
+ * @param {import('../../types/node-process.js').NodeProcessOptions} [configuration]
13
+ * @returns {import('../../types/node-process.js').NodeProcessDriver}
14
+ */
15
+ export function nodeProcessDriver(configuration = {}) {
16
+ const maxOwners = positiveOption('maxOwners', configuration.maxOwners, PROCESS_DEFAULTS.maxOwners);
17
+ const timeoutMs = positiveOption('timeoutMs', configuration.timeoutMs, PROCESS_DEFAULTS.timeoutMs);
18
+ const maxRequestBytes = positiveOption('maxRequestBytes', configuration.maxRequestBytes, PROCESS_DEFAULTS.maxRequestBytes);
19
+ const settings = workerSettings(configuration, PROCESS_DEFAULTS);
20
+ const { limits } = settings;
21
+ const owners = new Set();
22
+ let generation = 0;
23
+ const driver = {
24
+ name: 'node-process-sqlite', dialect: sqliteDialect,
25
+ metrics: () => Object.freeze({ capacity: maxOwners, owners: owners.size,
26
+ quarantined: [...owners].filter((owner) => owner.status === 'quarantined').length,
27
+ healthy: [...owners].filter((owner) => owner.status === 'healthy').length }),
28
+ async open(path = ':memory:', options = {}) {
29
+ if (globalThis.process?.versions?.bun || globalThis.process?.release?.name !== 'node'
30
+ || Number(process.versions.node.split('.')[0]) < 24)
31
+ throw new DbCompileError('JD0003', 'supervised SQLite processes require Node.js 24 or newer');
32
+ const [{ fork }, { resolve }] = await Promise.all([import('node:child_process'), import('node:path')]);
33
+ const identity = path === ':memory:' ? null : resolve(path);
34
+ if (owners.size >= maxOwners || identity && [...owners].some((owner) => owner.path === identity))
35
+ throw queueFailure('process owner capacity or database ownership is reserved', owners.size);
36
+ const epoch = ++generation;
37
+ const state = { path: identity, status: 'starting', transaction: 'none', generation: epoch, pid: null,
38
+ safeToReplace: false, exitCode: null, exitSignal: null };
39
+ owners.add(state);
40
+ let child;
41
+ try {
42
+ child = fork(new URL('./node-process-endpoint.js', import.meta.url), [], {
43
+ serialization: 'advanced', stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
44
+ execArgv: ['--no-warnings=ExperimentalWarning'],
45
+ });
46
+ }
47
+ catch (error) { owners.delete(state); throw error; }
48
+ state.pid = child.pid ?? null;
49
+ let resolveExit, control, killPromise, active = false, closeAcknowledged = false;
50
+ let transactionOpen = false, ambiguous = false;
51
+ const prepared = new Map();
52
+ const cursors = new Map();
53
+ const report = () => Object.freeze({ ...state });
54
+ const exited = new Promise((resolve) => { resolveExit = resolve; });
55
+ child.once('exit', (code, signal) => {
56
+ state.status = 'exited'; state.safeToReplace = true;
57
+ state.exitCode = code; state.exitSignal = signal;
58
+ queueMicrotask(() => {
59
+ if (ambiguous) state.transaction = 'unknown';
60
+ else if (transactionOpen) state.transaction = 'rolled-back';
61
+ owners.delete(state); resolveExit(report());
62
+ });
63
+ });
64
+ // A failed spawn has no owner to quarantine and emits no exit event.
65
+ child.once('error', () => {
66
+ if (child.pid === undefined) {
67
+ state.status = 'exited'; state.safeToReplace = true;
68
+ owners.delete(state); resolveExit(report());
69
+ }
70
+ });
71
+ const terminate = () => {
72
+ if (!killPromise) {
73
+ if (state.status !== 'exited' && !closeAcknowledged) { state.status = 'quarantined'; child.kill('SIGKILL'); }
74
+ killPromise = exited;
75
+ }
76
+ return killPromise;
77
+ };
78
+ const transport = {
79
+ on: (name, fn) => child.on(name, fn),
80
+ postMessage: (message) => child.send(message, (error) => { if (error) control?.lose(error); }),
81
+ terminate, unref: () => {},
82
+ };
83
+ const info = (sql) => {
84
+ const tokens = sqlTokens(sql), words = tokens.filter((token) => token.kind === 'word').map((token) => token.value.toUpperCase());
85
+ const multiple = tokens.some((token, i) => token.kind === 'symbol' && token.value === ';' && i < tokens.length - 1);
86
+ return { multiple, end: multiple || ['COMMIT', 'END', 'RELEASE'].includes(words[0]),
87
+ rollback: words[0] === 'ROLLBACK' && !words.includes('TO'),
88
+ writes: multiple || !['SELECT', 'WITH'].includes(words[0]) || words.some((word) => ['INSERT', 'UPDATE', 'DELETE', 'REPLACE'].includes(word)) };
89
+ };
90
+ const unknownStatement = { multiple: true, writes: true, end: true };
91
+ const execution = (op, data) => op === 'exec' ? info(data.sql)
92
+ : ['next', 'return'].includes(op) ? cursors.get(data.cursor) ?? unknownStatement
93
+ : ['run', 'get', 'iterate'].includes(op) ? prepared.get(data.statement) ?? unknownStatement : null;
94
+ const observe = (op, data, open, failed = false, statement = execution(op, data)) => {
95
+ if (typeof open !== 'boolean') return;
96
+ const wasOpen = transactionOpen;
97
+ transactionOpen = open;
98
+ if (open) { ambiguous = false; state.transaction = 'active'; }
99
+ else if (statement?.multiple || [...cursors.values()].some((cursor) => cursor.writes)
100
+ || op === 'return' && statement?.writes) { ambiguous = true; state.transaction = 'unknown'; }
101
+ else if (failed && wasOpen || statement?.rollback) {
102
+ ambiguous = false; state.transaction = 'rolled-back';
103
+ }
104
+ else if (!failed && statement && (wasOpen || ambiguous && statement.writes)) { ambiguous = false; state.transaction = 'committed'; }
105
+ };
106
+ const hooks = {
107
+ control: (value) => { control = value; },
108
+ lost: (error, pending) => {
109
+ const live = pending.map(({ op, data }) => execution(op, data)).filter(Boolean);
110
+ ambiguous = live.some((statement) => statement.end || statement.writes && !transactionOpen)
111
+ || !transactionOpen && [...cursors.values()].some((statement) => statement.writes);
112
+ if (ambiguous) state.transaction = 'unknown';
113
+ if (ambiguous || transactionOpen) error.retryable = false;
114
+ if (state.status !== 'exited') { state.status = 'quarantined'; terminate(); }
115
+ },
116
+ request: (op, data) => {
117
+ if (data.params?.some((param) => param !== null && !['number', 'bigint', 'string'].includes(typeof param) && !(param instanceof Uint8Array)))
118
+ throw new DbRuntimeError('JD2093', 'process parameters must be SQLite scalars or byte arrays');
119
+ if (rowBytes(data) > maxRequestBytes) throw new DbRuntimeError('JD2092', 'process request exceeds its byte credit');
120
+ const statement = execution(op, data);
121
+ if (statement && (statement.end || statement.writes && !transactionOpen)) {
122
+ ambiguous = true; state.transaction = 'unknown';
123
+ }
124
+ },
125
+ failure: (op, data, open) => observe(op, data, open, true),
126
+ result: (op, data, value, open) => {
127
+ const statement = execution(op, data);
128
+ if (op === 'close') closeAcknowledged = true;
129
+ if (op === 'prepare') {
130
+ if (prepared.size >= limits.statements) prepared.delete(prepared.keys().next().value);
131
+ prepared.set(value, info(data.sql));
132
+ }
133
+ if (op === 'iterate') cursors.set(value, statement);
134
+ if (op === 'return' || op === 'next' && value.done) cursors.delete(data.cursor);
135
+ observe(op, data, open, false, statement);
136
+ },
137
+ };
138
+ const opening = createWorkerConnection(transport, { ...settings, epoch, options, hooks, awaitStartupExit: false,
139
+ reopen: () => driver.open(path, options) });
140
+ child.send({ generation: epoch, path, options: { timeout: options.timeout, readOnly: options.readOnly }, limits });
141
+ const connection = await opening;
142
+ state.status = 'healthy';
143
+ const cancel = (reason = 'caller cancelled') => {
144
+ const error = Object.assign(new DbRuntimeError('JD2097', String(reason)), {
145
+ generation: epoch, retryable: false, class: 'cancelled',
146
+ });
147
+ control.lose(error); terminate(); return error;
148
+ };
149
+ const result = Object.freeze({ ...connection,
150
+ get mustQueue() { return connection.mustQueue; },
151
+ capabilities: Object.freeze({ ...connection.capabilities, worker: false, process: true,
152
+ ownerTermination: true, cancellation: Object.freeze({ ...connection.capabilities.cancellation, midStatement: false }) }),
153
+ settlement: report, settled: () => exited, cancel,
154
+ metrics: () => Object.freeze({ ...connection.metrics(), owner: report(), supervised: active ? 1 : 0 }),
155
+ async supervise(body, request = {}) {
156
+ if (active) throw queueFailure('one supervised operation already owns this process', 1);
157
+ const bound = positiveOption('timeoutMs', request.timeoutMs, timeoutMs);
158
+ if (request.signal?.aborted) throw Object.assign(new DbRuntimeError('JD2097', 'cancelled before admission'), { generation: epoch, retryable: true, class: 'cancelled' });
159
+ if (state.status !== 'healthy') throw new DbRuntimeError('JD2090', 'process owner is not healthy');
160
+ active = true;
161
+ let timer, abort;
162
+ try {
163
+ return await new Promise((resolve, reject) => {
164
+ abort = () => { const error = cancel('supervised operation was cancelled'); reject(error); };
165
+ request.signal?.addEventListener('abort', abort, { once: true });
166
+ timer = setTimeout(() => { const error = cancel('supervised operation exceeded its response deadline'); reject(error); }, bound);
167
+ Promise.resolve().then(() => body(result)).then(resolve, reject);
168
+ });
169
+ }
170
+ finally { active = false; clearTimeout(timer); request.signal?.removeEventListener('abort', abort); }
171
+ },
172
+ });
173
+ return result;
174
+ },
175
+ };
176
+ return Object.freeze(driver);
177
+ }
@@ -1,105 +1,5 @@
1
1
  //@ts-check
2
- /** One worker owns one SQLite handle. Only bounded credit frames carry rows. */
2
+ /** A thread owns one SQLite endpoint and its bounded protocol frames. */
3
3
  import { parentPort, workerData } from 'node:worker_threads';
4
- import { nodeDriver } from './node.js';
5
- import { DbRuntimeError, cloneDriverError } from '../errors.js';
6
- import { validRequest, generationFailure, rowBytes } from './worker-protocol.js';
7
-
8
- const { generation, path, options, limits } = workerData;
9
- let connection;
10
- let sequence = 0;
11
- const statements = new Map();
12
- const cursors = new Map();
13
- const frame = (kind, id, value) => ({ v: 1, generation, kind, id, ...value });
14
- const bound = (reason) => new DbRuntimeError('JD2092', reason);
15
- const release = (id) => {
16
- const cursor = cursors.get(id);
17
- if (cursor === undefined) return;
18
- cursors.delete(id);
19
- cursor.iterator.return?.();
20
- if (cursor.ephemeral) statements.delete(cursor.statement);
21
- };
22
- const statementOf = (id) => {
23
- const statement = statements.get(id);
24
- if (statement === undefined) throw generationFailure(generation);
25
- return statement;
26
- };
27
- const dispatch = (request) => {
28
- switch (request.op) {
29
- case 'exec': return connection.exec(request.sql);
30
- case 'prepare': {
31
- if (statements.size >= limits.statements) throw bound(`worker statement capacity ${limits.statements} exceeded`);
32
- const id = ++sequence;
33
- statements.set(id, { statement: connection.prepare(request.sql), ephemeral: request.ephemeral === true });
34
- return id;
35
- }
36
- case 'get': {
37
- const row = statementOf(request.statement).statement.get(request.params);
38
- if (rowBytes(row) > limits.bytes) throw bound(`one worker row exceeds ${limits.bytes} bytes`);
39
- return row;
40
- }
41
- case 'run': return statementOf(request.statement).statement.run(request.params);
42
- case 'iterate': {
43
- if (cursors.size >= limits.cursors) throw bound(`worker cursor capacity ${limits.cursors} exceeded`);
44
- const record = statementOf(request.statement);
45
- const id = ++sequence;
46
- cursors.set(id, { iterator: record.statement.iterate(request.params),
47
- statement: request.statement, ephemeral: record.ephemeral, buffered: null });
48
- return id;
49
- }
50
- case 'next': {
51
- const cursor = cursors.get(request.cursor);
52
- if (cursor === undefined) throw generationFailure(generation);
53
- if (request.rows > limits.rows || request.bytes > limits.bytes) throw bound('worker cursor credit exceeds the negotiated window');
54
- const rows = [];
55
- let bytes = 0;
56
- try {
57
- while (rows.length < request.rows) {
58
- const step = cursor.buffered ?? cursor.iterator.next();
59
- cursor.buffered = null;
60
- if (step.done) { release(request.cursor); return { rows, bytes, done: true }; }
61
- const size = rowBytes(step.value);
62
- if (size > request.bytes) throw bound(`one worker row of ${size} bytes exceeds the ${request.bytes} byte window`);
63
- if (bytes + size > request.bytes) { cursor.buffered = step; break; }
64
- rows.push(step.value);
65
- bytes += size;
66
- }
67
- return { rows, bytes, done: false };
68
- }
69
- catch (error) { release(request.cursor); throw error; }
70
- }
71
- case 'return': release(request.cursor); return undefined;
72
- case 'finalize': {
73
- for (const [id, cursor] of cursors) if (cursor.statement === request.statement) release(id);
74
- statements.delete(request.statement);
75
- return undefined;
76
- }
77
- case 'close': {
78
- for (const id of cursors.keys()) release(id);
79
- statements.clear();
80
- return connection.close();
81
- }
82
- }
83
- };
84
- try {
85
- connection = await nodeDriver().open(path, options);
86
- // Functions cannot cross structured clone. Capture uses the existing
87
- // journal path; a live query requires a synchronous connection.
88
- parentPort.postMessage(frame('ready', 0, { capabilities: { ...connection.capabilities,
89
- sessions: false, userFunctions: false, deterministicIndexableFunctions: false,
90
- aggregateFunctions: false, backup: false, worker: true, pooling: false } }));
91
- parentPort.on('message', (request) => {
92
- try {
93
- if (!validRequest(request)) throw new DbRuntimeError('JD2093', 'invalid worker protocol request');
94
- if (request.generation !== generation) throw generationFailure(request.generation);
95
- const value = dispatch(request);
96
- parentPort.postMessage(frame('result', request.id, { value }));
97
- if (request.op === 'close') parentPort.close();
98
- }
99
- catch (error) { parentPort.postMessage(frame('failure', request?.id ?? 0, { error: cloneDriverError(error) })); }
100
- });
101
- }
102
- catch (error) {
103
- parentPort.postMessage(frame('failure', 0, { error: cloneDriverError(error) }));
104
- parentPort.close();
105
- }
4
+ import { serveSqliteEndpoint } from './sqlite-endpoint.js';
5
+ await serveSqliteEndpoint(parentPort, workerData);
@@ -1,9 +1,9 @@
1
1
  //@ts-check
2
2
  /** A worker transport behind the ordinary Connection contract. */
3
- import { finishConnection, lazyOpen } from '../driver.js';
3
+ import { lazyOpen } from '../driver.js';
4
4
  import { sqliteDialect } from '../dialects/sqlite.js';
5
- import { DbRuntimeError } from '../errors.js';
6
- import { generationFailure, positiveOption, queueFailure, rowBytes, validResponse, validResult } from './worker-protocol.js';
5
+ import { createWorkerConnection } from './worker-client.js';
6
+ import { workerSettings } from './worker-protocol.js';
7
7
 
8
8
  /** Dedicated SQLite worker per connection. No function serialization or write replay.
9
9
  * @param {{ windowRows?: number, windowBytes?: number, maxPending?: number,
@@ -12,17 +12,7 @@ import { generationFailure, positiveOption, queueFailure, rowBytes, validRespons
12
12
  * @returns {any} a Driver
13
13
  */
14
14
  export function nodeWorkerDriver(configuration = {}) {
15
- const limits = {
16
- rows: positiveOption('windowRows', configuration.windowRows, 64),
17
- bytes: positiveOption('windowBytes', configuration.windowBytes, 1024 * 1024),
18
- statements: positiveOption('maxStatements', configuration.maxStatements, 1024),
19
- cursors: positiveOption('maxCursors', configuration.maxCursors, 64),
20
- };
21
- const maxPending = positiveOption('maxPending', configuration.maxPending, 64);
22
- const allRows = positiveOption('allMaxRows', configuration.allMaxRows, 100000);
23
- const allBytes = positiveOption('allMaxBytes', configuration.allMaxBytes, 16 * 1024 * 1024);
24
- const closeMs = positiveOption('closeTimeoutMs', configuration.closeTimeoutMs, 5000);
25
- const startupMs = positiveOption('startupTimeoutMs', configuration.startupTimeoutMs, 10000);
15
+ const { limits, maxPending, allRows, allBytes, closeMs, startupMs } = workerSettings(configuration);
26
16
  let generation = 0;
27
17
  const driver = {
28
18
  name: 'node-worker-sqlite', dialect: sqliteDialect,
@@ -34,170 +24,8 @@ export function nodeWorkerDriver(configuration = {}) {
34
24
  // Parent --test/--input-type/preloads do not describe the endpoint.
35
25
  execArgv: ['--no-warnings=ExperimentalWarning'],
36
26
  });
37
- const pending = new Map();
38
- let sequence = 0;
39
- let failed = null;
40
- let closing = false;
41
- let closed = false;
42
- let transactionDepth = 0;
43
- let closePromise;
44
- const metrics = { frames: 0, rows: 0, maxFrameRows: 0, maxFrameBytes: 0, maxPending: 0 };
45
- let readyResolve;
46
- let readyReject;
47
- const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
48
- const lose = (cause) => {
49
- if (failed !== null || closed) return;
50
- failed = generationFailure(epoch, transactionDepth > 0, cause);
51
- readyReject(failed);
52
- for (const request of pending.values()) request.reject(failed);
53
- pending.clear();
54
- };
55
- worker.on('error', lose);
56
- worker.on('exit', (code) => { if (!closed) lose(new Error(`worker exited (${code})`)); });
57
- worker.on('message', (message) => {
58
- if (!validResponse(message, epoch)) { lose(new Error('invalid worker response')); return; }
59
- if (message.kind === 'ready') { readyResolve(message.capabilities); return; }
60
- if (message.kind === 'failure' && message.id === 0) {
61
- readyReject(Object.assign(new Error(message.error.message), message.error)); return;
62
- }
63
- const request = pending.get(message.id);
64
- if (request === undefined) return;
65
- pending.delete(message.id);
66
- if (message.kind === 'failure') {
67
- const error = message.error;
68
- request.reject(Object.assign(error.code?.startsWith('JD')
69
- ? new DbRuntimeError(error.code, error.message) : new Error(error.message), error));
70
- }
71
- else if (message.kind === 'result' && validResult(request.op, message.value, limits)) request.resolve(message.value);
72
- else { request.reject(generationFailure(epoch, transactionDepth > 0)); lose(new Error('invalid worker response')); }
73
- });
74
- const timer = setTimeout(() => { lose(new Error('worker startup timed out')); worker.terminate(); }, startupMs);
75
- let capabilities;
76
- try { capabilities = await ready; }
77
- catch (error) { await worker.terminate(); throw error; }
78
- finally { clearTimeout(timer); }
79
- const request = (op, data = {}, cleanup = false) => {
80
- if (failed !== null) return Promise.reject(failed);
81
- if (closed || (closing && !cleanup)) return Promise.reject(new DbRuntimeError('JD2063', 'the worker connection is closing or closed'));
82
- if (!cleanup && pending.size >= maxPending)
83
- return Promise.reject(queueFailure(`worker request capacity ${maxPending} exceeded`, pending.size));
84
- // One return per live cursor and one close have reserved capacity.
85
- if (cleanup && pending.size >= maxPending + limits.cursors + 1)
86
- return Promise.reject(queueFailure('worker cleanup capacity exceeded', pending.size));
87
- const id = ++sequence;
88
- return new Promise((resolve, reject) => {
89
- pending.set(id, { resolve, reject, op });
90
- metrics.maxPending = Math.max(metrics.maxPending, pending.size);
91
- try { worker.postMessage({ v: 1, generation: epoch, kind: 'request', id, op, ...data }); }
92
- catch (error) { pending.delete(id); reject(error); }
93
- });
94
- };
95
- const raw = {
96
- closeDrainsIterators: true,
97
- exec: (sql) => {
98
- if (/^(?:SAVEPOINT|BEGIN)\b/.test(sql)) transactionDepth++;
99
- return request('exec', { sql }).then((value) => {
100
- if (/^RELEASE\b/.test(sql)) transactionDepth = Math.max(0, transactionDepth - 1);
101
- if (/^(?:COMMIT|ROLLBACK(?! TO))\b/.test(sql)) transactionDepth = 0;
102
- return value;
103
- });
104
- },
105
- prepare: async (sql, metadata = {}) => {
106
- const id = await request('prepare', { sql, ephemeral: metadata.ephemeral === true });
107
- const iterate = async (params = []) => {
108
- const cursor = await request('iterate', { statement: id, params });
109
- let rows = [];
110
- let at = 0;
111
- let done = false;
112
- let returned = false;
113
- let pulling = Promise.resolve();
114
- const next = async () => {
115
- if (failed !== null) throw failed;
116
- if (returned) return { done: true, value: undefined };
117
- if (at < rows.length) return { done: false, value: rows[at++] };
118
- if (done) return { done: true, value: undefined };
119
- const batch = await request('next', { cursor, rows: limits.rows, bytes: limits.bytes });
120
- metrics.frames++;
121
- metrics.rows += batch.rows.length;
122
- metrics.maxFrameRows = Math.max(metrics.maxFrameRows, batch.rows.length);
123
- metrics.maxFrameBytes = Math.max(metrics.maxFrameBytes, batch.bytes);
124
- if (returned) return { done: true, value: undefined };
125
- rows = batch.rows;
126
- at = 0;
127
- done = batch.done;
128
- return at < rows.length ? { done: false, value: rows[at++] } : { done: true, value: undefined };
129
- };
130
- return {
131
- next: () => {
132
- const result = pulling.then(next);
133
- pulling = result.then(() => undefined, () => undefined);
134
- return result;
135
- },
136
- return: async () => {
137
- if (returned) return { done: true, value: undefined };
138
- returned = true;
139
- rows = [];
140
- if (!done) await request('return', { cursor }, true);
141
- return { done: true, value: undefined };
142
- },
143
- };
144
- };
145
- return {
146
- run: (params = []) => request('run', { statement: id, params }),
147
- get: (params = []) => request('get', { statement: id, params }),
148
- iterate,
149
- all: async (params = []) => {
150
- const iterator = await iterate(params);
151
- const rows = [];
152
- let bytes = 0;
153
- try {
154
- for (;;) {
155
- const step = await iterator.next();
156
- if (step.done) return rows;
157
- bytes += rowBytes(step.value);
158
- if (rows.length >= allRows || bytes > allBytes)
159
- throw new DbRuntimeError('JD2092', `worker all() exceeds its ${allRows} row / ${allBytes} byte bound; use a cursor`);
160
- rows.push(step.value);
161
- }
162
- }
163
- finally { await iterator.return(); }
164
- },
165
- };
166
- },
167
- close: () => {
168
- if (closePromise !== undefined) return closePromise;
169
- closing = true;
170
- closePromise = new Promise((resolve, reject) => {
171
- const timeout = setTimeout(() => {
172
- lose(new Error('worker close timed out'));
173
- // V8 termination cannot preempt a synchronous native SQLite
174
- // call. Fence and detach now; never hold the caller's deadline
175
- // hostage to that native call or claim it was rolled back.
176
- worker.unref();
177
- worker.terminate().catch(() => {});
178
- reject(failed);
179
- }, closeMs);
180
- request('close', {}, true).then(() => {
181
- closed = true;
182
- clearTimeout(timeout);
183
- worker.terminate().then(() => resolve(undefined), reject);
184
- }, (error) => {
185
- clearTimeout(timeout);
186
- worker.unref();
187
- worker.terminate().catch(() => {});
188
- reject(error);
189
- });
190
- });
191
- return closePromise;
192
- },
193
- };
194
- const connection = finishConnection(raw, sqliteDialect, false, Object.freeze(capabilities), options.queueTimeout);
195
- return Object.freeze({ ...connection,
196
- get mustQueue() { return connection.mustQueue; },
197
- generation: epoch,
198
- metrics: () => Object.freeze({ ...metrics, pending: pending.size, generation: epoch, healthy: failed === null && !closed }),
199
- restart: async () => { lose(new Error('worker restarted')); await worker.terminate(); return driver.open(path, options); },
200
- });
27
+ return createWorkerConnection(worker, { epoch, options, limits, maxPending, allRows, allBytes,
28
+ closeMs, startupMs, reopen: () => driver.open(path, options) });
201
29
  }, []),
202
30
  };
203
31
  return Object.freeze(driver);
@@ -38,6 +38,7 @@ export function adaptNodeDatabase(db, options) {
38
38
  };
39
39
  },
40
40
  close: () => db.close(),
41
+ transactionState: () => typeof db.isTransaction === 'boolean' ? db.isTransaction : null,
41
42
  // each optional primitive is exposed only when the HANDLE has it, so
42
43
  // a substitute that carries less than `node:sqlite` reports less —
43
44
  // a declared capability the handle cannot honour is a TypeError at
@@ -0,0 +1,113 @@
1
+ //@ts-check
2
+ /** One worker owns one SQLite handle. Only bounded credit frames carry rows. */
3
+ import { nodeDriver } from './node.js';
4
+ import { DbRuntimeError, cloneDriverError } from '../errors.js';
5
+ import { validRequest, generationFailure, rowBytes } from './worker-protocol.js';
6
+
7
+ /** @param {any} parentPort @param {any} configuration */
8
+ export async function serveSqliteEndpoint(parentPort, configuration) {
9
+ const { generation, path, options, limits } = configuration;
10
+ let connection;
11
+ let sequence = 0;
12
+ const statements = new Map();
13
+ const cursors = new Map();
14
+ const frame = (kind, id, value) => ({ v: 1, generation, kind, id, ...value });
15
+ const transaction = () => {
16
+ try { return connection.transactionState(); }
17
+ catch { return null; }
18
+ };
19
+ const bound = (reason) => new DbRuntimeError('JD2092', reason);
20
+ const release = (id) => {
21
+ const cursor = cursors.get(id);
22
+ if (cursor === undefined) return;
23
+ cursors.delete(id);
24
+ cursor.iterator.return?.();
25
+ if (cursor.ephemeral) statements.delete(cursor.statement);
26
+ };
27
+ const statementOf = (id) => {
28
+ const statement = statements.get(id);
29
+ if (statement === undefined) throw generationFailure(generation);
30
+ return statement;
31
+ };
32
+ const dispatch = (request) => {
33
+ switch (request.op) {
34
+ case 'exec': return connection.exec(request.sql);
35
+ case 'prepare': {
36
+ if (statements.size >= limits.statements) throw bound(`worker statement capacity ${limits.statements} exceeded`);
37
+ const id = ++sequence;
38
+ statements.set(id, { statement: connection.prepare(request.sql), ephemeral: request.ephemeral === true });
39
+ return id;
40
+ }
41
+ case 'get': {
42
+ const row = statementOf(request.statement).statement.get(request.params);
43
+ if (rowBytes(row) > limits.bytes) throw bound(`one worker row exceeds ${limits.bytes} bytes`);
44
+ return row;
45
+ }
46
+ case 'run': return statementOf(request.statement).statement.run(request.params);
47
+ case 'iterate': {
48
+ if (cursors.size >= limits.cursors) throw bound(`worker cursor capacity ${limits.cursors} exceeded`);
49
+ const record = statementOf(request.statement);
50
+ const id = ++sequence;
51
+ cursors.set(id, { iterator: record.statement.iterate(request.params),
52
+ statement: request.statement, ephemeral: record.ephemeral, buffered: null });
53
+ return id;
54
+ }
55
+ case 'next': {
56
+ const cursor = cursors.get(request.cursor);
57
+ if (cursor === undefined) throw generationFailure(generation);
58
+ if (request.rows > limits.rows || request.bytes > limits.bytes) throw bound('worker cursor credit exceeds the negotiated window');
59
+ const rows = [];
60
+ let bytes = 0;
61
+ try {
62
+ while (rows.length < request.rows) {
63
+ const step = cursor.buffered ?? cursor.iterator.next();
64
+ cursor.buffered = null;
65
+ if (step.done) { release(request.cursor); return { rows, bytes, done: true }; }
66
+ const size = rowBytes(step.value);
67
+ if (size > request.bytes) throw bound(`one worker row of ${size} bytes exceeds the ${request.bytes} byte window`);
68
+ if (bytes + size > request.bytes) { cursor.buffered = step; break; }
69
+ rows.push(step.value);
70
+ bytes += size;
71
+ }
72
+ return { rows, bytes, done: false };
73
+ }
74
+ catch (error) { release(request.cursor); throw error; }
75
+ }
76
+ case 'return': release(request.cursor); return undefined;
77
+ case 'finalize': {
78
+ for (const [id, cursor] of cursors) if (cursor.statement === request.statement) release(id);
79
+ statements.delete(request.statement);
80
+ return undefined;
81
+ }
82
+ case 'close': {
83
+ for (const id of cursors.keys()) release(id);
84
+ statements.clear();
85
+ return connection.close();
86
+ }
87
+ }
88
+ };
89
+ try {
90
+ connection = await nodeDriver().open(path, options);
91
+ // Functions cannot cross structured clone. Capture uses the existing
92
+ // journal path; a live query requires a synchronous connection.
93
+ parentPort.postMessage(frame('ready', 0, { capabilities: { ...connection.capabilities,
94
+ sessions: false, userFunctions: false, deterministicIndexableFunctions: false,
95
+ aggregateFunctions: false, backup: false, worker: true, pooling: false } }));
96
+ parentPort.on('message', (request) => {
97
+ try {
98
+ if (!validRequest(request)) throw new DbRuntimeError('JD2093', 'invalid worker protocol request');
99
+ if (request.generation !== generation) throw generationFailure(request.generation);
100
+ const value = dispatch(request);
101
+ parentPort.postMessage(frame('result', request.id, { value,
102
+ transaction: request.op === 'close' ? null : transaction() }));
103
+ if (request.op === 'close') parentPort.close();
104
+ }
105
+ catch (error) { parentPort.postMessage(frame('failure', request?.id ?? 0, {
106
+ error: cloneDriverError(error), transaction: transaction() })); }
107
+ });
108
+ }
109
+ catch (error) {
110
+ parentPort.postMessage(frame('failure', 0, { error: cloneDriverError(error) }));
111
+ parentPort.close();
112
+ }
113
+ }
@@ -0,0 +1,185 @@
1
+ //@ts-check
2
+ /** Shared bounded RPC Connection client for owned SQLite execution hosts. */
3
+ import { finishConnection } from '../driver.js';
4
+ import { sqliteDialect } from '../dialects/sqlite.js';
5
+ import { DbRuntimeError } from '../errors.js';
6
+ import { generationFailure, queueFailure, rowBytes, validResponse, validResult } from './worker-protocol.js';
7
+
8
+ /** @param {any} worker @param {any} settings @returns {Promise<any>} */
9
+ export async function createWorkerConnection(worker, settings) {
10
+ const { epoch, options, limits, maxPending, allRows, allBytes, closeMs, startupMs, reopen, hooks = {} } = settings;
11
+ const pending = new Map();
12
+ let sequence = 0;
13
+ let failed = null;
14
+ let closing = false;
15
+ let closed = false;
16
+ let transactionDepth = 0;
17
+ let closePromise;
18
+ const metrics = { frames: 0, rows: 0, maxFrameRows: 0, maxFrameBytes: 0, maxPending: 0 };
19
+ let readyResolve;
20
+ let readyReject;
21
+ const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
22
+ const lose = (cause) => {
23
+ if (failed !== null || closed) return;
24
+ failed = generationFailure(epoch, transactionDepth > 0, cause);
25
+ hooks.lost?.(failed, [...pending.values()]);
26
+ readyReject(failed);
27
+ for (const request of pending.values()) request.reject(failed);
28
+ pending.clear();
29
+ };
30
+ hooks.control?.({ lose });
31
+ worker.on('error', lose);
32
+ worker.on('exit', (code) => { if (!closed) lose(new Error(`worker exited (${code})`)); });
33
+ worker.on('message', (message) => {
34
+ if (!validResponse(message, epoch)) { lose(new Error('invalid worker response')); return; }
35
+ if (message.kind === 'ready') { readyResolve(message.capabilities); return; }
36
+ if (message.kind === 'failure' && message.id === 0) {
37
+ readyReject(Object.assign(new Error(message.error.message), message.error)); return;
38
+ }
39
+ const request = pending.get(message.id);
40
+ if (request === undefined) return;
41
+ pending.delete(message.id);
42
+ if (message.kind === 'failure') {
43
+ hooks.failure?.(request.op, request.data, message.transaction);
44
+ const error = message.error;
45
+ request.reject(Object.assign(error.code?.startsWith('JD')
46
+ ? new DbRuntimeError(error.code, error.message) : new Error(error.message), error));
47
+ }
48
+ else if (message.kind === 'result' && validResult(request.op, message.value, limits)) {
49
+ hooks.result?.(request.op, request.data, message.value, message.transaction); request.resolve(message.value);
50
+ }
51
+ else { request.reject(generationFailure(epoch, transactionDepth > 0)); lose(new Error('invalid worker response')); }
52
+ });
53
+ const timer = setTimeout(() => { lose(new Error('worker startup timed out')); worker.terminate(); }, startupMs);
54
+ let capabilities;
55
+ try { capabilities = await ready; }
56
+ catch (error) {
57
+ const termination = worker.terminate();
58
+ if (settings.awaitStartupExit !== false) await termination;
59
+ else termination.catch(() => {});
60
+ throw error;
61
+ }
62
+ finally { clearTimeout(timer); }
63
+ const request = (op, data = {}, cleanup = false) => {
64
+ if (failed !== null) return Promise.reject(failed);
65
+ if (closed || (closing && !cleanup)) return Promise.reject(new DbRuntimeError('JD2063', 'the worker connection is closing or closed'));
66
+ if (!cleanup && pending.size >= maxPending)
67
+ return Promise.reject(queueFailure(`worker request capacity ${maxPending} exceeded`, pending.size));
68
+ // One return per live cursor and one close have reserved capacity.
69
+ if (cleanup && pending.size >= maxPending + limits.cursors + 1)
70
+ return Promise.reject(queueFailure('worker cleanup capacity exceeded', pending.size));
71
+ const id = ++sequence;
72
+ return new Promise((resolve, reject) => {
73
+ pending.set(id, { resolve, reject, op, data });
74
+ metrics.maxPending = Math.max(metrics.maxPending, pending.size);
75
+ try { hooks.request?.(op, data); worker.postMessage({ v: 1, generation: epoch, kind: 'request', id, op, ...data }); }
76
+ catch (error) { pending.delete(id); reject(error); }
77
+ });
78
+ };
79
+ const raw = {
80
+ closeDrainsIterators: true,
81
+ exec: (sql) => {
82
+ if (/^(?:SAVEPOINT|BEGIN)\b/.test(sql)) transactionDepth++;
83
+ return request('exec', { sql }).then((value) => {
84
+ if (/^RELEASE\b/.test(sql)) transactionDepth = Math.max(0, transactionDepth - 1);
85
+ if (/^(?:COMMIT|ROLLBACK(?! TO))\b/.test(sql)) transactionDepth = 0;
86
+ return value;
87
+ });
88
+ },
89
+ prepare: async (sql, metadata = {}) => {
90
+ const id = await request('prepare', { sql, ephemeral: metadata.ephemeral === true });
91
+ const iterate = async (params = []) => {
92
+ const cursor = await request('iterate', { statement: id, params });
93
+ let rows = [];
94
+ let at = 0;
95
+ let done = false;
96
+ let returned = false;
97
+ let pulling = Promise.resolve();
98
+ const next = async () => {
99
+ if (failed !== null) throw failed;
100
+ if (returned) return { done: true, value: undefined };
101
+ if (at < rows.length) return { done: false, value: rows[at++] };
102
+ if (done) return { done: true, value: undefined };
103
+ const batch = await request('next', { cursor, rows: limits.rows, bytes: limits.bytes });
104
+ metrics.frames++;
105
+ metrics.rows += batch.rows.length;
106
+ metrics.maxFrameRows = Math.max(metrics.maxFrameRows, batch.rows.length);
107
+ metrics.maxFrameBytes = Math.max(metrics.maxFrameBytes, batch.bytes);
108
+ if (returned) return { done: true, value: undefined };
109
+ rows = batch.rows;
110
+ at = 0;
111
+ done = batch.done;
112
+ return at < rows.length ? { done: false, value: rows[at++] } : { done: true, value: undefined };
113
+ };
114
+ return {
115
+ next: () => {
116
+ const result = pulling.then(next);
117
+ pulling = result.then(() => undefined, () => undefined);
118
+ return result;
119
+ },
120
+ return: async () => {
121
+ if (returned) return { done: true, value: undefined };
122
+ returned = true;
123
+ rows = [];
124
+ if (!done) await request('return', { cursor }, true);
125
+ return { done: true, value: undefined };
126
+ },
127
+ };
128
+ };
129
+ return {
130
+ run: (params = []) => request('run', { statement: id, params }),
131
+ get: (params = []) => request('get', { statement: id, params }),
132
+ iterate,
133
+ all: async (params = []) => {
134
+ const iterator = await iterate(params);
135
+ const rows = [];
136
+ let bytes = 0;
137
+ try {
138
+ for (;;) {
139
+ const step = await iterator.next();
140
+ if (step.done) return rows;
141
+ bytes += rowBytes(step.value);
142
+ if (rows.length >= allRows || bytes > allBytes)
143
+ throw new DbRuntimeError('JD2092', `worker all() exceeds its ${allRows} row / ${allBytes} byte bound; use a cursor`);
144
+ rows.push(step.value);
145
+ }
146
+ }
147
+ finally { await iterator.return(); }
148
+ },
149
+ };
150
+ },
151
+ close: () => {
152
+ if (closePromise !== undefined) return closePromise;
153
+ closing = true;
154
+ closePromise = new Promise((resolve, reject) => {
155
+ const timeout = setTimeout(() => {
156
+ lose(new Error('worker close timed out'));
157
+ // V8 termination cannot preempt a synchronous native SQLite
158
+ // call. Fence and detach now; never hold the caller's deadline
159
+ // hostage to that native call or claim it was rolled back.
160
+ worker.unref();
161
+ worker.terminate().catch(() => {});
162
+ reject(failed);
163
+ }, closeMs);
164
+ request('close', {}, true).then(() => {
165
+ closed = true;
166
+ clearTimeout(timeout);
167
+ worker.terminate().then(() => resolve(undefined), reject);
168
+ }, (error) => {
169
+ clearTimeout(timeout);
170
+ worker.unref();
171
+ worker.terminate().catch(() => {});
172
+ reject(error);
173
+ });
174
+ });
175
+ return closePromise;
176
+ },
177
+ };
178
+ const connection = finishConnection(raw, sqliteDialect, false, Object.freeze(capabilities), options.queueTimeout);
179
+ return Object.freeze({ ...connection,
180
+ get mustQueue() { return connection.mustQueue; },
181
+ generation: epoch,
182
+ metrics: () => Object.freeze({ ...metrics, pending: pending.size, generation: epoch, healthy: failed === null && !closed }),
183
+ restart: async () => { lose(new Error('worker restarted')); await worker.terminate(); return reopen(); },
184
+ });
185
+ }
@@ -4,6 +4,11 @@ import { DbRuntimeError } from '../errors.js';
4
4
  import { jsonStringBytes } from '../json-bytes.js';
5
5
 
6
6
  export const WORKER_PROTOCOL_VERSION = 1;
7
+ export const WORKER_DEFAULTS = Object.freeze({ windowRows: 64, windowBytes: 1048576,
8
+ maxPending: 64, maxStatements: 1024, maxCursors: 64, allMaxRows: 100000,
9
+ allMaxBytes: 16777216, closeTimeoutMs: 5000, startupTimeoutMs: 10000 });
10
+ export const PROCESS_DEFAULTS = Object.freeze({ ...WORKER_DEFAULTS, closeTimeoutMs: 1000,
11
+ maxOwners: 4, timeoutMs: 250, maxRequestBytes: 1048576 });
7
12
  const operations = new Set(['exec', 'prepare', 'run', 'get', 'iterate', 'next', 'return', 'finalize', 'close']);
8
13
 
9
14
  /** @param {number} generation @param {boolean} [transaction] @param {unknown} [cause] */
@@ -73,6 +78,16 @@ export function positiveOption(name, value, fallback) {
73
78
  return value;
74
79
  }
75
80
 
81
+ /** One validation and spelling of shared transport credits.
82
+ * @param {any} configuration @param {typeof WORKER_DEFAULTS} [defaults] */
83
+ export function workerSettings(configuration, defaults = WORKER_DEFAULTS) {
84
+ const values = Object.fromEntries(Object.keys(WORKER_DEFAULTS).map((name) =>
85
+ [name, positiveOption(name, configuration[name], defaults[name])]));
86
+ return { limits: { rows: values.windowRows, bytes: values.windowBytes, statements: values.maxStatements, cursors: values.maxCursors },
87
+ maxPending: values.maxPending, allRows: values.allMaxRows, allBytes: values.allMaxBytes,
88
+ closeMs: values.closeTimeoutMs, startupMs: values.startupTimeoutMs };
89
+ }
90
+
76
91
  /** @param {string} reason @param {number} depth */
77
92
  export function queueFailure(reason, depth) {
78
93
  return Object.assign(new DbRuntimeError('JD2091', reason), { class: 'queue', retryable: true, depth });
package/src/errors.js CHANGED
@@ -112,6 +112,7 @@ export const DB_CODES = Object.freeze({
112
112
  JD2094: 'the durable snapshot failed and the connection is invalid',
113
113
  JD2095: 'the trusted SQL or synchronous transaction authority was refused',
114
114
  JD2096: 'a persistence invariant rejected the mutation',
115
+ JD2097: 'the supervised operation was cancelled or exceeded its response deadline',
115
116
  });
116
117
 
117
118
  /**
package/types/index.d.ts CHANGED
@@ -514,6 +514,8 @@ export interface StoreCapabilities {
514
514
  readonly sessions: boolean;
515
515
  readonly sessionReason: string | null;
516
516
  readonly worker: boolean;
517
+ readonly process: boolean;
518
+ readonly ownerTermination: boolean;
517
519
  readonly pooling: boolean;
518
520
  readonly poolReaders: number;
519
521
  readonly poolWriters: number;
@@ -0,0 +1,33 @@
1
+ import type { NodeOpenOptions } from './node.js';
2
+ import type { CancellationCapabilities, Driver } from './index.js';
3
+ import type { NodeWorkerConnection, NodeWorkerOptions, WorkerMetrics } from './node-worker.js';
4
+
5
+ export interface NodeProcessOptions extends NodeWorkerOptions { maxOwners?: number; timeoutMs?: number; maxRequestBytes?: number }
6
+ export interface ProcessSettlement {
7
+ readonly path: string | null;
8
+ readonly generation: number;
9
+ readonly pid: number | null;
10
+ readonly status: 'starting' | 'healthy' | 'quarantined' | 'exited';
11
+ readonly transaction: 'none' | 'active' | 'committed' | 'rolled-back' | 'unknown';
12
+ readonly safeToReplace: boolean;
13
+ readonly exitCode: number | null;
14
+ readonly exitSignal: string | null;
15
+ }
16
+ export interface NodeProcessConnection extends NodeWorkerConnection {
17
+ readonly capabilities: Readonly<Record<string, unknown>> & {
18
+ readonly process: true; readonly ownerTermination: true;
19
+ readonly cancellation: CancellationCapabilities & {readonly midStatement: false};
20
+ };
21
+ supervise<T>(body: (connection: NodeProcessConnection) => T | Promise<T>, options?: {signal?: AbortSignal; timeoutMs?: number}): Promise<T>;
22
+ cancel(reason?: string): Error;
23
+ settlement(): ProcessSettlement;
24
+ /** Resolves only after the OS reports owner exit, never from a caller deadline. */
25
+ settled(): Promise<ProcessSettlement>;
26
+ metrics(): WorkerMetrics & {readonly owner: ProcessSettlement; readonly supervised: number};
27
+ restart(): Promise<NodeProcessConnection>;
28
+ }
29
+ export interface NodeProcessDriver extends Driver {
30
+ open(path?: string, options?: NodeOpenOptions): Promise<NodeProcessConnection>;
31
+ metrics(): Readonly<{capacity: number; owners: number; quarantined: number; healthy: number}>;
32
+ }
33
+ export declare function nodeProcessDriver(options?: NodeProcessOptions): NodeProcessDriver;