@jarenjs/db 0.34.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 +397 -0
- package/README.md +218 -0
- package/dist/types/algebra.d.ts +133 -0
- package/dist/types/app.d.ts +49 -0
- package/dist/types/capture.d.ts +85 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/dag-job.d.ts +40 -0
- package/dist/types/ddl.d.ts +170 -0
- package/dist/types/dialect.d.ts +130 -0
- package/dist/types/dialects/sqlite.d.ts +9 -0
- package/dist/types/driver.d.ts +128 -0
- package/dist/types/drivers/bun.d.ts +47 -0
- package/dist/types/drivers/node.d.ts +37 -0
- package/dist/types/drivers/wasm.d.ts +65 -0
- package/dist/types/emit-model.d.ts +44 -0
- package/dist/types/emit.d.ts +72 -0
- package/dist/types/entity.d.ts +23 -0
- package/dist/types/errors.d.ts +165 -0
- package/dist/types/graph.d.ts +28 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/jobs.d.ts +134 -0
- package/dist/types/live.d.ts +62 -0
- package/dist/types/migrate.d.ts +163 -0
- package/dist/types/model.d.ts +36 -0
- package/dist/types/patch-sql.d.ts +37 -0
- package/dist/types/plan.d.ts +119 -0
- package/dist/types/profile.d.ts +80 -0
- package/dist/types/query.d.ts +100 -0
- package/dist/types/residual.d.ts +50 -0
- package/dist/types/store.d.ts +53 -0
- package/dist/types/tracker.d.ts +43 -0
- package/dist/types/typed.d.ts +15 -0
- package/dist/types/types.d.ts +26 -0
- package/dist/types/udf.d.ts +70 -0
- package/dist/types/window.d.ts +52 -0
- package/docs/JOBS-FORMAT.md +218 -0
- package/docs/LIVE-FORMAT.md +348 -0
- package/docs/MIGRATION-FORMAT.md +302 -0
- package/docs/MODEL-FORMAT.md +928 -0
- package/package.json +81 -0
- package/schemas/jaren-migration.draft-07.schema.json +144 -0
- package/schemas/jaren-migration.schema.json +144 -0
- package/schemas/jaren-model.draft-07.schema.json +149 -0
- package/schemas/jaren-model.schema.json +149 -0
- package/src/algebra.js +105 -0
- package/src/app.js +108 -0
- package/src/capture.js +584 -0
- package/src/cli.js +264 -0
- package/src/dag-job.js +86 -0
- package/src/ddl.js +588 -0
- package/src/dialect.js +297 -0
- package/src/dialects/sqlite.js +175 -0
- package/src/driver.js +419 -0
- package/src/drivers/bun.js +101 -0
- package/src/drivers/node.js +93 -0
- package/src/drivers/wasm.js +178 -0
- package/src/emit-model.js +208 -0
- package/src/emit.js +393 -0
- package/src/entity.js +367 -0
- package/src/errors.js +173 -0
- package/src/graph.js +101 -0
- package/src/index.js +64 -0
- package/src/jobs.js +507 -0
- package/src/live.js +899 -0
- package/src/migrate.js +1411 -0
- package/src/model.js +476 -0
- package/src/patch-sql.js +150 -0
- package/src/plan.js +1038 -0
- package/src/profile.js +131 -0
- package/src/query.js +1010 -0
- package/src/residual.js +91 -0
- package/src/store.js +1422 -0
- package/src/tracker.js +776 -0
- package/src/typed.js +19 -0
- package/src/types.js +36 -0
- package/src/udf.js +132 -0
- package/src/window.js +125 -0
- package/types/app.d.ts +36 -0
- package/types/bun.d.ts +9 -0
- package/types/index.d.ts +592 -0
- package/types/node.d.ts +15 -0
- package/types/typed.d.ts +108 -0
- package/types/wasm.d.ts +5 -0
package/src/entity.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Entity sets: create / read by key / update / delete over the
|
|
4
|
+
* hybrid mapping. The physical row is the mapped scalar columns, the
|
|
5
|
+
* foreign-key columns, and one JSONB `doc` column for everything
|
|
6
|
+
* else; a write SPLITS the completed document along the mapping and a
|
|
7
|
+
* read MERGES it back. Defaults apply in JavaScript before validation
|
|
8
|
+
* — the value the application sees and the value stored are the same
|
|
9
|
+
* — and identity follows the declared strategy (caller, uuid, auto).
|
|
10
|
+
*
|
|
11
|
+
* Epoch date columns are DERIVED: the document keeps the RFC 3339
|
|
12
|
+
* string, the column carries `getEpochOf…RFC3339(value)` so range
|
|
13
|
+
* predicates are index-friendly; reads take the string from the
|
|
14
|
+
* document and skip the derived column.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
18
|
+
import {
|
|
19
|
+
getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339,
|
|
20
|
+
} from '@jarenjs/core/dates/rfc3339';
|
|
21
|
+
|
|
22
|
+
import { DbRuntimeError } from './errors.js';
|
|
23
|
+
import { chain } from './driver.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The write/read machinery for one entity, prepared once.
|
|
27
|
+
* @param {any} connection
|
|
28
|
+
* @param {any} entity - the normalized entity (model.js)
|
|
29
|
+
* @param {any} entityMapping - `explainMapping(...).entities[name]`
|
|
30
|
+
* @param {((doc: any) => any) | null} validate
|
|
31
|
+
* @returns {any}
|
|
32
|
+
*/
|
|
33
|
+
export function entityCore(connection, entity, entityMapping, validate) {
|
|
34
|
+
const dialect = connection.dialect;
|
|
35
|
+
const q = dialect.quoteIdentifier;
|
|
36
|
+
const table = entityMapping.table;
|
|
37
|
+
const docPath = entity.docPath;
|
|
38
|
+
|
|
39
|
+
// the column plan: mapped scalars (epoch ones derived), then FKs;
|
|
40
|
+
// everything else lives in the JSONB document
|
|
41
|
+
const scalarColumns = entityMapping.columns.map((column) => ({
|
|
42
|
+
...column,
|
|
43
|
+
epoch: column.source === 'epoch(document)',
|
|
44
|
+
property: entity.properties.get(column.name),
|
|
45
|
+
}));
|
|
46
|
+
// a declared via property is ALREADY a scalar column — the foreign
|
|
47
|
+
// key adds a column only when no property claims it
|
|
48
|
+
const scalarNames = new Set(scalarColumns.map((column) => column.name));
|
|
49
|
+
const fkColumns = entityMapping.foreignKeys
|
|
50
|
+
.map((fk) => fk.column)
|
|
51
|
+
.filter((name) => !scalarNames.has(name));
|
|
52
|
+
const columnNames = [
|
|
53
|
+
...scalarColumns.map((column) => column.name),
|
|
54
|
+
...fkColumns,
|
|
55
|
+
];
|
|
56
|
+
const columnSet = new Set(columnNames);
|
|
57
|
+
const keys = entityMapping.keys;
|
|
58
|
+
const autoKey = keys.length === 1
|
|
59
|
+
&& entity.properties.get(keys[0]).default === 'auto' ? keys[0] : null;
|
|
60
|
+
|
|
61
|
+
const epochOf = (property, value) => {
|
|
62
|
+
if (typeof value !== 'string') return null;
|
|
63
|
+
// the mapped-instant contract (§10.3): a present string on an
|
|
64
|
+
// integer date column must parse in the property's own family and
|
|
65
|
+
// be Z-normalized — an offset form would let the derived epoch
|
|
66
|
+
// order hours away from the document string the engine compares
|
|
67
|
+
const dateOnly = property?.format === 'date';
|
|
68
|
+
const epoch = dateOnly
|
|
69
|
+
? getEpochOfDateOnlyRFC3339(value)
|
|
70
|
+
: getEpochOfDateTimeRFC3339(value);
|
|
71
|
+
if ((dateOnly || value.endsWith('Z'))
|
|
72
|
+
&& typeof epoch === 'number' && Number.isFinite(epoch)) return epoch;
|
|
73
|
+
throw new DbRuntimeError('JD2003',
|
|
74
|
+
`entity '${entity.name}' maps '${property.name}' to an integer date column: `
|
|
75
|
+
+ `the value must be ${dateOnly
|
|
76
|
+
? "a 'YYYY-MM-DD' date" : 'a Z-normalized RFC 3339 date-time'}`
|
|
77
|
+
+ ` (got ${JSON.stringify(value)})`,
|
|
78
|
+
{ docPath, collection: entity.name });
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const relationNames = new Set(
|
|
82
|
+
[...entity.properties.values()]
|
|
83
|
+
.filter((property) => property.relation !== undefined)
|
|
84
|
+
.map((property) => property.name));
|
|
85
|
+
|
|
86
|
+
/** Split a completed document into bound column values + the rest.
|
|
87
|
+
* Relation members are PROJECTIONS (§10.1) — never stored. */
|
|
88
|
+
const split = (doc) => {
|
|
89
|
+
const values = [];
|
|
90
|
+
/** @type {any} */
|
|
91
|
+
const rest = {};
|
|
92
|
+
for (const key of Object.keys(doc)) {
|
|
93
|
+
if (!columnSet.has(key) && !relationNames.has(key)) rest[key] = doc[key];
|
|
94
|
+
}
|
|
95
|
+
for (const column of scalarColumns) {
|
|
96
|
+
if (column.name === autoKey && doc[column.name] === undefined) continue;
|
|
97
|
+
const value = doc[column.name];
|
|
98
|
+
if (column.epoch) {
|
|
99
|
+
// derived: the string stays in the document, the epoch rides
|
|
100
|
+
// the column
|
|
101
|
+
rest[column.name] = value;
|
|
102
|
+
values.push({ name: column.name, value: epochOf(column.property, value) });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
values.push({
|
|
106
|
+
name: column.name,
|
|
107
|
+
value: value === undefined || value === null
|
|
108
|
+
? null
|
|
109
|
+
: typeof value === 'boolean' ? (value ? 1 : 0) : value,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
for (const fk of fkColumns) {
|
|
113
|
+
const value = doc[fk];
|
|
114
|
+
values.push({ name: fk, value: value === undefined || value === null ? null : value });
|
|
115
|
+
}
|
|
116
|
+
return { values, rest };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Merge a row back into a document. */
|
|
120
|
+
const merge = (row) => {
|
|
121
|
+
const doc = JSON.parse(row.doc);
|
|
122
|
+
for (const column of scalarColumns) {
|
|
123
|
+
if (column.epoch) continue; // the string is already in the doc
|
|
124
|
+
const value = row[column.name];
|
|
125
|
+
if (value === null || value === undefined) continue; // absent (§9.3)
|
|
126
|
+
doc[column.name] = column.storage === 'boolean' ? value === 1 : value;
|
|
127
|
+
}
|
|
128
|
+
for (const fk of fkColumns) {
|
|
129
|
+
const value = row[fk];
|
|
130
|
+
if (value !== null && value !== undefined) doc[fk] = value;
|
|
131
|
+
}
|
|
132
|
+
return doc;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// defaults, compiled once
|
|
136
|
+
const defaulters = [];
|
|
137
|
+
const updateStamps = [];
|
|
138
|
+
for (const property of entity.properties.values()) {
|
|
139
|
+
const declared = property.default;
|
|
140
|
+
if (declared === undefined || property.relation !== undefined) continue;
|
|
141
|
+
if (declared === 'now' || declared === 'updated') {
|
|
142
|
+
defaulters.push({ name: property.name, fill: () => new Date().toISOString() });
|
|
143
|
+
if (declared === 'updated')
|
|
144
|
+
updateStamps.push({ name: property.name, fill: () => new Date().toISOString() });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (declared === 'uuid') {
|
|
148
|
+
defaulters.push({ name: property.name, fill: () => crypto.randomUUID() });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (declared === 'auto') continue; // the database allocates
|
|
152
|
+
if (Object.hasOwn(declared, 'value')) {
|
|
153
|
+
defaulters.push({ name: property.name, fill: () => structuredClone(declared.value) });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const compiled = compileJsonQuery(declared.query);
|
|
157
|
+
defaulters.push({ name: property.name, fill: (doc) => compiled(doc) });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const applyDefaults = (doc, { updating }) => {
|
|
161
|
+
const out = { ...doc };
|
|
162
|
+
for (const { name, fill } of defaulters) {
|
|
163
|
+
if (out[name] === undefined) out[name] = fill(out);
|
|
164
|
+
}
|
|
165
|
+
if (updating) {
|
|
166
|
+
for (const { name, fill } of updateStamps) out[name] = fill(out);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const checkValid = (doc) => {
|
|
172
|
+
if (validate === null) return;
|
|
173
|
+
const outcome = validate(doc);
|
|
174
|
+
const valid = outcome === true || outcome?.valid === true;
|
|
175
|
+
if (!valid) {
|
|
176
|
+
throw new DbRuntimeError('JD2003',
|
|
177
|
+
`entity '${entity.name}' rejected the document`,
|
|
178
|
+
Array.isArray(outcome?.errors)
|
|
179
|
+
? { docPath, collection: entity.name, errors: outcome.errors }
|
|
180
|
+
: { docPath, collection: entity.name });
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// prepared statements, built lazily from the column plan
|
|
185
|
+
/** @type {Map<string, any>} */
|
|
186
|
+
const statements = new Map();
|
|
187
|
+
const prepared = (name, sql) => {
|
|
188
|
+
let statement = statements.get(name);
|
|
189
|
+
if (statement === undefined) {
|
|
190
|
+
statement = connection.prepare(sql);
|
|
191
|
+
statements.set(name, statement);
|
|
192
|
+
}
|
|
193
|
+
return statement;
|
|
194
|
+
};
|
|
195
|
+
const parameterAt = (i) => dialect.parameterRef(i, 'v');
|
|
196
|
+
const keyWhere = (offset) => keys
|
|
197
|
+
.map((key, i) => `${q(key)} = ${parameterAt(offset + i + 1)}`).join(' AND ');
|
|
198
|
+
const selectColumns = [
|
|
199
|
+
...scalarColumns.filter((column) => !column.epoch).map((column) => q(column.name)),
|
|
200
|
+
...fkColumns.map((column) => q(column)),
|
|
201
|
+
`${dialect.jsonText(q('doc'))} AS ${q('doc')}`,
|
|
202
|
+
].join(', ');
|
|
203
|
+
|
|
204
|
+
const insertSqlFor = (names) => {
|
|
205
|
+
const withDoc = [...names, 'doc'];
|
|
206
|
+
const refs = withDoc.map((name, i) => (name === 'doc'
|
|
207
|
+
? dialect.jsonEncode(parameterAt(i + 1))
|
|
208
|
+
: parameterAt(i + 1)));
|
|
209
|
+
const returning = autoKey !== null && !names.includes(autoKey)
|
|
210
|
+
? ` RETURNING ${q(autoKey)} AS ${q('key')}`
|
|
211
|
+
: '';
|
|
212
|
+
return `INSERT INTO ${q(table)} (${withDoc.map(q).join(', ')}) `
|
|
213
|
+
+ `VALUES (${refs.join(', ')})${returning}`;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const normalizeKeyArg = (key) => {
|
|
217
|
+
if (keys.length === 1) {
|
|
218
|
+
if (typeof key === 'string' || typeof key === 'number') return [key];
|
|
219
|
+
if (key !== null && typeof key === 'object' && !Array.isArray(key)
|
|
220
|
+
&& typeof key[keys[0]] !== 'object' && key[keys[0]] !== undefined)
|
|
221
|
+
return [key[keys[0]]];
|
|
222
|
+
}
|
|
223
|
+
else if (key !== null && typeof key === 'object' && !Array.isArray(key)) {
|
|
224
|
+
const parts = keys.map((name) => key[name]);
|
|
225
|
+
if (parts.every((part) => typeof part === 'string' || typeof part === 'number'))
|
|
226
|
+
return parts;
|
|
227
|
+
}
|
|
228
|
+
throw new DbRuntimeError('JD2002',
|
|
229
|
+
keys.length === 1
|
|
230
|
+
? 'an entity key must be a scalar'
|
|
231
|
+
: `a composite key needs { ${keys.join(', ')} }`,
|
|
232
|
+
{ docPath, collection: entity.name });
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const wrapWrite = (error, key) => {
|
|
236
|
+
if (/** @type {any} */ (error)?.code === 'JD2003') return error;
|
|
237
|
+
return new DbRuntimeError('JD2005',
|
|
238
|
+
`the database rejected the operation: ${/** @type {any} */ (error)?.message ?? String(error)}`,
|
|
239
|
+
key === undefined
|
|
240
|
+
? { docPath, collection: entity.name, cause: error }
|
|
241
|
+
: { docPath, collection: entity.name, key, cause: error });
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const columnByName = new Map(scalarColumns.map((column) => [column.name, column]));
|
|
245
|
+
/** Encode ONE column assignment the way {@link split} would. */
|
|
246
|
+
const encodeColumn = (name, value) => {
|
|
247
|
+
const column = columnByName.get(name);
|
|
248
|
+
if (column !== undefined && column.epoch)
|
|
249
|
+
return value === undefined ? null : epochOf(column.property, value);
|
|
250
|
+
if (value === undefined || value === null) return null;
|
|
251
|
+
return typeof value === 'boolean' ? (value ? 1 : 0) : value;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
// the unit-of-work exposure (tracker.js): the column plan and the
|
|
256
|
+
// completion/validation/stamping machinery, one source of truth
|
|
257
|
+
plan: {
|
|
258
|
+
table,
|
|
259
|
+
keys,
|
|
260
|
+
autoKey,
|
|
261
|
+
version: entity.version ?? null,
|
|
262
|
+
scalarColumns,
|
|
263
|
+
fkColumns,
|
|
264
|
+
columnSet,
|
|
265
|
+
split,
|
|
266
|
+
merge,
|
|
267
|
+
encodeColumn,
|
|
268
|
+
},
|
|
269
|
+
complete: (doc, { updating }) => {
|
|
270
|
+
const completed = applyDefaults(doc, { updating });
|
|
271
|
+
checkValid(completed);
|
|
272
|
+
return completed;
|
|
273
|
+
},
|
|
274
|
+
validateOnly: (doc) => checkValid(doc),
|
|
275
|
+
stampUpdated: (doc) => {
|
|
276
|
+
if (updateStamps.length === 0) return doc;
|
|
277
|
+
const out = { ...doc };
|
|
278
|
+
for (const { name, fill } of updateStamps) out[name] = fill(out);
|
|
279
|
+
return out;
|
|
280
|
+
},
|
|
281
|
+
normalizeKey: (key) => normalizeKeyArg(key),
|
|
282
|
+
create(doc) {
|
|
283
|
+
for (const name of relationNames) {
|
|
284
|
+
const value = doc?.[name];
|
|
285
|
+
if (value !== undefined && (!Array.isArray(value) || value.length > 0)) {
|
|
286
|
+
throw new DbRuntimeError('JD2003',
|
|
287
|
+
`'${name}' is a relation member — create() stores no `
|
|
288
|
+
+ 'projections; use the unit of work for membership',
|
|
289
|
+
{ docPath, collection: entity.name });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const completed = applyDefaults(doc, { updating: false });
|
|
293
|
+
checkValid(completed);
|
|
294
|
+
const { values, rest } = split(completed);
|
|
295
|
+
const names = values.map((value) => value.name);
|
|
296
|
+
const sql = insertSqlFor(names);
|
|
297
|
+
return chain(prepared(`insert:${names.join(',')}`, sql), (statement) => {
|
|
298
|
+
const params = [...values.map((value) => value.value), JSON.stringify(rest)];
|
|
299
|
+
let out;
|
|
300
|
+
try {
|
|
301
|
+
out = autoKey !== null && !names.includes(autoKey)
|
|
302
|
+
? statement.get(params)
|
|
303
|
+
: (statement.run(params), null);
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
throw wrapWrite(error, completed[keys[0]]);
|
|
307
|
+
}
|
|
308
|
+
if (out !== null) return { ...completed, [autoKey]: out.key };
|
|
309
|
+
return completed;
|
|
310
|
+
});
|
|
311
|
+
},
|
|
312
|
+
get(key) {
|
|
313
|
+
const parts = normalizeKeyArg(key);
|
|
314
|
+
const sql = `SELECT ${selectColumns} FROM ${q(table)} WHERE ${keyWhere(0)}`;
|
|
315
|
+
return chain(prepared('get', sql), (statement) =>
|
|
316
|
+
chain(statement.get(parts), (row) => (row === undefined ? undefined : merge(row))));
|
|
317
|
+
},
|
|
318
|
+
update(key, changes) {
|
|
319
|
+
const parts = normalizeKeyArg(key);
|
|
320
|
+
return chain(this.get(key), (current) => {
|
|
321
|
+
if (current === undefined) {
|
|
322
|
+
throw new DbRuntimeError('JD2006',
|
|
323
|
+
`no '${entity.name}' to update under that key`,
|
|
324
|
+
{ docPath, collection: entity.name });
|
|
325
|
+
}
|
|
326
|
+
const next = applyDefaults({ ...current, ...changes }, { updating: true });
|
|
327
|
+
// an explicit update is last-write-wins by contract (§11.2),
|
|
328
|
+
// but it still moves a declared version token so optimistic
|
|
329
|
+
// savers see the row changed
|
|
330
|
+
if (entity.version !== null && entity.version !== undefined)
|
|
331
|
+
next[entity.version] = (Number(current[entity.version]) || 0) + 1;
|
|
332
|
+
checkValid(next);
|
|
333
|
+
const { values, rest } = split(next);
|
|
334
|
+
const assignments = [
|
|
335
|
+
...values.map((value, i) => `${q(value.name)} = ${parameterAt(i + 1)}`),
|
|
336
|
+
`${q('doc')} = ${dialect.jsonEncode(parameterAt(values.length + 1))}`,
|
|
337
|
+
].join(', ');
|
|
338
|
+
const sql = `UPDATE ${q(table)} SET ${assignments} `
|
|
339
|
+
+ `WHERE ${keyWhere(values.length + 1)}`;
|
|
340
|
+
return chain(prepared(`update:${values.length}`, sql), (statement) => {
|
|
341
|
+
try {
|
|
342
|
+
statement.run([...values.map((value) => value.value),
|
|
343
|
+
JSON.stringify(rest), ...parts]);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
throw wrapWrite(error, parts[0]);
|
|
347
|
+
}
|
|
348
|
+
return next;
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
},
|
|
352
|
+
delete(key) {
|
|
353
|
+
const parts = normalizeKeyArg(key);
|
|
354
|
+
const sql = `DELETE FROM ${q(table)} WHERE ${keyWhere(0)}`;
|
|
355
|
+
return chain(prepared('delete', sql), (statement) => {
|
|
356
|
+
let out;
|
|
357
|
+
try {
|
|
358
|
+
out = statement.run(parts);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
throw wrapWrite(error, parts[0]);
|
|
362
|
+
}
|
|
363
|
+
return chain(out, (result) => Number(result?.changes ?? 0) > 0);
|
|
364
|
+
});
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Error types for @jarenjs/db, built on `@jarenjs/core`'s coded
|
|
4
|
+
* contract: every failure carries a stable `code` (JD0xxx compile-time,
|
|
5
|
+
* JD2xxx runtime), a bare `reason`, a composed `message`, and — where a
|
|
6
|
+
* position in the model document exists — a `docPath`. Runtime errors
|
|
7
|
+
* additionally carry the `collection` and, where one exists, the `key`
|
|
8
|
+
* as own properties. Database errors are wrapped, never leaked raw: the
|
|
9
|
+
* reason keeps the original text, `cause` keeps the original error. The
|
|
10
|
+
* normative table lives in docs/MODEL-FORMAT.md §7, proven in sync with
|
|
11
|
+
* `DB_CODES` below by a test.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { CodedError } from '@jarenjs/core/errors';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The runtime code table (the `CSV_CODES` shape): one entry per code
|
|
18
|
+
* this package can raise, proven in sync with MODEL-FORMAT.md §7's
|
|
19
|
+
* normative table by a test.
|
|
20
|
+
*/
|
|
21
|
+
export const DB_CODES = Object.freeze({
|
|
22
|
+
JD0001: 'the SQLite library is below the supported floor',
|
|
23
|
+
JD0002: 'the declared model disagrees with the existing database',
|
|
24
|
+
JD0003: 'the driver binding is unavailable on this runtime',
|
|
25
|
+
JD0004: 'an index path is not a singular member selection',
|
|
26
|
+
JD0005: 'the model document is invalid',
|
|
27
|
+
JD0010: 'strict mode refused a residual',
|
|
28
|
+
JD0011: 'the profile refused the document',
|
|
29
|
+
JD0012: 'work waited too long for the open transaction to settle',
|
|
30
|
+
JD0030: 'an unknown x-entity member was declared',
|
|
31
|
+
JD0031: 'relation declarations contradict each other',
|
|
32
|
+
JD0032: 'the include specification is invalid',
|
|
33
|
+
JD0040: 'the save spans a relation cycle',
|
|
34
|
+
JD0050: 'live queries require change capture',
|
|
35
|
+
JD0051: 'the demanded live mode is unavailable',
|
|
36
|
+
JD0052: 'the live-query bound was reached',
|
|
37
|
+
JD0020: "the migration's from-shape does not match the database",
|
|
38
|
+
JD0021: 'the migration is missing a required data transform',
|
|
39
|
+
JD0022: 'an applied migration disagrees with the history record',
|
|
40
|
+
JD0023: 'a migration step failed',
|
|
41
|
+
JD2001: 'insert found the key already present',
|
|
42
|
+
JD2002: 'a usable key could not be resolved for the write',
|
|
43
|
+
JD2003: 'the write failed schema validation',
|
|
44
|
+
JD2004: 'an undeclared collection was requested',
|
|
45
|
+
JD2005: 'a database operation failed',
|
|
46
|
+
JD2006: 'patch found no document at the key',
|
|
47
|
+
JD2007: 'the result exceeded the profile row bound',
|
|
48
|
+
JD2040: 'the row changed under an optimistic update',
|
|
49
|
+
JD2050: 'a changeset could not be decoded',
|
|
50
|
+
JD2051: 'the change log is not enabled',
|
|
51
|
+
JD2060: 'the maintained live state exceeded its bound',
|
|
52
|
+
JD2061: 'another context owns the database',
|
|
53
|
+
JD2062: 'the store closed with job handlers still in flight',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A defect found while opening a store — in the model document, the
|
|
58
|
+
* declared indexes, the driver binding, or the database's agreement
|
|
59
|
+
* with the declaration. Codes:
|
|
60
|
+
*
|
|
61
|
+
* - `JD0001` — the SQLite library reported a version below the
|
|
62
|
+
* supported floor; the reason names the version found
|
|
63
|
+
* - `JD0002` — a declared collection already exists in the database
|
|
64
|
+
* with a different shape; nothing was altered — changing shape is
|
|
65
|
+
* the migration story, a later capability
|
|
66
|
+
* - `JD0003` — the runtime builtin behind a driver could not be
|
|
67
|
+
* loaded here (Node cannot resolve `bun:`; Bun ships no
|
|
68
|
+
* `node:sqlite`), or an injected handle is missing
|
|
69
|
+
* - `JD0004` — an index path does not select exactly one member
|
|
70
|
+
* (wildcards, slices, filters and descendants are not indexable);
|
|
71
|
+
* the reason names the expression
|
|
72
|
+
* - `JD0005` — the model document is invalid; `docPath` points at
|
|
73
|
+
* the offending member
|
|
74
|
+
* - `JD0010` — `strict: true` and part of the query would have run
|
|
75
|
+
* outside the database; the reason names the forcing construct
|
|
76
|
+
* - `JD0011` — the active profile refused the document before any
|
|
77
|
+
* execution: an undeclared external, host function, collation or
|
|
78
|
+
* collection, or a refused full-table scan; the reason names it
|
|
79
|
+
* - `JD0030` — an unknown member inside an `x-entity` block; a
|
|
80
|
+
* silently ignored mapping directive is a data-loss bug waiting
|
|
81
|
+
* - `JD0031` — two relation declarations whose inverses contradict
|
|
82
|
+
* (different `via`, impossible `many` pairings)
|
|
83
|
+
* - `JD0032` — a graph-load include specification is invalid: an
|
|
84
|
+
* unknown relation, a cycle, an untranslatable filter, or the
|
|
85
|
+
* depth bound exceeded (the bound is printed, never silent)
|
|
86
|
+
* - `JD0040` — `saveChanges()` cannot order its statements: the
|
|
87
|
+
* entities being inserted or deleted form a foreign-key cycle
|
|
88
|
+
* (self-references included); break the save in two
|
|
89
|
+
* - `JD0050` — a live query was registered on a store opened without
|
|
90
|
+
* `capture`; the patch stream is the invalidation source
|
|
91
|
+
* - `JD0051` — `mode: 'incremental'` was demanded but the document
|
|
92
|
+
* classifies as re-run; the reason names the forcing construct
|
|
93
|
+
* - `JD0052` — registering would exceed the store's `live.maxQueries`
|
|
94
|
+
* bound; the bound is printed, never silent
|
|
95
|
+
* - `JD0020` — a migration's `from` hash does not match the
|
|
96
|
+
* database's recorded shape; running it would corrupt
|
|
97
|
+
* - `JD0021` — a draft transform was not filled in, or a document no
|
|
98
|
+
* longer validates after the migration (a narrowing without an
|
|
99
|
+
* adequate transform)
|
|
100
|
+
* - `JD0022` — the migration list disagrees with the applied history
|
|
101
|
+
* (an edited file, a missing file, a reordered sequence)
|
|
102
|
+
* - `JD0023` — a step failed: an assertion returned rows, DDL was
|
|
103
|
+
* rejected, or a transform produced an unstorable value
|
|
104
|
+
*/
|
|
105
|
+
export class DbCompileError extends CodedError {
|
|
106
|
+
/**
|
|
107
|
+
* @param {string} code
|
|
108
|
+
* @param {string} reason - The bare reason; `message` is composed per
|
|
109
|
+
* the coded contract.
|
|
110
|
+
* @param {string} [docPath] - JSON Pointer into the model document,
|
|
111
|
+
* where one exists.
|
|
112
|
+
* @param {Error} [cause]
|
|
113
|
+
*/
|
|
114
|
+
constructor(code, reason, docPath, cause) {
|
|
115
|
+
super('DbCompileError', code, reason, docPath,
|
|
116
|
+
cause !== undefined ? { cause } : undefined);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A failure while reading or writing an open store. Codes:
|
|
122
|
+
*
|
|
123
|
+
* - `JD2001` — `insert` hit a document already stored under the key
|
|
124
|
+
* - `JD2002` — the declared key pointer resolved to nothing or to a
|
|
125
|
+
* non-scalar, or an explicit key argument is not a string or number
|
|
126
|
+
* - `JD2003` — the injected validation hook rejected the document
|
|
127
|
+
* that a write would have stored; `errors` carries the hook's
|
|
128
|
+
* findings when it produced any
|
|
129
|
+
* - `JD2004` — `collection()` named a collection the model does not
|
|
130
|
+
* declare
|
|
131
|
+
* - `JD2005` — the database rejected an operation for a reason that
|
|
132
|
+
* is not a duplicate key; the original error is the `cause`
|
|
133
|
+
* - `JD2006` — `patch` addressed a key with no stored document
|
|
134
|
+
* - `JD2007` — a fetch crossed the profile's `maxRows` bound; the
|
|
135
|
+
* result is refused whole, never silently truncated
|
|
136
|
+
* - `JD2040` — an optimistic update or delete matched no row: the
|
|
137
|
+
* declared version changed under the save (or the row is gone);
|
|
138
|
+
* the error names the entity and key, and the whole save rolled
|
|
139
|
+
* back
|
|
140
|
+
* - `JD2050` — a session changeset carried bytes this decoder does
|
|
141
|
+
* not recognise (a future SQLite format change would land here)
|
|
142
|
+
* - `JD2051` — `changesSince` was called on a store whose capture
|
|
143
|
+
* has no persisted log
|
|
144
|
+
* - `JD2060` — maintenance crossed the live query's `maxMaintained`
|
|
145
|
+
* bound; the query delivered this error and closed rather than
|
|
146
|
+
* degrade
|
|
147
|
+
* - `JD2061` — a second context tried to open a database whose
|
|
148
|
+
* storage grants one context exclusive access (the owner topology
|
|
149
|
+
* of LIVE-FORMAT §11); connect to the owner instead
|
|
150
|
+
*/
|
|
151
|
+
export class DbRuntimeError extends CodedError {
|
|
152
|
+
/**
|
|
153
|
+
* @param {string} code
|
|
154
|
+
* @param {string} reason - The bare reason; `message` is composed per
|
|
155
|
+
* the coded contract.
|
|
156
|
+
* @param {{ docPath?: string, collection?: string,
|
|
157
|
+
* key?: string | number, errors?: unknown[], cause?: unknown }} [details]
|
|
158
|
+
* - `docPath` points into the model document (the collection the
|
|
159
|
+
* failure belongs to); `collection`/`key` are installed as own
|
|
160
|
+
* properties; `errors` carries validation findings; `cause` follows
|
|
161
|
+
* the coded contract's `hasOwn` form.
|
|
162
|
+
*/
|
|
163
|
+
constructor(code, reason, details = undefined) {
|
|
164
|
+
super('DbRuntimeError', code, reason,
|
|
165
|
+
details?.docPath !== undefined ? { docPath: details.docPath } : undefined,
|
|
166
|
+
details !== undefined && Object.hasOwn(details, 'cause')
|
|
167
|
+
? { cause: details.cause }
|
|
168
|
+
: undefined);
|
|
169
|
+
if (details?.collection !== undefined) this.collection = details.collection;
|
|
170
|
+
if (details?.key !== undefined) this.key = details.key;
|
|
171
|
+
if (details?.errors !== undefined) this.errors = details.errors;
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/graph.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Row → entity-graph reconstruction. One place owns the merge
|
|
4
|
+
* discipline (§9.3): mapped scalar columns fold back into the JSONB
|
|
5
|
+
* document's parse (booleans un-integer, SQL NULL reads back ABSENT,
|
|
6
|
+
* derived epoch columns are skipped because the string never left the
|
|
7
|
+
* document), foreign-key columns fold in the same way, and — for the
|
|
8
|
+
* one-statement graph loads — projected relation JSON parses
|
|
9
|
+
* recursively into child arrays or single children.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Merge one database row back into its entity document.
|
|
14
|
+
* @param {any} entityMapping - `explainMapping(...).entities[name]`
|
|
15
|
+
* @param {any} row - a row carrying the entity's columns plus the
|
|
16
|
+
* rendered document text
|
|
17
|
+
* @param {string} [docField] - the column the document text rides in
|
|
18
|
+
* @returns {any}
|
|
19
|
+
*/
|
|
20
|
+
export function mergeEntityRow(entityMapping, row, docField = 'doc') {
|
|
21
|
+
const doc = JSON.parse(row[docField]);
|
|
22
|
+
for (const column of entityMapping.columns) {
|
|
23
|
+
if (column.source === 'epoch(document)') continue;
|
|
24
|
+
const value = row[column.name];
|
|
25
|
+
if (value === null || value === undefined) continue;
|
|
26
|
+
doc[column.name] = column.storage === 'boolean' ? value === 1 : value;
|
|
27
|
+
}
|
|
28
|
+
for (const fk of entityMapping.foreignKeys) {
|
|
29
|
+
const value = row[fk.column];
|
|
30
|
+
if (value !== null && value !== undefined) doc[fk.column] = value;
|
|
31
|
+
}
|
|
32
|
+
return doc;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse one graph-load row: the root entity's merge plus every
|
|
37
|
+
* included relation's projected JSON, recursively.
|
|
38
|
+
* @param {any} node - the include-plan node
|
|
39
|
+
* `{ entityMapping, includes: { name, field, many, count, child }[] }`
|
|
40
|
+
* @param {any} row
|
|
41
|
+
* @param {string} docField
|
|
42
|
+
* @returns {any}
|
|
43
|
+
*/
|
|
44
|
+
export function parseGraphRow(node, row, docField = '__doc') {
|
|
45
|
+
const doc = mergeEntityRow(node.entityMapping, row, docField);
|
|
46
|
+
for (const include of node.includes) {
|
|
47
|
+
const raw = row[include.field];
|
|
48
|
+
if (include.count === true) {
|
|
49
|
+
doc[include.name] = Number(raw ?? 0);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (raw === null || raw === undefined) {
|
|
53
|
+
doc[include.name] = include.many ? [] : null;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const parsed = JSON.parse(raw);
|
|
57
|
+
doc[include.name] = include.many
|
|
58
|
+
? parsed.map((child) => parseGraphChild(include.child, child))
|
|
59
|
+
: parseGraphChild(include.child, parsed);
|
|
60
|
+
}
|
|
61
|
+
return doc;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A child arrives as a plain object (json_object projection): columns
|
|
66
|
+
* under their names, the document under `__doc` — embedded as real
|
|
67
|
+
* JSON, because json() carries the JSON subtype into json_object —
|
|
68
|
+
* and nested includes under their fields.
|
|
69
|
+
* @param {any} node
|
|
70
|
+
* @param {any} child
|
|
71
|
+
* @returns {any}
|
|
72
|
+
*/
|
|
73
|
+
function parseGraphChild(node, child) {
|
|
74
|
+
const doc = typeof child.__doc === 'string' ? JSON.parse(child.__doc) : child.__doc;
|
|
75
|
+
for (const column of node.entityMapping.columns) {
|
|
76
|
+
if (column.source === 'epoch(document)') continue;
|
|
77
|
+
const value = child[column.name];
|
|
78
|
+
if (value === null || value === undefined) continue;
|
|
79
|
+
doc[column.name] = column.storage === 'boolean' ? value === 1 : value;
|
|
80
|
+
}
|
|
81
|
+
for (const fk of node.entityMapping.foreignKeys) {
|
|
82
|
+
const value = child[fk.column];
|
|
83
|
+
if (value !== null && value !== undefined) doc[fk.column] = value;
|
|
84
|
+
}
|
|
85
|
+
for (const include of node.includes) {
|
|
86
|
+
const raw = child[include.field];
|
|
87
|
+
if (include.count === true) {
|
|
88
|
+
doc[include.name] = Number(raw ?? 0);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (raw === null || raw === undefined) {
|
|
92
|
+
doc[include.name] = include.many ? [] : null;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
96
|
+
doc[include.name] = include.many
|
|
97
|
+
? parsed.map((grandchild) => parseGraphChild(include.child, grandchild))
|
|
98
|
+
: parseGraphChild(include.child, parsed);
|
|
99
|
+
}
|
|
100
|
+
return doc;
|
|
101
|
+
}
|