@jarenjs/db 0.84.3 → 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.
Files changed (51) hide show
  1. package/ARCHITECTURE.md +27 -3
  2. package/README.md +35 -14
  3. package/docs/HOSTS.md +131 -4
  4. package/docs/MODEL-FORMAT.md +15 -4
  5. package/docs/NATIVE-PLANS.md +24 -7
  6. package/docs/SQLITE-RELATIONAL.md +190 -0
  7. package/package.json +24 -4
  8. package/schemas/jaren-model.authoring.schema.json +157 -0
  9. package/schemas/jaren-model.draft-07.schema.json +157 -0
  10. package/schemas/jaren-model.schema.json +157 -0
  11. package/src/capture.js +4 -2
  12. package/src/ddl.js +8 -3
  13. package/src/dialects/sqlite-relational.js +314 -0
  14. package/src/dialects/sqlite-schema.js +142 -0
  15. package/src/dialects/sqlite.js +17 -0
  16. package/src/driver.js +3 -0
  17. package/src/drivers/bun.js +26 -15
  18. package/src/drivers/node-process-endpoint.js +13 -0
  19. package/src/drivers/node-process.js +177 -0
  20. package/src/drivers/node-worker-endpoint.js +3 -103
  21. package/src/drivers/node-worker.js +6 -178
  22. package/src/drivers/node.js +3 -0
  23. package/src/drivers/snapshot.js +49 -0
  24. package/src/drivers/sqlite-endpoint.js +113 -0
  25. package/src/drivers/worker-client.js +185 -0
  26. package/src/drivers/worker-protocol.js +15 -0
  27. package/src/emit.js +37 -5
  28. package/src/engine-metadata.js +18 -0
  29. package/src/errors.js +1 -0
  30. package/src/index.js +4 -1
  31. package/src/introspect.js +1 -2
  32. package/src/jobs.js +4 -2
  33. package/src/migrate.js +12 -16
  34. package/src/model-api.js +4 -0
  35. package/src/model.js +12 -0
  36. package/src/mutation.js +66 -14
  37. package/src/physical.js +37 -7
  38. package/src/plan.js +66 -3
  39. package/src/query-api.js +5 -0
  40. package/src/query.js +39 -6
  41. package/src/relational-api.js +6 -0
  42. package/src/store.js +6 -4
  43. package/src/table-migration.js +158 -0
  44. package/types/bun.d.ts +3 -0
  45. package/types/entity.d.ts +1 -0
  46. package/types/index.d.ts +11 -2
  47. package/types/model.d.ts +1 -0
  48. package/types/node-process.d.ts +33 -0
  49. package/types/node.d.ts +3 -0
  50. package/types/query.d.ts +2 -0
  51. package/types/relational.d.ts +114 -0
@@ -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/emit.js CHANGED
@@ -41,7 +41,7 @@ function compareRefs(pred, read) {
41
41
  }
42
42
 
43
43
  /**
44
- * @typedef {{ external: string } | { literal: unknown } |
44
+ * @typedef {{ external: string, nullable?: boolean } | { literal: unknown } |
45
45
  * { derived: { kind: 'bboxAxis', external: string,
46
46
  * axis: 'w' | 's' | 'e' | 'n' } } |
47
47
  * { derived: { kind: 'circleAxis', centre: { external: string } | { literal: unknown },
@@ -659,15 +659,20 @@ export function createEntityPredicateEmitters(dialect, param) {
659
659
 
660
660
  const emitColumnPred = (aliasSql, pred) => {
661
661
  const column = physicalComparable(pred.ref, `${aliasSql}.${q(pred.ref.column)}`, dialect);
662
+ const presentNull = pred.ref.nullPolicy === 'null';
663
+ const present = presentNull ? dialect.booleanLiteral(true) : `${column} IS NOT NULL`;
662
664
  if (pred.p === 'typeIs') {
663
665
  if (pred.types.length === 0)
664
- return pred.positive ? `${column} IS NOT NULL` : `${column} IS NULL`;
666
+ return pred.positive ? present : presentNull ? dialect.booleanLiteral(false) : `${column} IS NULL`;
665
667
  if (pred.types[0] === 'null')
666
- return pred.positive ? dialect.booleanLiteral(false) : `${column} IS NOT NULL`;
668
+ return pred.positive ? presentNull ? `${column} IS NULL` : dialect.booleanLiteral(false) : `${column} IS NOT NULL`;
669
+ if (pred.ref.storage !== 'boolean')
670
+ return pred.positive ? dialect.booleanLiteral(false) : present;
667
671
  const wanted = pred.types[0] === 'true' ? 1 : 0;
668
672
  return pred.positive
669
673
  ? `(${column} IS NOT NULL AND ${column} = ${param({ literal: wanted })})`
670
- : `(${column} IS NOT NULL AND ${column} <> ${param({ literal: wanted })})`;
674
+ : presentNull ? `${column} IS NOT ${param({ literal: wanted })}`
675
+ : `(${column} IS NOT NULL AND ${column} <> ${param({ literal: wanted })})`;
671
676
  }
672
677
  if (pred.p === 'strop') {
673
678
  const form = stropForm(dialect, param, column, pred);
@@ -675,6 +680,26 @@ export function createEntityPredicateEmitters(dialect, param) {
675
680
  }
676
681
  const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
677
682
  if ('ext' in pred.operand) {
683
+ if (dialect.name === 'sqlite') {
684
+ // SQL NULL is a present Jaren null only for an explicit null
685
+ // policy. Keep type guards: SQLite affinity must not turn a
686
+ // numeric lookup into a string lookup, or invert inequality.
687
+ const value = () => param({ external: pred.operand.ext, nullable: true });
688
+ const kind = pred.ref.storage === 'string' ? 'text' : 'number';
689
+ const guard = () => kind === 'text'
690
+ ? `${dialect.valueTypeOf(value())} = ${sl('text')}`
691
+ : pred.ref.storage === 'boolean' ? dialect.booleanLiteral(false)
692
+ : `${dialect.valueTypeOf(value())} IN ${NUMERIC()}`;
693
+ if (pred.op === 'eq') {
694
+ const nullCase = presentNull ? `(${column} IS NULL AND ${value()} IS NULL) OR ` : '';
695
+ return `(${nullCase}(${column} IS NOT NULL AND ${guard()} AND ${column} = ${value()}))`;
696
+ }
697
+ if (pred.op === 'ne') {
698
+ const nullCase = presentNull ? `(${column} IS NULL AND ${value()} IS NOT NULL) OR ` : '';
699
+ return `(${nullCase}(${column} IS NOT NULL AND (NOT (${guard()}) OR ${column} <> ${value()})))`;
700
+ }
701
+ return `(${column} IS NOT NULL AND ${guard()} AND ${column} ${symbol} ${value()})`;
702
+ }
678
703
  const kind = pred.ref.storage === 'string' ? 'text' : 'number';
679
704
  const guard = kind === 'text'
680
705
  ? `${dialect.valueTypeOf(externalSlot(pred.operand.ext))} = ${sl('text')}`
@@ -688,7 +713,8 @@ export function createEntityPredicateEmitters(dialect, param) {
688
713
  const storageKind = pred.ref.storage === 'string' ? 'string'
689
714
  : pred.ref.storage === 'boolean' ? 'boolean' : 'number';
690
715
  if (storageKind === 'boolean' || litKind === 'other' || storageKind !== litKind)
691
- return pred.op === 'ne' ? `${column} IS NOT NULL` : dialect.booleanLiteral(false);
716
+ return pred.op === 'ne' ? present : dialect.booleanLiteral(false);
717
+ if (presentNull && pred.op === 'ne') return `${column} IS NOT ${param({ literal: lit })}`;
692
718
  return `(${column} IS NOT NULL AND ${column} ${symbol} ${param({ literal: lit })})`;
693
719
  };
694
720
 
@@ -875,6 +901,12 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
875
901
  ? `CAST(${groupValue(entry.ref)} AS REAL)` : groupValue(entry.ref))} AS ${q(`a${i}`)}`),
876
902
  ...group.aggregates.flatMap((entry, i) => numericGroup(entry) ? [`${groupSafe(entry)} AS ${q(`_safe${i}`)}`] : []),
877
903
  ...(groupRefs.length ? [`${groupRefs.map(groupValid).join(' AND ')} AS ${q('_valid')}`] : [])].join(', ')
904
+ : plan.scalarAggregate
905
+ ? `${dialect.groupAggregate(plan.scalarAggregate.fn, plan.scalarAggregate.ref.type === 'string'
906
+ ? groupValue(plan.scalarAggregate.ref) : `CAST(${groupValue(plan.scalarAggregate.ref)} AS REAL)`)} AS ${q('value')}, `
907
+ + `${groupValid(plan.scalarAggregate.ref)} AS ${q('_valid')}, `
908
+ + `${plan.scalarAggregate.ref.nullPolicy === 'null' ? `COUNT(*) - COUNT(${groupValue(plan.scalarAggregate.ref)})` : '0'} AS ${q('_nulls')}, `
909
+ + `${numericGroup(plan.scalarAggregate) ? groupSafe(plan.scalarAggregate) : '1'} AS ${q('_safe')}`
878
910
  : plan.aggregate === 'count'
879
911
  ? `COUNT(*) AS ${q('value')}`
880
912
  : plan.project != null
@@ -0,0 +1,18 @@
1
+ //@ts-check
2
+ /** Inert metadata: inspecting a schema must not load its runtime owners. */
3
+ import { REPLICATION_TABLES } from './replication-tables.js';
4
+ /** Current model document version. */
5
+ export const MODEL_VERSION = '0.1';
6
+ /** Migration receipt table. */
7
+ export const HISTORY_TABLE = '_jaren_migrations';
8
+ /** Change ledger table. */
9
+ export const CHANGES_TABLE = '_jaren_changes';
10
+ /** Change ledger retention state. */
11
+ export const CHANGES_STATE_TABLE = '_jaren_changes_state';
12
+ /** Durable job queue. */
13
+ export const JOBS_TABLE = '_jaren_jobs';
14
+ /** Durable job checkpoints. */
15
+ export const JOB_CHECKPOINTS_TABLE = '_jaren_job_checkpoints';
16
+ /** Tables excluded from model adoption and schema drift comparisons. */
17
+ export const ENGINE_TABLES = new Set([HISTORY_TABLE, CHANGES_TABLE, CHANGES_STATE_TABLE,
18
+ JOBS_TABLE, JOB_CHECKPOINTS_TABLE, ...Object.values(REPLICATION_TABLES)]);
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/src/index.js CHANGED
@@ -66,7 +66,7 @@ export {
66
66
  applyMandatoryPredicate, applyRowBound,
67
67
  } from './profile.js';
68
68
  export { translatePatch } from './patch-sql.js';
69
- export { normalizeEntities, explainMapping, relationTables } from './model.js';
69
+ export { normalizeEntities, explainMapping, compileEntityModel, relationTables } from './model.js';
70
70
  export { planEntity, planJoinTable } from './ddl.js';
71
71
  export { entityCore } from './entity.js';
72
72
  export { entityEmitModel } from './emit-model.js';
@@ -103,3 +103,6 @@ export { REPLICATION_VERSION, REPLICATION_DEFAULTS, normalizeFrontier,
103
103
 
104
104
  export { planInvariants } from './ddl.js';
105
105
  export { planPhysicalMigration } from './migrate.js';
106
+ export { sql, planRelational, relational } from './dialects/sqlite-relational.js';
107
+ export { defineTable, planTable } from './dialects/sqlite-schema.js';
108
+ export { planTableMigration, applyTableMigration, withForeignKeysSuspended } from './table-migration.js';
package/src/introspect.js CHANGED
@@ -30,8 +30,7 @@
30
30
  import { chain } from './driver.js';
31
31
  import { DbCompileError } from './errors.js';
32
32
  import { KEY_COLUMN, DOC_COLUMN } from './ddl.js';
33
- import { MODEL_VERSION } from './store.js';
34
- import { ENGINE_TABLES } from './migrate.js';
33
+ import { MODEL_VERSION, ENGINE_TABLES } from './engine-metadata.js';
35
34
  import { registeredName, expressionMembers } from './expression.js';
36
35
 
37
36
  /**
package/src/jobs.js CHANGED
@@ -1,4 +1,6 @@
1
1
  //@ts-check
2
+ import { JOBS_TABLE, JOB_CHECKPOINTS_TABLE } from './engine-metadata.js';
3
+ export { JOBS_TABLE, JOB_CHECKPOINTS_TABLE };
2
4
  /**
3
5
  * @file The durable job queue (JOBS-FORMAT): enqueue, the
4
6
  * single-statement guarded claim (§3 — one statement is one
@@ -44,8 +46,8 @@ import { DbCompileError, DbRuntimeError, wrapDriverError } from './errors.js';
44
46
  import { createCursor, rowClassOf, PAGE_LIMIT_DEFAULT } from './cursor.js';
45
47
  import { refuseCancelled } from './cancellation.js';
46
48
 
47
- export const JOBS_TABLE = '_jaren_jobs';
48
- export const JOB_CHECKPOINTS_TABLE = '_jaren_job_checkpoints';
49
+
50
+
49
51
 
50
52
  /** §4 defaults, all overridable per worker. */
51
53
  export const JOB_DEFAULTS = Object.freeze({
package/src/migrate.js CHANGED
@@ -34,9 +34,8 @@ import { chain, toPromise } from './driver.js';
34
34
  import { normalizeModel } from './store.js';
35
35
  import { planQuery } from './plan.js';
36
36
  import { createQueryEngine, createQueryState } from './query.js';
37
- import { CHANGES_TABLE, CHANGES_STATE_TABLE } from './capture.js';
38
- import { JOBS_TABLE, JOB_CHECKPOINTS_TABLE } from './jobs.js';
39
- import { REPLICATION_TABLES } from './replication-tables.js';
37
+ import { HISTORY_TABLE, ENGINE_TABLES } from './engine-metadata.js';
38
+ export { HISTORY_TABLE, ENGINE_TABLES };
40
39
  import { planCollection, verifyShape, planEntity, planJoinTable } from './ddl.js';
41
40
  import { normalizeEntities, explainMapping } from './model.js';
42
41
  import { derivedValue, memberAt, registerDeriveFunctions } from './derive.js';
@@ -74,17 +73,6 @@ function mappingFor(connection, expressions = undefined) {
74
73
  };
75
74
  }
76
75
 
77
- /** The history table name (outside the model's identifier namespace
78
- * conventions on purpose — a collection cannot collide with it). */
79
- export const HISTORY_TABLE = '_jaren_migrations';
80
- /** The tables the engine owns beside a model's: never a shape-drift finding. */
81
- /** The tables this package owns. A model never declared one, so one
82
- * found in a database is the engine's own bookkeeping rather than
83
- * anybody's drift — the drift check skips them and the introspector
84
- * does not derive them. */
85
- export const ENGINE_TABLES = new Set([HISTORY_TABLE, CHANGES_TABLE, CHANGES_STATE_TABLE,
86
- JOBS_TABLE, JOB_CHECKPOINTS_TABLE, ...Object.values(REPLICATION_TABLES)]);
87
-
88
76
  /**
89
77
  * The signature-grade identity of a model SHAPE.
90
78
  * @param {any} model - A jaren-model document
@@ -198,8 +186,16 @@ export function planMigration(fromModel, toModel, options = undefined) {
198
186
  const dialect = options?.dialect ?? null;
199
187
  if (dialect === null || typeof dialect !== 'object')
200
188
  throw new TypeError('planMigration needs { dialect } (the store dialect renders the DDL)');
201
- if ([fromModel, toModel].some((m) => Object.values(m.entities ?? {}).some((e) => e.physical !== undefined)))
202
- throw refuse('JD0021', 'column layouts require planPhysicalMigration with explicit preservation dispositions');
189
+ if ([fromModel, toModel].some((m) => Object.values(m.entities ?? {}).some((e) => e.physical !== undefined))) {
190
+ normalizeEntities(fromModel);
191
+ normalizeEntities(toModel);
192
+ if (canonicalizeJson(fromModel) !== canonicalizeJson(toModel))
193
+ throw refuse('JD0021', 'changed column layouts require planTableMigration on the open connection or planPhysicalMigration with explicit preservation dispositions');
194
+ return { migration: { $migration: MIGRATION_VERSION,
195
+ id: options?.id ?? `to-${shapeHash(toModel).slice(0, 8)}`,
196
+ from: shapeHash(fromModel), to: shapeHash(toModel), steps: [] },
197
+ report: { renamed: [], added: [], removed: [], schemaChanged: [], drafts: [], destructive: false } };
198
+ }
203
199
  const mapping = { derived: options?.derived ?? 'virtual', rtree: options?.rtree !== false,
204
200
  // a model that declares an index EXPRESSION resolves its functions
205
201
  // here too: a plan is DDL, and DDL over a function this planner was
@@ -0,0 +1,4 @@
1
+ //@ts-check
2
+ /** Model metadata and read-only schema inspection without opening a store. */
3
+ export { normalizeEntities, explainMapping, compileEntityModel, relationTables } from './model.js';
4
+ export { readSchema, introspectModel, INTROSPECT_CODES } from './introspect.js';
package/src/model.js CHANGED
@@ -557,7 +557,19 @@ export function relationTables(entities) {
557
557
  * @returns {any}
558
558
  */
559
559
  export function explainMapping(model) {
560
+ return mappingOf(model, normalizeEntities(model));
561
+ }
562
+
563
+ /** Normalize and map once for lightweight engines sharing model metadata.
564
+ * No process-global cache retains the model or any connection.
565
+ * @param {any} model @returns {{entities:Map<string,any>,mapping:any}} */
566
+ export function compileEntityModel(model) {
560
567
  const entities = normalizeEntities(model);
568
+ return { entities, mapping: mappingOf(model, entities) };
569
+ }
570
+
571
+ /** @param {any} model @param {Map<string,any>} entities @returns {any} */
572
+ function mappingOf(model, entities) {
561
573
  /** @type {any} */
562
574
  const mapping = { entities: {}, joinTables: {} };
563
575