@jarenjs/db 0.46.5 → 0.56.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 (72) hide show
  1. package/ARCHITECTURE.md +133 -17
  2. package/README.md +270 -36
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +139 -7
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +251 -30
  7. package/package.json +4 -5
  8. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  9. package/schemas/jaren-migration.schema.json +73 -0
  10. package/src/algebra.js +22 -3
  11. package/src/capture.js +66 -28
  12. package/src/cli.js +225 -44
  13. package/src/ddl.js +23 -3
  14. package/src/dialect.js +13 -0
  15. package/src/dialects/sqlite.js +21 -1
  16. package/src/driver.js +63 -16
  17. package/src/drivers/wasm.js +1 -0
  18. package/src/emit-model.js +14 -0
  19. package/src/emit.js +42 -9
  20. package/src/entity.js +92 -47
  21. package/src/errors.js +28 -0
  22. package/src/index.js +2 -2
  23. package/src/jobs.js +40 -5
  24. package/src/live-time.js +605 -0
  25. package/src/live.js +52 -9
  26. package/src/migrate.js +397 -191
  27. package/src/model.js +173 -8
  28. package/src/plan.js +834 -47
  29. package/src/query.js +296 -22
  30. package/src/residual.js +15 -6
  31. package/src/series.js +349 -0
  32. package/src/store.js +243 -69
  33. package/src/tracker.js +173 -48
  34. package/types/index.d.ts +206 -12
  35. package/types/node.d.ts +3 -1
  36. package/types/typed.d.ts +58 -2
  37. package/types/wasm.d.ts +7 -0
  38. package/dist/types/algebra.d.ts +0 -199
  39. package/dist/types/app.d.ts +0 -49
  40. package/dist/types/capture.d.ts +0 -85
  41. package/dist/types/cli.d.ts +0 -2
  42. package/dist/types/dag-job.d.ts +0 -40
  43. package/dist/types/ddl.d.ts +0 -229
  44. package/dist/types/derive.d.ts +0 -250
  45. package/dist/types/dialect.d.ts +0 -149
  46. package/dist/types/dialects/sqlite.d.ts +0 -9
  47. package/dist/types/driver.d.ts +0 -110
  48. package/dist/types/drivers/bun.d.ts +0 -47
  49. package/dist/types/drivers/node.d.ts +0 -37
  50. package/dist/types/drivers/wasm.d.ts +0 -65
  51. package/dist/types/emit-model.d.ts +0 -44
  52. package/dist/types/emit.d.ts +0 -75
  53. package/dist/types/entity.d.ts +0 -23
  54. package/dist/types/errors.d.ts +0 -167
  55. package/dist/types/graph.d.ts +0 -28
  56. package/dist/types/index.d.ts +0 -37
  57. package/dist/types/jobs.d.ts +0 -140
  58. package/dist/types/knn.d.ts +0 -69
  59. package/dist/types/live.d.ts +0 -62
  60. package/dist/types/migrate.d.ts +0 -170
  61. package/dist/types/model.d.ts +0 -36
  62. package/dist/types/patch-sql.d.ts +0 -37
  63. package/dist/types/plan.d.ts +0 -140
  64. package/dist/types/profile.d.ts +0 -80
  65. package/dist/types/query.d.ts +0 -111
  66. package/dist/types/residual.d.ts +0 -61
  67. package/dist/types/store.d.ts +0 -53
  68. package/dist/types/tracker.d.ts +0 -43
  69. package/dist/types/typed.d.ts +0 -15
  70. package/dist/types/types.d.ts +0 -26
  71. package/dist/types/udf.d.ts +0 -75
  72. package/dist/types/window.d.ts +0 -52
@@ -33,7 +33,8 @@ function jsonPathText(segments) {
33
33
  let text = '$';
34
34
  for (const segment of segments) {
35
35
  if ('index' in segment) {
36
- text += `[${segment.index}]`;
36
+ // SQLite counts from the end as `[#-1]`; a bare `[-1]` is a bad path
37
+ text += segment.index < 0 ? `[#${segment.index}]` : `[${segment.index}]`;
37
38
  continue;
38
39
  }
39
40
  // eslint-disable-next-line no-control-regex
@@ -82,6 +83,9 @@ export const sqliteDialect = createDialect({
82
83
  upsert: true,
83
84
  savepoints: true,
84
85
  alterTableFull: false,
86
+ // a GROUP BY / ORDER BY term may name a result alias, so a bucket
87
+ // ladder is written once rather than three times
88
+ groupByAlias: true,
85
89
  },
86
90
  tableSuffix: ' STRICT',
87
91
  // RFC 3339 text → epoch milliseconds, in SQL: the migration planner
@@ -152,6 +156,22 @@ export const sqliteDialect = createDialect({
152
156
  `(length(${patternA}) = 0 OR substr(${valueSql}, -length(${patternB})) = ${patternC})`,
153
157
  strContains: (valueSql, patternSql) => `instr(${valueSql}, ${patternSql}) > 0`,
154
158
  orderNulls: (nullsFirst) => (nullsFirst ? ' NULLS FIRST' : ' NULLS LAST'),
159
+ // the fixed bucket ladder, in integer arithmetic all the way down.
160
+ // `origin + floor((at - origin) / every) * every` is `at` less the
161
+ // NON-NEGATIVE remainder, and `((x % m) + m) % m` is how a language
162
+ // whose `%` truncates towards zero (C's, and SQLite's) spells one —
163
+ // which is the whole of why an instant before 1970 lands in its own
164
+ // bucket rather than the one after it. The column is declared
165
+ // INTEGER, so nothing here converts and nothing rounds.
166
+ timeBucket: (instantSql, originSql, everyA, everyB, everyC) =>
167
+ `(${instantSql} - (((${instantSql} - ${originSql}) % ${everyA} + ${everyB}) % ${everyC}))`,
168
+ // `rows` is COUNT(*) — the D5 count of SOURCE rows, which is not
169
+ // COUNT(value): a measured gap is a row that reported nothing, and
170
+ // the difference between "nobody reported" and "everybody reported a
171
+ // gap" is exactly what the count is for
172
+ groupAggregate: (fn, valueSql) => (valueSql === null
173
+ ? 'COUNT(*)'
174
+ : `${{ sum: 'SUM', avg: 'AVG', min: 'MIN', max: 'MAX' }[fn]}(${valueSql})`),
155
175
  rowIdentity: () => '"rowid"',
156
176
  // membership of the row identity in a bound list — the fetch of a
157
177
  // k-nearest plan's candidates. `IN` over the rowid is a primary-key
package/src/driver.js CHANGED
@@ -32,7 +32,7 @@
32
32
 
33
33
  import { isThenable, chain, toPromise } from '@jarenjs/core/function';
34
34
 
35
- import { DbCompileError } from './errors.js';
35
+ import { DbCompileError, DbRuntimeError } from './errors.js';
36
36
 
37
37
  /** The minimum SQLite the store accepts, asserted at open. */
38
38
  export const SQLITE_FLOOR = '3.45.0';
@@ -96,18 +96,43 @@ export function lazyOpen(specifier, reason, use, args) {
96
96
  * @returns {{ run: Function, get: Function, all: Function,
97
97
  * iterate: Function }}
98
98
  */
99
- export function wrapStatement(statement) {
99
+ export function wrapStatement(statement, guard = undefined) {
100
+ const before = guard ?? (() => {});
100
101
  return {
101
- run: (params = []) => statement.run(params),
102
- get: (params = []) => statement.get(params),
103
- all: (params = []) => statement.all(params),
102
+ run: (params = []) => { before(); return statement.run(params); },
103
+ get: (params = []) => { before(); return statement.get(params); },
104
+ all: (params = []) => { before(); return statement.all(params); },
104
105
  iterate: typeof statement.iterate === 'function'
105
- ? (params = []) => /** @type {Function} */ (statement.iterate)(params)
106
- : (params = []) => chain(statement.all(params),
107
- (rows) => rows[Symbol.iterator]()),
106
+ ? (params = []) => { before(); return /** @type {Function} */ (statement.iterate)(params); }
107
+ : (params = []) => { before(); return chain(statement.all(params),
108
+ (rows) => rows[Symbol.iterator]()); },
108
109
  };
109
110
  }
110
111
 
112
+ /**
113
+ * Run a driver call and map its failure — thrown OR rejected — through
114
+ * `wrap`. A `try/catch` around a value-or-promise call saw only the
115
+ * synchronous throw: on an asynchronous driver the failure arrived as a
116
+ * rejection nothing handled, so a rejected write resolved as a success
117
+ * and surfaced later as an unhandled rejection.
118
+ * @template T
119
+ * @param {() => T | Promise<T>} call
120
+ * @param {(error: any) => Error} wrap
121
+ * @returns {T | Promise<T>}
122
+ */
123
+ export function attempt(call, wrap) {
124
+ let out;
125
+ try {
126
+ out = call();
127
+ }
128
+ catch (error) {
129
+ throw wrap(error);
130
+ }
131
+ return isThenable(out)
132
+ ? /** @type {Promise<T>} */ (out).then(undefined, (error) => { throw wrap(error); })
133
+ : out;
134
+ }
135
+
111
136
  /**
112
137
  * Finish a raw binding into the connection contract: probe the library
113
138
  * once, assert the version floor, freeze the capability table, and
@@ -223,6 +248,16 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
223
248
  let onStack = false;
224
249
  /** @type {Array<() => void>} FIFO of work waiting for the owner. */
225
250
  const waiting = [];
251
+ /** Set by `close()`: every later call is refused by name rather than
252
+ * leaking the binding's own error (or, on a build that tolerates it,
253
+ * running against a closed handle). */
254
+ let closed = false;
255
+ const requireOpen = () => {
256
+ if (closed) {
257
+ throw new DbRuntimeError('JD2063',
258
+ 'the store is closed — a call after close() has no connection to run on');
259
+ }
260
+ };
226
261
 
227
262
  /** Hand the connection to the next waiter, in arrival order. */
228
263
  const release = () => {
@@ -315,9 +350,9 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
315
350
  capabilities,
316
351
  dialect,
317
352
  /** @param {string} sql */
318
- exec: (sql) => raw.exec(sql),
353
+ exec: (sql) => { requireOpen(); return raw.exec(sql); },
319
354
  /** @param {string} sql */
320
- prepare: (sql) => chain(raw.prepare(sql), wrapStatement),
355
+ prepare: (sql) => { requireOpen(); return chain(raw.prepare(sql), (s) => wrapStatement(s, requireOpen)); },
321
356
  /** A nested savepoint inside this transaction.
322
357
  * @param {(scope: any) => any} fn */
323
358
  transaction: (fn) => savepointAround(fn),
@@ -338,9 +373,14 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
338
373
  // two apart — give each concurrent writer its own store to separate
339
374
  // them). What the gate below does guarantee is that two TRANSACTIONS
340
375
  // never interleave, which is what made commits report failure.
341
- exec: (sql) => raw.exec(sql),
376
+ exec: (sql) => { requireOpen(); return raw.exec(sql); },
342
377
  /** @param {string} sql */
343
- prepare: (sql) => chain(raw.prepare(sql), wrapStatement),
378
+ prepare: (sql) => { requireOpen(); return chain(raw.prepare(sql), (s) => wrapStatement(s, requireOpen)); },
379
+ /** Whether a transaction issued NOW would have to queue: an owner
380
+ * holds the connection and no owning callback is on the stack (a
381
+ * synchronous call from inside the callback nests instead). What a
382
+ * synchronous surface must know before it would hand back a Promise. */
383
+ get mustQueue() { return owned && !onStack; },
344
384
  /**
345
385
  * A transaction. `fn`'s value is returned; a throw rolls back exactly
346
386
  * this level and rethrows. No implicit retry.
@@ -363,6 +403,7 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
363
403
  * @param {(scope: any) => any} fn
364
404
  */
365
405
  transaction(fn) {
406
+ requireOpen();
366
407
  if (onStack) return savepointAround(fn);
367
408
  return whenFree(() => {
368
409
  owned = true;
@@ -383,15 +424,21 @@ function finishConnection(raw, dialect, synchronous, capabilities, queueTimeout)
383
424
  (error) => { release(); throw error; });
384
425
  }, 'a transaction');
385
426
  },
386
- close: () => raw.close(),
427
+ // idempotent: the second close is a no-op on every driver, not a
428
+ // raw error on one and a resolved promise on another
429
+ close: () => {
430
+ if (closed) return undefined;
431
+ closed = true;
432
+ return raw.close();
433
+ },
387
434
  registerFunction: typeof raw.registerFunction === 'function'
388
- ? (name, functionOptions, fn) => raw.registerFunction(name, functionOptions, fn)
435
+ ? (name, functionOptions, fn) => { requireOpen(); return raw.registerFunction(name, functionOptions, fn); }
389
436
  : null,
390
437
  registerAggregate: typeof raw.registerAggregate === 'function'
391
- ? (name, spec) => raw.registerAggregate(name, spec)
438
+ ? (name, spec) => { requireOpen(); return raw.registerAggregate(name, spec); }
392
439
  : null,
393
440
  session: typeof raw.session === 'function'
394
- ? (table) => raw.session(table)
441
+ ? (table) => { requireOpen(); return raw.session(table); }
395
442
  : null,
396
443
  });
397
444
  }
@@ -166,6 +166,7 @@ export function wasmDriver(handle) {
166
166
  openConnection(raw, {
167
167
  dialect: sqliteDialect,
168
168
  synchronous: handle.synchronous === true,
169
+ queueTimeout: options?.queueTimeout,
169
170
  declared: {
170
171
  sessions: handle.declares?.sessions === true,
171
172
  userFunctions: handle.declares?.userFunctions === true,
package/src/emit-model.js CHANGED
@@ -32,6 +32,11 @@
32
32
  */
33
33
 
34
34
  import { normalizeEntities } from './model.js';
35
+ import { DbCompileError } from './errors.js';
36
+
37
+ /** The fixed declarations every artifact carries; an entity of one of
38
+ * these names would be emitted twice. */
39
+ const FIXED_DECLARATIONS = new Set(['DateTime', 'Entities', 'EntityInputs', 'EntityMetaMap']);
35
40
 
36
41
  const primitive = (name) => ({ kind: 'primitive', primitive: name });
37
42
  const ref = (name) => ({ kind: 'ref', ref: name });
@@ -75,6 +80,15 @@ export function entityEmitModel(model, options) {
75
80
  throw new TypeError("entityEmitModel needs { compile: compileEmitModel } injected");
76
81
  const entities = normalizeEntities(model);
77
82
  const entityNames = [...entities.keys()];
83
+ for (const name of entityNames) {
84
+ if (FIXED_DECLARATIONS.has(name)
85
+ || (name.endsWith('Input') && entityNames.includes(name.slice(0, -'Input'.length)))) {
86
+ throw new DbCompileError('JD0005',
87
+ `entity '${name}' collides with a fixed declaration of the emitted artifact `
88
+ + `(${[...FIXED_DECLARATIONS].join(', ')}, and <Entity>Input for every entity)`,
89
+ `/entities/${name}`);
90
+ }
91
+ }
78
92
 
79
93
  // every entity name, input name and the brand are reserved up front
80
94
  // so nested-shape hints can never steal them
package/src/emit.js CHANGED
@@ -18,6 +18,14 @@
18
18
 
19
19
  import { codePointPrefixSuccessor } from '@jarenjs/core/string';
20
20
 
21
+ /**
22
+ * A promoted path the dialect's JSON path grammar cannot spell (a
23
+ * member name holding a double quote or a control character). The
24
+ * planner promotes by SCHEMA, not by grammar, so the query engine
25
+ * catches this and runs the document in the set residual instead.
26
+ */
27
+ export class UnrepresentablePath extends Error {}
28
+
21
29
  /**
22
30
  * @typedef {{ external: string } | { literal: unknown } |
23
31
  * { derived: { kind: 'bboxAxis', external: string,
@@ -117,9 +125,8 @@ export function emitPlan(plan, dialect, physical) {
117
125
  const pathTextOf = (ref) => {
118
126
  const text = dialect.jsonPathText(ref.segments);
119
127
  if (text === null) {
120
- // the planner never promotes an unrepresentable path; reaching
121
- // this is an internal inconsistency, not a user error
122
- throw new Error('emit: a promoted path is not representable in the dialect JSON path grammar');
128
+ throw new UnrepresentablePath('a member name the dialect\'s JSON path grammar cannot '
129
+ + 'carry (a double quote or a control character) runs in the residual');
123
130
  }
124
131
  return text;
125
132
  };
@@ -273,6 +280,17 @@ export function emitPlan(plan, dialect, physical) {
273
280
  }
274
281
  };
275
282
 
283
+ // The temporal bucket's ladder, written ONCE and named: the SELECT
284
+ // list carries it, and the grouping and the ordering name the alias.
285
+ // Writing it three times would triple its parameters, and a bucket
286
+ // start is exactly the kind of value the caller wants to read back.
287
+ const bucketSql = plan.bucket === null ? null
288
+ : dialect.timeBucket(valueOf(plan.bucket.ref),
289
+ param({ literal: plan.bucket.origin }),
290
+ param({ literal: plan.bucket.every }),
291
+ param({ literal: plan.bucket.every }),
292
+ param({ literal: plan.bucket.every }));
293
+
276
294
  const selection = plan.rank !== null
277
295
  // the k-nearest fetch: the row identity and the packed column
278
296
  // under the pushed WHERE, and nothing that orders or limits — the
@@ -280,15 +298,30 @@ export function emitPlan(plan, dialect, physical) {
280
298
  // the rank loses to fetching the column and ranking in the engine,
281
299
  // and none of them runs where no function can be registered)
282
300
  ? `${dialect.rowIdentity()} AS ${q('rid')}, ${q(plan.rank.column)} AS ${q('vec')}`
283
- : plan.aggregate === null
284
- ? `${dialect.jsonText(docColumn)} AS ${q('doc')}`
285
- : plan.aggregate.fn === 'count'
286
- ? `COUNT(*) AS ${q('value')}`
287
- : `${plan.aggregate.fn.toUpperCase()}(${valueOf(plan.aggregate.ref)}) AS ${q('value')}`;
301
+ : plan.bucket !== null
302
+ ? [`${bucketSql} AS ${q(plan.bucket.as)}`,
303
+ ...plan.bucket.aggregates.map((entry) =>
304
+ `${dialect.groupAggregate(entry.fn,
305
+ entry.ref === null ? null : valueOf(entry.ref))} AS ${q(entry.as)}`)].join(', ')
306
+ : plan.aggregate === null
307
+ ? `${dialect.jsonText(docColumn)} AS ${q('doc')}`
308
+ : plan.aggregate.fn === 'count'
309
+ ? `COUNT(*) AS ${q('value')}`
310
+ : `${plan.aggregate.fn.toUpperCase()}(${valueOf(plan.aggregate.ref)}) AS ${q('value')}`;
288
311
 
289
312
  let sql = `SELECT ${selection} FROM ${q(physical.table)}`;
290
313
  if (plan.filter !== null) sql += ` WHERE ${emitPred(plan.filter)}`;
291
- if (plan.aggregate === null && plan.rank === null) {
314
+ if (plan.bucket !== null) {
315
+ // `first-seen` is the engine's own group order (§6.5, first
316
+ // appearance), which over a collection is the group's earliest row
317
+ // identity — the same tiebreaker the ungrouped fetch appends
318
+ const alias = q(plan.bucket.as);
319
+ const order = plan.bucket.order === 'first-seen'
320
+ ? dialect.groupAggregate('min', dialect.rowIdentity())
321
+ : `${alias} ${plan.bucket.order === 'desc' ? 'DESC' : 'ASC'}`;
322
+ sql += ` GROUP BY ${alias} ORDER BY ${order}`;
323
+ }
324
+ if (plan.aggregate === null && plan.rank === null && plan.bucket === null) {
292
325
  const terms = (plan.order ?? []).map((term) => {
293
326
  // Jaren's default sorts an empty key least: NULLS FIRST when
294
327
  // ascending, NULLS LAST when descending — and mirrored for
package/src/entity.js CHANGED
@@ -19,8 +19,8 @@ import {
19
19
  getEpochOfDateTimeRFC3339, getEpochOfDateOnlyRFC3339,
20
20
  } from '@jarenjs/core/dates/rfc3339';
21
21
 
22
- import { DbRuntimeError } from './errors.js';
23
- import { chain } from './driver.js';
22
+ import { DbRuntimeError, isDuplicateKeyError } from './errors.js';
23
+ import { chain, attempt } from './driver.js';
24
24
 
25
25
  /**
26
26
  * The write/read machinery for one entity, prepared once.
@@ -132,6 +132,28 @@ export function entityCore(connection, entity, entityMapping, validate) {
132
132
  return doc;
133
133
  };
134
134
 
135
+ /** The document as a read will answer it. For a column-mapped scalar,
136
+ * JSON `null` and absence both store as SQL NULL and read back ABSENT
137
+ * (§9.3) — so a value a write RETURNS drops it too, and the promise
138
+ * this file opens with holds: the value the application sees IS the
139
+ * value stored. A property that needs present-`null` declares
140
+ * `column: "json"` and stays in the document, where it survives; an
141
+ * epoch column keeps its string in the document for the same reason. */
142
+ const asStored = (doc) => {
143
+ let out = doc;
144
+ const drop = (name) => {
145
+ if (!(name in out) || (out[name] !== null && out[name] !== undefined)) return;
146
+ if (out === doc) out = { ...doc };
147
+ delete out[name];
148
+ };
149
+ for (const column of scalarColumns) {
150
+ if (column.epoch) continue; // the string is in the document, null and all
151
+ drop(column.name);
152
+ }
153
+ for (const fk of fkColumns) drop(fk);
154
+ return out;
155
+ };
156
+
135
157
  // defaults, compiled once
136
158
  const defaulters = [];
137
159
  const updateStamps = [];
@@ -139,9 +161,13 @@ export function entityCore(connection, entity, entityMapping, validate) {
139
161
  const declared = property.default;
140
162
  if (declared === undefined || property.relation !== undefined) continue;
141
163
  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() });
164
+ // a `date` property takes the calendar date; a date-time stamp on
165
+ // it was invalid under its own format and refused by an epoch column
166
+ const stamp = property.format === 'date'
167
+ ? () => new Date().toISOString().slice(0, 10)
168
+ : () => new Date().toISOString();
169
+ defaulters.push({ name: property.name, fill: stamp });
170
+ if (declared === 'updated') updateStamps.push({ name: property.name, fill: stamp });
145
171
  continue;
146
172
  }
147
173
  if (declared === 'uuid') {
@@ -162,12 +188,42 @@ export function entityCore(connection, entity, entityMapping, validate) {
162
188
  for (const { name, fill } of defaulters) {
163
189
  if (out[name] === undefined) out[name] = fill(out);
164
190
  }
191
+ // the version token starts at 0 on insert: a row written with SQL
192
+ // NULL never matched the tracker's `WHERE ver = 0` and could not be
193
+ // saved through the unit of work at all
194
+ if (!updating && entity.version !== null && entity.version !== undefined
195
+ && out[entity.version] === undefined) {
196
+ out[entity.version] = 0;
197
+ }
165
198
  if (updating) {
166
199
  for (const { name, fill } of updateStamps) out[name] = fill(out);
167
200
  }
168
201
  return out;
169
202
  };
170
203
 
204
+ /**
205
+ * Refuse relation members a write cannot store: every relation member
206
+ * is a projection (§10.1), except a many-to-many MEMBERSHIP array,
207
+ * which `create()`/`add()` attach through the join table.
208
+ * @param {any} doc
209
+ * @param {string} verb
210
+ * @param {boolean} memberships - whether membership arrays are taken
211
+ */
212
+ const refuseProjections = (doc, verb, memberships) => {
213
+ for (const name of relationNames) {
214
+ const value = doc?.[name];
215
+ if (value === undefined || (Array.isArray(value) && value.length === 0)) continue;
216
+ const relation = entity.properties.get(name).relation;
217
+ if (memberships && relation.kind === 'manyToMany' && Array.isArray(value)) continue;
218
+ throw new DbRuntimeError('JD2003',
219
+ `'${name}' is a relation member — ${verb}() stores no projections; `
220
+ + (relation.kind === 'manyToMany'
221
+ ? 'membership changes through the unit of work (put + saveChanges)'
222
+ : 'write the related entities themselves'),
223
+ { docPath, collection: entity.name });
224
+ }
225
+ };
226
+
171
227
  const checkValid = (doc) => {
172
228
  if (validate === null) return;
173
229
  const outcome = validate(doc);
@@ -233,7 +289,13 @@ export function entityCore(connection, entity, entityMapping, validate) {
233
289
  };
234
290
 
235
291
  const wrapWrite = (error, key) => {
236
- if (/** @type {any} */ (error)?.code === 'JD2003') return error;
292
+ const code = /** @type {any} */ (error)?.code;
293
+ if (typeof code === 'string' && code.startsWith('JD')) return error;
294
+ if (keys.length === 1 && isDuplicateKeyError(error, table, keys[0])) {
295
+ return new DbRuntimeError('JD2001',
296
+ `a '${entity.name}' already exists under key ${JSON.stringify(key)}`,
297
+ { docPath, collection: entity.name, key, cause: error });
298
+ }
237
299
  return new DbRuntimeError('JD2005',
238
300
  `the database rejected the operation: ${/** @type {any} */ (error)?.message ?? String(error)}`,
239
301
  key === undefined
@@ -269,7 +331,7 @@ export function entityCore(connection, entity, entityMapping, validate) {
269
331
  complete: (doc, { updating }) => {
270
332
  const completed = applyDefaults(doc, { updating });
271
333
  checkValid(completed);
272
- return completed;
334
+ return asStored(completed);
273
335
  },
274
336
  validateOnly: (doc) => checkValid(doc),
275
337
  stampUpdated: (doc) => {
@@ -280,15 +342,7 @@ export function entityCore(connection, entity, entityMapping, validate) {
280
342
  },
281
343
  normalizeKey: (key) => normalizeKeyArg(key),
282
344
  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
- }
345
+ refuseProjections(doc, 'create', true);
292
346
  const completed = applyDefaults(doc, { updating: false });
293
347
  checkValid(completed);
294
348
  const { values, rest } = split(completed);
@@ -296,17 +350,11 @@ export function entityCore(connection, entity, entityMapping, validate) {
296
350
  const sql = insertSqlFor(names);
297
351
  return chain(prepared(`insert:${names.join(',')}`, sql), (statement) => {
298
352
  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;
353
+ const returning = autoKey !== null && !names.includes(autoKey);
354
+ return chain(
355
+ attempt(() => (returning ? statement.get(params) : statement.run(params)),
356
+ (error) => wrapWrite(error, completed[keys[0]])),
357
+ (out) => asStored(returning ? { ...completed, [autoKey]: out.key } : completed));
310
358
  });
311
359
  },
312
360
  get(key) {
@@ -317,12 +365,22 @@ export function entityCore(connection, entity, entityMapping, validate) {
317
365
  },
318
366
  update(key, changes) {
319
367
  const parts = normalizeKeyArg(key);
368
+ refuseProjections(changes, 'update', false);
320
369
  return chain(this.get(key), (current) => {
321
370
  if (current === undefined) {
322
371
  throw new DbRuntimeError('JD2006',
323
372
  `no '${entity.name}' to update under that key`,
324
373
  { docPath, collection: entity.name });
325
374
  }
375
+ for (const keyName of keys) {
376
+ // the key identifies the row the UPDATE addresses; rewriting it
377
+ // through `changes` moved rows out from under the tracker
378
+ if (changes?.[keyName] !== undefined && changes[keyName] !== current[keyName]) {
379
+ throw new DbRuntimeError('JD2003',
380
+ `'${keyName}' is the primary key — update() cannot rewrite it; delete and create`,
381
+ { docPath, collection: entity.name, key: parts[0] });
382
+ }
383
+ }
326
384
  const next = applyDefaults({ ...current, ...changes }, { updating: true });
327
385
  // an explicit update is last-write-wins by contract (§11.2),
328
386
  // but it still moves a declared version token so optimistic
@@ -337,31 +395,18 @@ export function entityCore(connection, entity, entityMapping, validate) {
337
395
  ].join(', ');
338
396
  const sql = `UPDATE ${q(table)} SET ${assignments} `
339
397
  + `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
- });
398
+ return chain(prepared(`update:${values.length}`, sql), (statement) =>
399
+ chain(attempt(() => statement.run([...values.map((value) => value.value),
400
+ JSON.stringify(rest), ...parts]), (error) => wrapWrite(error, parts[0])),
401
+ () => asStored(next)));
350
402
  });
351
403
  },
352
404
  delete(key) {
353
405
  const parts = normalizeKeyArg(key);
354
406
  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
- });
407
+ return chain(prepared('delete', sql), (statement) =>
408
+ chain(attempt(() => statement.run(parts), (error) => wrapWrite(error, parts[0])),
409
+ (result) => Number(result?.changes ?? 0) > 0));
365
410
  },
366
411
  };
367
412
  }
package/src/errors.js CHANGED
@@ -18,6 +18,23 @@ import { CodedError } from '@jarenjs/core/errors';
18
18
  * this package can raise, proven in sync with MODEL-FORMAT.md §7's
19
19
  * normative table by a test.
20
20
  */
21
+ /**
22
+ * Whether a database error is the unique-key collision of `table.column`
23
+ * — the one failure every insert path reports as `JD2001` rather than
24
+ * the generic `JD2005`. One detector for the collection cores, the
25
+ * entity cores and the unit of work, so no path wraps it differently.
26
+ * @param {any} error
27
+ * @param {string} table
28
+ * @param {string} column
29
+ * @returns {boolean}
30
+ */
31
+ export function isDuplicateKeyError(error, table, column) {
32
+ if (error?.errcode === 1555) return true;
33
+ return typeof error?.message === 'string'
34
+ && error.message.includes('UNIQUE constraint failed')
35
+ && error.message.includes(`${table}.${column}`);
36
+ }
37
+
21
38
  export const DB_CODES = Object.freeze({
22
39
  JD0001: 'the SQLite library is below the supported floor',
23
40
  JD0002: 'the declared model disagrees with the existing database',
@@ -30,10 +47,12 @@ export const DB_CODES = Object.freeze({
30
47
  JD0030: 'an unknown x-entity member was declared',
31
48
  JD0031: 'relation declarations contradict each other',
32
49
  JD0032: 'the include specification is invalid',
50
+ JD0033: 'an entity query names no entity array',
33
51
  JD0040: 'the save spans a relation cycle',
34
52
  JD0050: 'live queries require change capture',
35
53
  JD0051: 'the demanded live mode is unavailable',
36
54
  JD0052: 'the live-query bound was reached',
55
+ JD0053: 'the live event-time declaration is invalid',
37
56
  JD0020: "the migration's from-shape does not match the database",
38
57
  JD0021: 'the migration is missing a required data transform',
39
58
  JD0022: 'an applied migration disagrees with the history record',
@@ -51,6 +70,7 @@ export const DB_CODES = Object.freeze({
51
70
  JD2060: 'the maintained live state exceeded its bound',
52
71
  JD2061: 'another context owns the database',
53
72
  JD2062: 'the store closed with job handlers still in flight',
73
+ JD2063: 'the store is closed',
54
74
  });
55
75
 
56
76
  /**
@@ -82,6 +102,9 @@ export const DB_CODES = Object.freeze({
82
102
  * silently ignored mapping directive is a data-loss bug waiting
83
103
  * - `JD0031` — two relation declarations whose inverses contradict
84
104
  * (different `via`, impossible `many` pairings)
105
+ * - `JD0033` — a document handed to `store.execute()` over entities
106
+ * ranges over no declared entity array (`$.<Entity>[*]`); the root
107
+ * is the map of entity arrays, so there are no rows to answer
85
108
  * - `JD0032` — a graph-load include specification is invalid: an
86
109
  * unknown relation, a cycle, an untranslatable filter, or the
87
110
  * depth bound exceeded (the bound is printed, never silent)
@@ -94,6 +117,8 @@ export const DB_CODES = Object.freeze({
94
117
  * classifies as re-run; the reason names the forcing construct
95
118
  * - `JD0052` — registering would exceed the store's `live.maxQueries`
96
119
  * bound; the bound is printed, never silent
120
+ * - `JD0053` — a live query's `eventTime` names a member it does not
121
+ * admit, or a watermark/retention that is not a finite span
97
122
  * - `JD0020` — a migration's `from` hash does not match the
98
123
  * database's recorded shape; running it would corrupt
99
124
  * - `JD0021` — a draft transform was not filled in, or a document no
@@ -149,6 +174,9 @@ export class DbCompileError extends CodedError {
149
174
  * - `JD2061` — a second context tried to open a database whose
150
175
  * storage grants one context exclusive access (the owner topology
151
176
  * of LIVE-FORMAT §11); connect to the owner instead
177
+ * - `JD2063` — a call after `close()`: every entry point of a closed
178
+ * store refuses by name rather than leaking the driver's own error,
179
+ * and a second `close()` is a no-op on every driver
152
180
  */
153
181
  export class DbRuntimeError extends CodedError {
154
182
  /**
package/src/index.js CHANGED
@@ -21,7 +21,7 @@ export {
21
21
  } from './ddl.js';
22
22
  export {
23
23
  planQuery, assertDecidedKind, entityShape, entityPathRef,
24
- planEntityPredicate, planEntityQuery,
24
+ planEntityPredicate, planEntityQuery, collectEntityRoots, entityRoot,
25
25
  } from './plan.js';
26
26
  export { emitPlan, createEntityPredicateEmitters, emitEntityPlan } from './emit.js';
27
27
  export { mergeEntityRow, parseGraphRow } from './graph.js';
@@ -45,7 +45,7 @@ export {
45
45
  applyMandatoryPredicate, applyRowBound,
46
46
  } from './profile.js';
47
47
  export { translatePatch } from './patch-sql.js';
48
- export { normalizeEntities, explainMapping } from './model.js';
48
+ export { normalizeEntities, explainMapping, relationTables } from './model.js';
49
49
  export { planEntity, planJoinTable } from './ddl.js';
50
50
  export { entityCore } from './entity.js';
51
51
  export { entityEmitModel } from './emit-model.js';