@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.
Files changed (83) hide show
  1. package/ARCHITECTURE.md +397 -0
  2. package/README.md +218 -0
  3. package/dist/types/algebra.d.ts +133 -0
  4. package/dist/types/app.d.ts +49 -0
  5. package/dist/types/capture.d.ts +85 -0
  6. package/dist/types/cli.d.ts +2 -0
  7. package/dist/types/dag-job.d.ts +40 -0
  8. package/dist/types/ddl.d.ts +170 -0
  9. package/dist/types/dialect.d.ts +130 -0
  10. package/dist/types/dialects/sqlite.d.ts +9 -0
  11. package/dist/types/driver.d.ts +128 -0
  12. package/dist/types/drivers/bun.d.ts +47 -0
  13. package/dist/types/drivers/node.d.ts +37 -0
  14. package/dist/types/drivers/wasm.d.ts +65 -0
  15. package/dist/types/emit-model.d.ts +44 -0
  16. package/dist/types/emit.d.ts +72 -0
  17. package/dist/types/entity.d.ts +23 -0
  18. package/dist/types/errors.d.ts +165 -0
  19. package/dist/types/graph.d.ts +28 -0
  20. package/dist/types/index.d.ts +35 -0
  21. package/dist/types/jobs.d.ts +134 -0
  22. package/dist/types/live.d.ts +62 -0
  23. package/dist/types/migrate.d.ts +163 -0
  24. package/dist/types/model.d.ts +36 -0
  25. package/dist/types/patch-sql.d.ts +37 -0
  26. package/dist/types/plan.d.ts +119 -0
  27. package/dist/types/profile.d.ts +80 -0
  28. package/dist/types/query.d.ts +100 -0
  29. package/dist/types/residual.d.ts +50 -0
  30. package/dist/types/store.d.ts +53 -0
  31. package/dist/types/tracker.d.ts +43 -0
  32. package/dist/types/typed.d.ts +15 -0
  33. package/dist/types/types.d.ts +26 -0
  34. package/dist/types/udf.d.ts +70 -0
  35. package/dist/types/window.d.ts +52 -0
  36. package/docs/JOBS-FORMAT.md +218 -0
  37. package/docs/LIVE-FORMAT.md +348 -0
  38. package/docs/MIGRATION-FORMAT.md +302 -0
  39. package/docs/MODEL-FORMAT.md +928 -0
  40. package/package.json +81 -0
  41. package/schemas/jaren-migration.draft-07.schema.json +144 -0
  42. package/schemas/jaren-migration.schema.json +144 -0
  43. package/schemas/jaren-model.draft-07.schema.json +149 -0
  44. package/schemas/jaren-model.schema.json +149 -0
  45. package/src/algebra.js +105 -0
  46. package/src/app.js +108 -0
  47. package/src/capture.js +584 -0
  48. package/src/cli.js +264 -0
  49. package/src/dag-job.js +86 -0
  50. package/src/ddl.js +588 -0
  51. package/src/dialect.js +297 -0
  52. package/src/dialects/sqlite.js +175 -0
  53. package/src/driver.js +419 -0
  54. package/src/drivers/bun.js +101 -0
  55. package/src/drivers/node.js +93 -0
  56. package/src/drivers/wasm.js +178 -0
  57. package/src/emit-model.js +208 -0
  58. package/src/emit.js +393 -0
  59. package/src/entity.js +367 -0
  60. package/src/errors.js +173 -0
  61. package/src/graph.js +101 -0
  62. package/src/index.js +64 -0
  63. package/src/jobs.js +507 -0
  64. package/src/live.js +899 -0
  65. package/src/migrate.js +1411 -0
  66. package/src/model.js +476 -0
  67. package/src/patch-sql.js +150 -0
  68. package/src/plan.js +1038 -0
  69. package/src/profile.js +131 -0
  70. package/src/query.js +1010 -0
  71. package/src/residual.js +91 -0
  72. package/src/store.js +1422 -0
  73. package/src/tracker.js +776 -0
  74. package/src/typed.js +19 -0
  75. package/src/types.js +36 -0
  76. package/src/udf.js +132 -0
  77. package/src/window.js +125 -0
  78. package/types/app.d.ts +36 -0
  79. package/types/bun.d.ts +9 -0
  80. package/types/index.d.ts +592 -0
  81. package/types/node.d.ts +15 -0
  82. package/types/typed.d.ts +108 -0
  83. 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
+ }