@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.
- package/ARCHITECTURE.md +27 -3
- package/README.md +35 -14
- package/docs/HOSTS.md +131 -4
- package/docs/MODEL-FORMAT.md +15 -4
- package/docs/NATIVE-PLANS.md +24 -7
- package/docs/SQLITE-RELATIONAL.md +190 -0
- package/package.json +24 -4
- package/schemas/jaren-model.authoring.schema.json +157 -0
- package/schemas/jaren-model.draft-07.schema.json +157 -0
- package/schemas/jaren-model.schema.json +157 -0
- package/src/capture.js +4 -2
- package/src/ddl.js +8 -3
- package/src/dialects/sqlite-relational.js +314 -0
- package/src/dialects/sqlite-schema.js +142 -0
- package/src/dialects/sqlite.js +17 -0
- package/src/driver.js +3 -0
- package/src/drivers/bun.js +26 -15
- package/src/drivers/node-process-endpoint.js +13 -0
- package/src/drivers/node-process.js +177 -0
- package/src/drivers/node-worker-endpoint.js +3 -103
- package/src/drivers/node-worker.js +6 -178
- package/src/drivers/node.js +3 -0
- package/src/drivers/snapshot.js +49 -0
- package/src/drivers/sqlite-endpoint.js +113 -0
- package/src/drivers/worker-client.js +185 -0
- package/src/drivers/worker-protocol.js +15 -0
- package/src/emit.js +37 -5
- package/src/engine-metadata.js +18 -0
- package/src/errors.js +1 -0
- package/src/index.js +4 -1
- package/src/introspect.js +1 -2
- package/src/jobs.js +4 -2
- package/src/migrate.js +12 -16
- package/src/model-api.js +4 -0
- package/src/model.js +12 -0
- package/src/mutation.js +66 -14
- package/src/physical.js +37 -7
- package/src/plan.js +66 -3
- package/src/query-api.js +5 -0
- package/src/query.js +39 -6
- package/src/relational-api.js +6 -0
- package/src/store.js +6 -4
- package/src/table-migration.js +158 -0
- package/types/bun.d.ts +3 -0
- package/types/entity.d.ts +1 -0
- package/types/index.d.ts +11 -2
- package/types/model.d.ts +1 -0
- package/types/node-process.d.ts +33 -0
- package/types/node.d.ts +3 -0
- package/types/query.d.ts +2 -0
- 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 ?
|
|
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
|
-
:
|
|
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' ?
|
|
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 './
|
|
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
|
-
|
|
48
|
-
|
|
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 {
|
|
38
|
-
|
|
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
|
-
|
|
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
|
package/src/model-api.js
ADDED
|
@@ -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
|
|