@jarenjs/db 0.73.0 → 0.83.2
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 +70 -7
- package/README.md +69 -6
- package/docs/HOSTS.md +17 -0
- package/docs/JOBS-FORMAT.md +26 -0
- package/docs/LIVE-FORMAT.md +52 -13
- package/docs/MIGRATION-FORMAT.md +34 -0
- package/docs/MODEL-FORMAT.md +163 -15
- package/docs/NATIVE-PLANS.md +111 -0
- package/docs/REPLICATION-FORMAT.md +19 -13
- package/docs/SEARCH.md +55 -0
- package/package.json +8 -4
- package/schemas/jaren-migration.draft-07.schema.json +54 -5
- package/schemas/jaren-migration.schema.json +49 -0
- package/schemas/jaren-model.authoring.schema.json +360 -0
- package/schemas/jaren-model.draft-07.schema.json +128 -0
- package/schemas/jaren-model.schema.json +128 -0
- package/src/algebra.js +26 -4
- package/src/backup.js +12 -7
- package/src/cursor.js +27 -4
- package/src/dag-job.js +2 -1
- package/src/ddl.js +13 -0
- package/src/derive.js +14 -3
- package/src/dialect.js +12 -0
- package/src/dialects/check-read.js +151 -0
- package/src/dialects/invariant-sql.js +117 -0
- package/src/dialects/postgres.js +28 -4
- package/src/dialects/sqlite.js +23 -3
- package/src/driver.js +1 -0
- package/src/drivers/bun.js +22 -4
- package/src/emit.js +133 -25
- package/src/entity.js +98 -41
- package/src/errors.js +8 -0
- package/src/graph.js +8 -1
- package/src/index.js +3 -0
- package/src/introspect.js +81 -12
- package/src/invariants.js +45 -0
- package/src/jobs.js +39 -6
- package/src/live-nested.js +27 -10
- package/src/live.js +51 -136
- package/src/migrate.js +136 -22
- package/src/model.js +12 -0
- package/src/mutation.js +165 -0
- package/src/physical.js +147 -0
- package/src/plan.js +275 -64
- package/src/query.js +175 -78
- package/src/search.js +144 -0
- package/src/sql.js +60 -0
- package/src/store.js +49 -13
- package/src/tracker.js +63 -39
- package/src/window.js +1 -0
- package/types/index.d.ts +59 -4
- package/types/search.d.ts +20 -0
- package/types/typed.d.ts +1 -0
package/src/mutation.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Bounded column mutation documents, lowered through the entity's writer plan. */
|
|
3
|
+
import { analyzeQuery } from '@jarenjs/json/query';
|
|
4
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
5
|
+
import { chain, attempt } from './driver.js';
|
|
6
|
+
import { DbCompileError, DbRuntimeError, wrapDriverError } from './errors.js';
|
|
7
|
+
import { entityShape, planEntityPredicate } from './plan.js';
|
|
8
|
+
import { createEntityPredicateEmitters } from './emit.js';
|
|
9
|
+
import { physicalSelection } from './physical.js';
|
|
10
|
+
import { utf8Length } from './cursor.js';
|
|
11
|
+
|
|
12
|
+
/** Compile once per document, execute within the existing guarded transaction.
|
|
13
|
+
* @param {any} connection @param {any} entity @param {any} mapping @param {any} core */
|
|
14
|
+
export function createEntityMutation(connection, entity, mapping, core) {
|
|
15
|
+
const dialect = connection.dialect;
|
|
16
|
+
const q = dialect.quoteIdentifier;
|
|
17
|
+
const plans = new Map();
|
|
18
|
+
const fail = (reason) => { throw new DbCompileError('JD0038', reason, entity.docPath); };
|
|
19
|
+
const column = (name) => {
|
|
20
|
+
const c = mapping.columns.find((entry) => entry.name === name);
|
|
21
|
+
if (!c) return fail(`unknown stored member '${name}'`);
|
|
22
|
+
return c;
|
|
23
|
+
};
|
|
24
|
+
const writableColumn = (name) => {
|
|
25
|
+
const c = column(name);
|
|
26
|
+
if (c.generated || core.plan.keys.includes(name) || name === entity.version)
|
|
27
|
+
fail(`'${name}' is an identity, revision or generated member`);
|
|
28
|
+
return c;
|
|
29
|
+
};
|
|
30
|
+
const comparison = (c, prefix = '') => {
|
|
31
|
+
const value = prefix + q(c.physical);
|
|
32
|
+
return c.storage === 'string' ? dialect.codepoint(value) : value;
|
|
33
|
+
};
|
|
34
|
+
const compile = (document) => {
|
|
35
|
+
if (entity.physical == null || dialect.name !== 'sqlite') fail('native mutations require a declared SQLite column layout');
|
|
36
|
+
core.plan.writable();
|
|
37
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) fail('a mutation is an object');
|
|
38
|
+
const allowed = { update: ['key', 'expectedRevision', 'set'], upsert: ['values', 'conflict', 'update'],
|
|
39
|
+
'insert-select': ['source', 'where', 'select', 'conflict', 'onConflict'] }[document.op];
|
|
40
|
+
if (!allowed) fail('op must be update, upsert or insert-select');
|
|
41
|
+
for (const key of Object.keys(document))
|
|
42
|
+
if (!['op', 'returning', 'maxRows', 'maxBytes', ...allowed].includes(key)) fail(`unknown mutation member '${key}'`);
|
|
43
|
+
const maxRows = document.maxRows ?? 100;
|
|
44
|
+
const maxBytes = document.maxBytes ?? 1_048_576;
|
|
45
|
+
if (![maxRows, maxBytes].every((n) => Number.isSafeInteger(n) && n > 0 && n < Number.MAX_SAFE_INTEGER)) fail('mutation row and byte bounds must be positive safe integers');
|
|
46
|
+
const returning = document.returning ?? mapping.columns.map((c) => c.name);
|
|
47
|
+
if (!Array.isArray(returning) || !returning.length || new Set(returning).size !== returning.length) fail('returning is a nonempty distinct member list');
|
|
48
|
+
returning.forEach(column);
|
|
49
|
+
// A before/after invariant needs the preimage. These plans promise one
|
|
50
|
+
// data statement; database invariants retain their trigger enforcement.
|
|
51
|
+
if (entity.invariants.some((rule) => rule.enforcement === 'store')) fail('store invariants require the entity writer with before/after images');
|
|
52
|
+
const params = [];
|
|
53
|
+
const param = (value) => { params.push(value); return dialect.parameterRef(params.length, 'v'); };
|
|
54
|
+
const table = q(mapping.table);
|
|
55
|
+
let sql;
|
|
56
|
+
let prefix = '';
|
|
57
|
+
if (document.op === 'update') {
|
|
58
|
+
if (!document.set || typeof document.set !== 'object' || Array.isArray(document.set) || !Object.keys(document.set).length) fail('update needs a nonempty set object');
|
|
59
|
+
const assignments = Object.entries(document.set).map(([name, value]) => {
|
|
60
|
+
const c = writableColumn(name);
|
|
61
|
+
const encoded = core.plan.encodeColumn(name, value);
|
|
62
|
+
return { name: q(c.physical), compare: comparison(c), value: param(encoded), encoded };
|
|
63
|
+
});
|
|
64
|
+
const parts = core.normalizeKey(document.key);
|
|
65
|
+
const where = core.plan.keys.map((key, i) => `${q(column(key).physical)} = ${param(core.plan.encodeColumn(key, parts[i]))}`);
|
|
66
|
+
if (entity.version !== null) {
|
|
67
|
+
if (!Number.isSafeInteger(document.expectedRevision) || document.expectedRevision < 0) fail('a versioned update needs expectedRevision');
|
|
68
|
+
where.push(`${q(column(entity.version).physical)} = ${param(document.expectedRevision)}`);
|
|
69
|
+
}
|
|
70
|
+
else if (document.expectedRevision !== undefined) fail('expectedRevision needs a declared version member');
|
|
71
|
+
where.push(`(${assignments.map((a) => `${a.compare} IS NOT ${param(a.encoded)}`).join(' OR ')})`);
|
|
72
|
+
const sets = assignments.map((a) => `${a.name} = ${a.value}`);
|
|
73
|
+
if (entity.version !== null) sets.push(`${q(column(entity.version).physical)} = ${q(column(entity.version).physical)} + 1`);
|
|
74
|
+
sql = `UPDATE ${table} SET ${sets.join(', ')} WHERE ${where.join(' AND ')}`;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
if (JSON.stringify(document.conflict) !== JSON.stringify(core.plan.keys)) fail('conflict must name the complete ordered primary key');
|
|
78
|
+
let names;
|
|
79
|
+
let source;
|
|
80
|
+
if (document.op === 'upsert') {
|
|
81
|
+
if (!document.values || typeof document.values !== 'object' || Array.isArray(document.values)) fail('upsert needs values');
|
|
82
|
+
const complete = core.complete(document.values, { updating: false });
|
|
83
|
+
const split = core.plan.split(complete);
|
|
84
|
+
names = split.values.map((v) => v.name);
|
|
85
|
+
if (core.plan.keys.some((key) => !names.includes(key))) fail('upsert requires every primary-key value');
|
|
86
|
+
source = `VALUES (${split.values.map((v) => param(v.value)).join(', ')})`;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
if (document.source !== entity.name || document.onConflict !== 'nothing') fail('insert-select supports same-entity sources with onConflict nothing');
|
|
90
|
+
if (!document.select || typeof document.select !== 'object' || Array.isArray(document.select)) fail('insert-select needs a projection');
|
|
91
|
+
names = Object.keys(document.select);
|
|
92
|
+
if (!names.length || core.plan.keys.some((key) => !names.includes(key))) fail('insert-select projects every primary-key member');
|
|
93
|
+
const values = names.map((name) => {
|
|
94
|
+
const target = column(name);
|
|
95
|
+
if (target.generated || name === entity.version) fail('insert-select leaves generated values and revisions database-owned');
|
|
96
|
+
const value = document.select[name];
|
|
97
|
+
if (typeof value === 'string' && value.startsWith('$it.')) {
|
|
98
|
+
const from = column(value.slice(4));
|
|
99
|
+
if (from.codec !== target.codec || from.null !== target.null) fail('insert-select requires identical source and target codecs');
|
|
100
|
+
return `${q('s')}.${q(from.physical)}`;
|
|
101
|
+
}
|
|
102
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 1 && Object.hasOwn(value, '$literal'))
|
|
103
|
+
return param(core.plan.encodeColumn(name, value.$literal));
|
|
104
|
+
return fail('insert-select values are singular member paths or $literal values');
|
|
105
|
+
});
|
|
106
|
+
const analyzed = analyzeQuery({ $for: { it: '$[*]' }, $where: document.where ?? true, $return: '$it' });
|
|
107
|
+
const predicate = planEntityPredicate(analyzed.root.where, analyzed.root.forBindings[0].slot, entityShape(entity, mapping));
|
|
108
|
+
if ('refusal' in predicate) fail(predicate.refusal.reason);
|
|
109
|
+
const emitter = createEntityPredicateEmitters(dialect, (slot) => {
|
|
110
|
+
if (!Object.hasOwn(slot, 'literal')) return fail('mutation predicates use literal values');
|
|
111
|
+
return param(slot.literal);
|
|
112
|
+
});
|
|
113
|
+
const where = emitter.emitPred(q('s'), `${q('s')}.${q('doc')}`, predicate.pred);
|
|
114
|
+
source = `SELECT ${values.join(', ')} FROM ${table} AS ${q('s')} WHERE ${where}`
|
|
115
|
+
+ ` ORDER BY ${core.plan.keys.map((key) => `${q('s')}.${q(column(key).physical)}`).join(', ')}`
|
|
116
|
+
+ ` LIMIT ${maxRows + 1}`;
|
|
117
|
+
const aliases = names.map((name, i) => q(`v${i}`));
|
|
118
|
+
prefix = `WITH ${q('_jaren_source')} (${aliases.join(', ')}) AS MATERIALIZED (${source}) `;
|
|
119
|
+
source = `SELECT ${aliases.join(', ')} FROM ${q('_jaren_source')} WHERE `
|
|
120
|
+
+ dialect.mutationRowGuard(`(SELECT COUNT(*) FROM ${q('_jaren_source')})`, maxRows);
|
|
121
|
+
}
|
|
122
|
+
sql = `INSERT INTO ${table} (${names.map((name) => q(column(name).physical)).join(', ')}) ${source}`
|
|
123
|
+
+ ` ON CONFLICT (${core.plan.keys.map((key) => q(column(key).physical)).join(', ')})`;
|
|
124
|
+
if (document.op === 'insert-select') sql += ' DO NOTHING';
|
|
125
|
+
else {
|
|
126
|
+
if (!Array.isArray(document.update) || !document.update.length || new Set(document.update).size !== document.update.length) fail('upsert update names distinct stored members');
|
|
127
|
+
const changes = document.update.map((name) => {
|
|
128
|
+
const c = writableColumn(name);
|
|
129
|
+
if (!names.includes(name)) fail('an upsert update member must be supplied in values');
|
|
130
|
+
return { name: q(c.physical), compare: comparison(c, `${table}.`) };
|
|
131
|
+
});
|
|
132
|
+
const sets = changes.map(({ name }) => `${name} = excluded.${name}`);
|
|
133
|
+
if (entity.version !== null) sets.push(`${q(column(entity.version).physical)} = ${table}.${q(column(entity.version).physical)} + 1`);
|
|
134
|
+
sql += ` DO UPDATE SET ${sets.join(', ')} WHERE ${changes.map(({ name, compare }) => `${compare} IS NOT excluded.${name}`).join(' OR ')}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
sql = prefix + sql + ` RETURNING ${physicalSelection(mapping, dialect)}`;
|
|
138
|
+
return { sql, params, returning, maxRows, maxBytes, statement: null };
|
|
139
|
+
};
|
|
140
|
+
return (document) => {
|
|
141
|
+
const key = canonicalizeJson(document);
|
|
142
|
+
let plan = plans.get(key);
|
|
143
|
+
if (!plan) {
|
|
144
|
+
plan = compile(document);
|
|
145
|
+
if (plans.size >= 64) plans.delete(plans.keys().next().value);
|
|
146
|
+
plans.set(key, plan);
|
|
147
|
+
}
|
|
148
|
+
return connection.transaction(() => {
|
|
149
|
+
plan.statement ??= connection.prepare(plan.sql);
|
|
150
|
+
return chain(plan.statement, (statement) => chain(attempt(() => statement.all(plan.params),
|
|
151
|
+
(error) => String(error?.message).includes('jaren-mutation-row-bound')
|
|
152
|
+
? new DbRuntimeError('JD2007', 'insert-select exceeded its source row bound', { cause: error })
|
|
153
|
+
: wrapDriverError(error, { collection: entity.name, docPath: entity.docPath })), (rows) => {
|
|
154
|
+
const stored = rows.map(core.plan.merge);
|
|
155
|
+
if (rows.length > plan.maxRows || utf8Length(JSON.stringify(stored)) > plan.maxBytes)
|
|
156
|
+
throw new DbRuntimeError('JD2007', 'native mutation exceeded its returned row or byte bound');
|
|
157
|
+
stored.forEach(core.validateOnly);
|
|
158
|
+
const returned = stored.map((row) => Object.fromEntries(plan.returning
|
|
159
|
+
.filter((name) => Object.hasOwn(row, name)).map((name) => [name, row[name]])));
|
|
160
|
+
return { mode: 'native', affected: rows.length, rows: returned,
|
|
161
|
+
admitted: { statements: 1, rows: rows.length, bytes: utf8Length(JSON.stringify(stored)) } };
|
|
162
|
+
}));
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
}
|
package/src/physical.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Explicit column layouts and lossless JSON-facing column codecs. */
|
|
3
|
+
import { getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339 } from '@jarenjs/core/dates/rfc3339';
|
|
4
|
+
import { DbCompileError, DbRuntimeError } from './errors.js';
|
|
5
|
+
import { chain } from './driver.js';
|
|
6
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
7
|
+
|
|
8
|
+
const compiledCodecs = new WeakMap();
|
|
9
|
+
const CODECS = new Set(['text', 'integer', 'number', 'boolean', 'json', 'date', 'datetime', 'epoch-ms', 'bigint', 'decimal', 'blob-hex']);
|
|
10
|
+
const STORAGE = { text: 'string', integer: 'integer', number: 'number', boolean: 'boolean',
|
|
11
|
+
json: 'string', date: 'string', datetime: 'string', 'epoch-ms': 'integer', bigint: 'integer', decimal: 'string', 'blob-hex': 'string' };
|
|
12
|
+
const identifier = (v) => typeof v === 'string' && v.length > 0 && !v.includes('\0');
|
|
13
|
+
|
|
14
|
+
/** Normalize a declared layout; implicit conversions are never adoption policy.
|
|
15
|
+
* @param {any} physical @param {Map<string, any>} properties @param {string[]} keys
|
|
16
|
+
* @param {string} path @returns {any} */
|
|
17
|
+
export function normalizePhysical(physical, properties, keys, path) {
|
|
18
|
+
if (physical === undefined) return null;
|
|
19
|
+
const fail = (reason) => { throw new DbCompileError('JD0005', reason, `${path}/physical`); };
|
|
20
|
+
if (!physical || typeof physical !== 'object' || Array.isArray(physical)) fail('physical must be an object');
|
|
21
|
+
for (const key of Object.keys(physical))
|
|
22
|
+
if (!['table', 'kind', 'keys', 'columns'].includes(key)) fail(`unknown physical member '${key}'`);
|
|
23
|
+
if (!identifier(physical.table)) fail('physical.table must be a nonempty SQL identifier');
|
|
24
|
+
if (physical.kind !== undefined && !['table', 'view'].includes(physical.kind)) fail('physical.kind is table or view');
|
|
25
|
+
if (!physical.columns || typeof physical.columns !== 'object' || Array.isArray(physical.columns)) fail('physical.columns is required');
|
|
26
|
+
const ordered = physical.keys ?? keys;
|
|
27
|
+
if (!Array.isArray(ordered) || ordered.length !== keys.length || new Set(ordered).size !== keys.length
|
|
28
|
+
|| ordered.some((key) => !keys.includes(key))) fail('physical.keys must order every declared key exactly once');
|
|
29
|
+
const used = new Set();
|
|
30
|
+
const columns = [];
|
|
31
|
+
for (const [name, property] of properties) {
|
|
32
|
+
if (property.relation) continue;
|
|
33
|
+
const c = physical.columns[name];
|
|
34
|
+
if (!c || typeof c !== 'object' || Array.isArray(c) || !CODECS.has(c.codec)) fail(`'${name}' needs an explicit supported column codec`);
|
|
35
|
+
for (const key of Object.keys(c))
|
|
36
|
+
if (!['name', 'codec', 'null', 'default', 'generated'].includes(key)) fail(`unknown column member '${key}'`);
|
|
37
|
+
if (!identifier(c.name) || used.has(c.name.toLowerCase())) fail(`'${name}' needs a distinct physical column name`);
|
|
38
|
+
if (!['null', 'absent', 'reject'].includes(c.null)) fail(`'${name}' must declare SQL NULL as null, absent or reject`);
|
|
39
|
+
if (c.default !== undefined && c.default !== 'database') fail('column default ownership is database');
|
|
40
|
+
if (c.generated !== undefined && typeof c.generated !== 'boolean') fail('generated must be boolean');
|
|
41
|
+
if (property.key && (!['text', 'integer', 'bigint'].includes(c.codec) || c.null !== 'reject')) fail('keys require non-null text, integer or bigint codecs');
|
|
42
|
+
if (property.column !== undefined) fail('physical codecs replace hybrid column overrides');
|
|
43
|
+
if (c.default === 'database' && property.default !== undefined && property.default !== 'auto') fail('a default has exactly one owner');
|
|
44
|
+
used.add(c.name.toLowerCase());
|
|
45
|
+
columns.push({ name, physical: c.name, codec: c.codec, null: c.null, databaseDefault: c.default === 'database',
|
|
46
|
+
generated: c.generated === true, storage: STORAGE[c.codec], source: 'column', key: property.key });
|
|
47
|
+
}
|
|
48
|
+
for (const name of Object.keys(physical.columns))
|
|
49
|
+
if (!properties.has(name) || properties.get(name).relation) fail(`column '${name}' is not a stored property`);
|
|
50
|
+
return { table: physical.table, kind: physical.kind ?? 'table', keys: [...ordered], columns };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Compile one codec once, with a JSON-safe public value and a bound SQL value.
|
|
54
|
+
* @param {any} column @returns {{ encode: Function, decode: Function, normalize: Function }} */
|
|
55
|
+
export function columnCodec(column) {
|
|
56
|
+
if (compiledCodecs.has(column)) return compiledCodecs.get(column);
|
|
57
|
+
const fail = () => { throw new DbRuntimeError('JD2003', `column '${column.name}' refuses a lossy or invalid ${column.codec} value`); };
|
|
58
|
+
const date = (v, only) => typeof v === 'string' && Number.isFinite(only
|
|
59
|
+
? getEpochOfDateOnlyRFC3339(v) : getEpochOfDateTimeRFC3339(v));
|
|
60
|
+
const convert = (v, reading) => {
|
|
61
|
+
if (v === undefined || v === null) {
|
|
62
|
+
if (!reading && v === null && column.codec === 'json') return 'null';
|
|
63
|
+
if (column.null === 'reject') return fail();
|
|
64
|
+
return reading ? (column.null === 'absent' ? undefined : null) : null;
|
|
65
|
+
}
|
|
66
|
+
switch (column.codec) {
|
|
67
|
+
case 'text': if (typeof v === 'string') return v; break;
|
|
68
|
+
case 'integer': if (typeof v === 'number' && Number.isSafeInteger(v)) return v; break;
|
|
69
|
+
case 'number': if (typeof v === 'number' && Number.isFinite(v) && (!Number.isInteger(v) || Number.isSafeInteger(v))) return v; break;
|
|
70
|
+
case 'boolean':
|
|
71
|
+
if (reading && (v === 0 || v === 1 || typeof v === 'boolean')) return v === 1 || v === true;
|
|
72
|
+
if (!reading && typeof v === 'boolean') return v ? 1 : 0;
|
|
73
|
+
break;
|
|
74
|
+
case 'bigint': if (typeof v === 'string' && /^(?:0|-?[1-9][0-9]*)$/.test(v)
|
|
75
|
+
&& BigInt(v) >= -9223372036854775808n && BigInt(v) <= 9223372036854775807n) return v; break;
|
|
76
|
+
case 'decimal': if (typeof v === 'string' && /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(v)) return v; break;
|
|
77
|
+
case 'date': if (date(v, true)) return v; break;
|
|
78
|
+
case 'datetime': if (date(v, false)) return v; break;
|
|
79
|
+
case 'epoch-ms':
|
|
80
|
+
if (reading && typeof v === 'number' && Number.isSafeInteger(v) && Number.isFinite(new Date(v).getTime())) return new Date(v).toISOString();
|
|
81
|
+
if (!reading && date(v, false) && new Date(v).toISOString() === v) return getEpochOfDateTimeRFC3339(v);
|
|
82
|
+
break;
|
|
83
|
+
case 'blob-hex':
|
|
84
|
+
if (typeof v === 'string' && /^(?:[0-9a-fA-F]{2})*$/.test(v))
|
|
85
|
+
return reading ? v.toLowerCase() : Uint8Array.from(v.match(/../g) ?? [], (b) => parseInt(b, 16));
|
|
86
|
+
break;
|
|
87
|
+
case 'json':
|
|
88
|
+
try {
|
|
89
|
+
if (reading) return JSON.parse(v);
|
|
90
|
+
canonicalizeJson(v);
|
|
91
|
+
const text = JSON.stringify(v);
|
|
92
|
+
if (text !== undefined && JSON.stringify(JSON.parse(text)) === text) return text;
|
|
93
|
+
}
|
|
94
|
+
catch { return fail(); }
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
return fail();
|
|
98
|
+
};
|
|
99
|
+
const codec = { encode: (v) => convert(v, false), decode: (v) => convert(v, true), normalize: (v) => {
|
|
100
|
+
const encoded = convert(v, false);
|
|
101
|
+
return column.codec === 'blob-hex' && encoded !== null ? v.toLowerCase() : convert(encoded, true);
|
|
102
|
+
} };
|
|
103
|
+
compiledCodecs.set(column, codec);
|
|
104
|
+
return codec;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** SQL selection keeps unsafe integers and byte handles out of application state.
|
|
108
|
+
* @param {any} column @param {any} dialect @param {string} [prefix] @returns {string} */
|
|
109
|
+
export function physicalRead(column, dialect, prefix = '') {
|
|
110
|
+
const sql = `${prefix}${dialect.quoteIdentifier(column.physical ?? column.name)}`;
|
|
111
|
+
return dialect.physicalRead(column.codec, sql);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Verify an existing mapped object without executing DDL. Unmapped columns and
|
|
115
|
+
* all application-owned programs remain physical facts, never inferred drops.
|
|
116
|
+
* @param {any} connection @param {any} mapping @param {any} schema @returns {any} */
|
|
117
|
+
export function verifyPhysical(connection, mapping, schema) {
|
|
118
|
+
const fail = (why) => { throw new DbCompileError('JD0002', `physical '${mapping.table}': ${why}`); };
|
|
119
|
+
if (connection.dialect.name !== 'sqlite') fail('column adoption is qualified only for SQLite');
|
|
120
|
+
const object = schema.objects.find((o) => o.name === mapping.table && o.type === mapping.kind);
|
|
121
|
+
if (!object) fail(`declared ${mapping.kind} does not exist`);
|
|
122
|
+
for (const trigger of mapping.triggers ?? []) {
|
|
123
|
+
const actual = schema.objects.find((o) => o.type === 'trigger' && o.name === trigger.name);
|
|
124
|
+
if (!actual || actual.sql?.trim().replace(/;$/, '') !== trigger.sql.trim().replace(/;$/, '')) fail(`invariant trigger '${trigger.name}' is missing or changed; apply an explicit migration`);
|
|
125
|
+
}
|
|
126
|
+
const read = mapping.kind === 'view'
|
|
127
|
+
? chain(connection.prepare(connection.dialect.introspect.columns(mapping.table)), (s) =>
|
|
128
|
+
chain(s.all([]), (columns) => ({ columns: columns.map((c) => ({ ...c, generated: !!c.hidden })), primaryKey: [] })))
|
|
129
|
+
: schema.tables.find((t) => t.name === mapping.table);
|
|
130
|
+
return chain(read, (table) => {
|
|
131
|
+
if (mapping.kind !== 'view' && JSON.stringify(table.primaryKey) !== JSON.stringify(mapping.keys.map((k) => mapping.columns.find((c) => c.name === k).physical))) fail('ordered primary key disagrees');
|
|
132
|
+
for (const column of mapping.columns) {
|
|
133
|
+
const actual = table.columns.find((c) => c.name === column.physical);
|
|
134
|
+
if (!actual) fail(`column '${column.physical}' does not exist`);
|
|
135
|
+
const type = actual.type.toUpperCase();
|
|
136
|
+
if (!connection.dialect.physicalTypeMatches(column.codec, type)) fail(`'${column.physical}' type ${type} cannot guarantee codec ${column.codec}`);
|
|
137
|
+
if (mapping.kind !== 'view' && actual.generated !== column.generated) fail(`'${column.physical}' generated ownership disagrees`);
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Select mapped columns with their physical aliases for the shared row merger.
|
|
144
|
+
* @param {any} mapping @param {any} dialect @param {string} [prefix] @returns {string} */
|
|
145
|
+
export function physicalSelection(mapping, dialect, prefix = '') {
|
|
146
|
+
return mapping.columns.map((c) => `${physicalRead(c, dialect, prefix)} AS ${dialect.quoteIdentifier(c.physical ?? c.name)}`).join(', ');
|
|
147
|
+
}
|