@jarenjs/db 0.73.0 → 0.83.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/ARCHITECTURE.md +70 -7
  2. package/README.md +69 -6
  3. package/docs/HOSTS.md +17 -0
  4. package/docs/JOBS-FORMAT.md +26 -0
  5. package/docs/LIVE-FORMAT.md +52 -13
  6. package/docs/MIGRATION-FORMAT.md +34 -0
  7. package/docs/MODEL-FORMAT.md +163 -15
  8. package/docs/NATIVE-PLANS.md +111 -0
  9. package/docs/REPLICATION-FORMAT.md +19 -13
  10. package/docs/SEARCH.md +55 -0
  11. package/package.json +8 -4
  12. package/schemas/jaren-migration.draft-07.schema.json +54 -5
  13. package/schemas/jaren-migration.schema.json +49 -0
  14. package/schemas/jaren-model.authoring.schema.json +360 -0
  15. package/schemas/jaren-model.draft-07.schema.json +128 -0
  16. package/schemas/jaren-model.schema.json +128 -0
  17. package/src/algebra.js +26 -4
  18. package/src/backup.js +12 -7
  19. package/src/cursor.js +27 -4
  20. package/src/dag-job.js +2 -1
  21. package/src/ddl.js +13 -0
  22. package/src/derive.js +14 -3
  23. package/src/dialect.js +12 -0
  24. package/src/dialects/check-read.js +151 -0
  25. package/src/dialects/invariant-sql.js +117 -0
  26. package/src/dialects/postgres.js +28 -4
  27. package/src/dialects/sqlite.js +23 -3
  28. package/src/driver.js +1 -0
  29. package/src/drivers/bun.js +22 -4
  30. package/src/emit.js +133 -25
  31. package/src/entity.js +98 -41
  32. package/src/errors.js +8 -0
  33. package/src/graph.js +8 -1
  34. package/src/index.js +3 -0
  35. package/src/introspect.js +81 -12
  36. package/src/invariants.js +45 -0
  37. package/src/jobs.js +39 -6
  38. package/src/live-nested.js +27 -10
  39. package/src/live.js +51 -136
  40. package/src/migrate.js +136 -22
  41. package/src/model.js +12 -0
  42. package/src/mutation.js +165 -0
  43. package/src/physical.js +147 -0
  44. package/src/plan.js +275 -64
  45. package/src/query.js +175 -78
  46. package/src/search.js +144 -0
  47. package/src/sql.js +60 -0
  48. package/src/store.js +49 -13
  49. package/src/tracker.js +63 -39
  50. package/src/window.js +1 -0
  51. package/types/index.d.ts +59 -4
  52. package/types/search.d.ts +20 -0
  53. package/types/typed.d.ts +1 -0
package/src/search.js ADDED
@@ -0,0 +1,144 @@
1
+ //@ts-check
2
+ /** Optional lexical persistence and bounded execution over authoritative entity snapshots. */
3
+ import { compileLexical } from '@jarenjs/core/search';
4
+ import { createLexicalProvider } from '@jarenjs/json/query';
5
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
6
+ import { utf8ByteLength } from '@jarenjs/core/string';
7
+ import { isCursorBudgetError } from './cursor.js';
8
+
9
+ /** Atomic snapshot storage over a host-declared collection of {id, payload} documents.
10
+ * Snapshot rows are derived caches and never answer catalog queries.
11
+ * @param {any} store @param {string} collection @param {{maxBytes?:number}} [options] */
12
+ export function createDbSearchStorage(store, collection, options = {}) {
13
+ const maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
14
+ if (typeof collection !== 'string' || !collection || !Number.isSafeInteger(maxBytes) || maxBytes < 1)
15
+ throw new TypeError('Invalid lexical snapshot storage');
16
+ return {
17
+ async load(id) { const row = await store.collection(collection).get(id);
18
+ if (!row) return null;
19
+ if (typeof row.payload !== 'string' || row.payload.length > maxBytes || utf8ByteLength(row.payload) > maxBytes)
20
+ throw new RangeError('Snapshot storage byte credits');
21
+ return row.payload;
22
+ },
23
+ async save(id, payload) {
24
+ if (typeof id !== 'string' || !id || typeof payload !== 'string' || payload.length > maxBytes || utf8ByteLength(payload) > maxBytes)
25
+ throw new RangeError('Snapshot storage byte credits');
26
+ return store.transaction(async (tx) => {
27
+ const target = tx.collection(collection), old = await target.get(id);
28
+ if (old?.payload === payload) return { changes: 0 };
29
+ await target.put({ id, payload }); return { changes: 1 };
30
+ });
31
+ },
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Read a bounded, complete entity snapshot inside the store transaction; captured
37
+ * commits invalidate it. External SQL invalidates through dataVersion on request.
38
+ * A SHA-256 source-content revision also detects uncaptured edits across reopen.
39
+ * Native FTS is deliberately refused: this adapter executes the shared ranker.
40
+ * @param {any} store @param {string} entity
41
+ * @param {import('@jarenjs/core/search').LexicalDefinition} definition
42
+ * @param {{source:string, maxRows?:number, maxBytes?:number, storage?:{load:(key:string)=>Promise<string|null>,save:(key:string,payload:string)=>Promise<any>}, snapshotKey?:string}} options
43
+ * @returns {Promise<any>}
44
+ */
45
+ export async function createDbSearch(store, entity, definition, options) {
46
+ const maxRows = options?.maxRows ?? 10000, maxBytes = options?.maxBytes ?? 8 * 1024 * 1024;
47
+ if (typeof options?.source !== 'string' || !options.source || !Number.isSafeInteger(maxRows) || maxRows < 1
48
+ || !Number.isSafeInteger(maxBytes) || maxBytes < 2) throw new TypeError('Invalid lexical source credits');
49
+ if (!store.capabilities?.capture || store.capabilities.capture === 'none') throw new TypeError('Lexical freshness requires committed capture');
50
+ const compiled = compileLexical(definition), index = compiled.create(), storage = options.storage;
51
+ const snapshotKey = options.snapshotKey ?? `${options.source}:${entity}`;
52
+ let rows = new Map(), sourceRevision = '', dataVersion, dirty = true, disposed = false, epoch = 0, busy = null;
53
+ let reads = 0, writes = 0, restores = 0, rebuilds = 0, sourceBytes = 0, recovery = null, pendingSnapshot = null;
54
+ const observers = new Set(), controller = new AbortController();
55
+ const invalidate = (reason) => {
56
+ dirty = true; epoch++;
57
+ for (const observer of observers) { try { observer({ type: 'reset', reason, revision: epoch, sourceRevision }); }
58
+ catch { /* An observer cannot suppress a sibling's invalidation. */ } }
59
+ };
60
+ const unsubscribe = store.observe((record) => { if (record.collections.includes(entity)) invalidate('source-changed'); });
61
+ const refusal = (state, reason) => ({ state, reason, hits: [], total: null, sourceRevision });
62
+ const refresh = async () => {
63
+ if (disposed) return refusal('error', 'disposed');
64
+ if (busy) return busy;
65
+ const run = async () => {
66
+ try {
67
+ const loaded = await store.transaction(async (tx) => {
68
+ const current = await tx.dataVersion();
69
+ if (dataVersion !== undefined && current !== dataVersion) invalidate('external-source-changed');
70
+ dataVersion = current;
71
+ if (!dirty) return null;
72
+ const page = await tx.entity(entity).page({ orderBy: '$it.id' },
73
+ { limit: maxRows + 1, maxBytes, lookahead: false, signal: controller.signal });
74
+ reads++;
75
+ if (page.hasMore !== false || page.items.length > maxRows) throw new RangeError('Lexical source exceeds row credits');
76
+ const source = canonicalizeJson(page.items);
77
+ if (utf8ByteLength(source) > maxBytes) throw new RangeError('Lexical source exceeds byte credits');
78
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(source));
79
+ const revision = `${options.source}:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')}`;
80
+ return { items: page.items, revision, bytes: utf8ByteLength(source), epoch };
81
+ }, { signal: controller.signal });
82
+ if (disposed) return refusal('error', 'disposed');
83
+ if (!loaded) return { state: 'complete', changes: 0, sourceRevision };
84
+ if (loaded.epoch !== epoch) return refusal('invalidated', 'source-changed');
85
+ if (loaded.revision === sourceRevision) {
86
+ if (pendingSnapshot !== null) { writes += (await storage.save(snapshotKey, pendingSnapshot)).changes; pendingSnapshot = null; }
87
+ if (disposed || loaded.epoch !== epoch) return refusal(disposed ? 'error' : 'invalidated', disposed ? 'disposed' : 'source-changed');
88
+ dirty = false; return { state: 'complete', changes: 0, sourceRevision };
89
+ }
90
+ const nextRows = new Map(loaded.items.map((row) => [row.id, Object.freeze(row)]));
91
+ if (nextRows.size !== loaded.items.length) throw new TypeError('Duplicate lexical source IDs');
92
+ let result;
93
+ if (!sourceRevision && storage) {
94
+ const saved = await storage.load(snapshotKey);
95
+ if (saved !== null) {
96
+ result = index.restore(saved, { sourceRevision: loaded.revision });
97
+ if (result.state === 'complete') restores++; else recovery = result.reason;
98
+ }
99
+ }
100
+ if (disposed || loaded.epoch !== epoch) return refusal(disposed ? 'error' : 'invalidated', disposed ? 'disposed' : 'source-changed');
101
+ if (result?.state !== 'complete') {
102
+ result = index.rebuild(loaded.items, { sourceRevision: loaded.revision }); rebuilds++;
103
+ }
104
+ if (result.state !== 'complete') return { ...result, hits: [], total: null };
105
+ rows = nextRows; sourceRevision = loaded.revision; sourceBytes = loaded.bytes; dirty = false;
106
+ if (storage) { pendingSnapshot = index.snapshot(); writes += (await storage.save(snapshotKey, pendingSnapshot)).changes; pendingSnapshot = null; }
107
+ if (disposed || loaded.epoch !== epoch) return refusal(disposed ? 'error' : 'invalidated', disposed ? 'disposed' : 'source-changed');
108
+ return { state: 'complete', changes: result.changes, sourceRevision };
109
+ }
110
+ catch (error) { dirty = true; return refusal(error instanceof RangeError || isCursorBudgetError(error)
111
+ ? 'budget-exhausted' : 'error', disposed ? 'disposed' : error?.message ?? String(error)); }
112
+ };
113
+ busy = run().finally(() => { busy = null; }); return busy;
114
+ };
115
+ const service = {
116
+ refresh,
117
+ get sourceRevision() { return sourceRevision; },
118
+ async search(text, spec = {}) {
119
+ const ready = await refresh(); if (ready.state !== 'complete') return ready;
120
+ return createLexicalProvider(index, { row: (id) => rows.get(id) }).compile(spec)(text);
121
+ },
122
+ /** Access a row only under its published search revision. */
123
+ row(id, revision) {
124
+ if (disposed || dirty || revision !== sourceRevision) throw new Error('Lexical source snapshot changed');
125
+ return structuredClone(rows.get(id));
126
+ },
127
+ subscribe(fn) {
128
+ if (disposed || typeof fn !== 'function') throw new TypeError('Invalid lexical observer');
129
+ if (observers.size >= 8) throw new RangeError('Lexical subscription credits');
130
+ observers.add(fn); return () => observers.delete(fn);
131
+ },
132
+ explain() { return { mode: 'resident', nativeFTS: false, reason: 'native-token-rank-parity-unqualified',
133
+ maxRows, maxBytes, capture: store.capabilities.capture, externalChanges: 'dataVersion plus authoritative SHA-256 on refresh' }; },
134
+ stats() { return { ...index.stats(), sourceRows: rows.size, sourceBytes, reads, writes, restores, rebuilds,
135
+ recovery, dirty, pending: busy ? 1 : 0, subscriptions: observers.size }; },
136
+ async dispose() {
137
+ disposed = true; controller.abort(); unsubscribe(); observers.clear();
138
+ await busy; index.dispose(); rows.clear(); sourceBytes = 0; pendingSnapshot = null;
139
+ },
140
+ };
141
+ const ready = await refresh();
142
+ if (ready.state !== 'complete') { await service.dispose(); throw new Error(`Lexical source: ${ready.reason}`); }
143
+ return service;
144
+ }
package/src/sql.js ADDED
@@ -0,0 +1,60 @@
1
+ //@ts-check
2
+ /** Trusted prepared statements, restricted to a live transaction's authority. */
3
+ import { sqlTokens } from './dialects/check-read.js';
4
+ import { chain, attempt } from './driver.js';
5
+ import { DbRuntimeError, wrapDriverError } from './errors.js';
6
+
7
+ /** @param {any} context @returns {any} a scoped SQL capability */
8
+ export function trustedSql({ connection, requireScope, beforeWrite, afterWrite, readOnly }) {
9
+ return Object.freeze({
10
+ /** Compile one statement. Access is explicit; this is trusted application SQL,
11
+ * not a sandbox for untrusted query text or side-effecting host functions.
12
+ * @param {string} sql @param {{ access: 'read' | 'write', affects?: readonly string[] }} options */
13
+ prepare(sql, options) {
14
+ requireScope();
15
+ const refuse = (why) => { throw new DbRuntimeError('JD2095', why); };
16
+ if (typeof sql !== 'string' || !['read', 'write'].includes(options?.access)) refuse('SQL prepare requires text and explicit read/write access');
17
+ const tokens = sqlTokens(sql);
18
+ const words = tokens.filter((t) => t.kind === 'word').map((t) => t.value.toUpperCase());
19
+ if (!tokens.length || tokens[0].kind !== 'word' || !['SELECT', 'WITH', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE'].includes(words[0])) refuse('trusted SQL accepts a single SELECT or data mutation');
20
+ if (tokens.some((t, i) => t.value === ';' && t.kind === 'symbol' && i !== tokens.length - 1)
21
+ || words.some((w) => /^(?:BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|ATTACH|DETACH|PRAGMA|CREATE|ALTER|DROP|VACUUM)$/.test(w))) refuse('SQL cannot change transaction ownership, schema or connection configuration');
22
+ const writing = options.access === 'write';
23
+ if (!writing && words.some((w) => ['INSERT', 'UPDATE', 'DELETE', 'REPLACE'].includes(w))) refuse('a mutation requires write access');
24
+ if (writing && readOnly) refuse('this store grants no SQL write authority');
25
+ if (options.affects !== undefined && (!Array.isArray(options.affects) || options.affects.some((v) => typeof v !== 'string'))) refuse('affects is an array of entity names');
26
+ let closed = false;
27
+ const check = () => {
28
+ requireScope();
29
+ if (closed) refuse('the prepared SQL statement is closed');
30
+ };
31
+ const statement = connection.prepare(sql, { readOnly: !writing });
32
+ const run = (method, params = []) => {
33
+ check();
34
+ if (!Array.isArray(params)) refuse('SQL parameters must be an array');
35
+ return attempt(() => chain(statement, (s) => {
36
+ check();
37
+ if (writing) beforeWrite(options.affects);
38
+ return chain(s[method](params), (result) => {
39
+ if (writing) afterWrite(options.affects);
40
+ return result;
41
+ });
42
+ }), (error) => wrapDriverError(error, { docPath: '/sql' }));
43
+ };
44
+ return Object.freeze({ run: (params) => run('run', params),
45
+ get: (params) => run('get', params), all: (params) => run('all', params),
46
+ close: () => { requireScope(); closed = true; } });
47
+ },
48
+ });
49
+ }
50
+
51
+ /** A synchronous transaction ends at callback return, including a thenable return.
52
+ * @param {Function} fn @param {any} tx @returns {any} */
53
+ export function synchronousBody(fn, tx) {
54
+ const value = fn(tx);
55
+ if (value != null && typeof value.then === 'function') {
56
+ Promise.resolve(value).catch(() => {});
57
+ throw new DbRuntimeError('JD2095', 'a synchronous transaction callback must not return a thenable');
58
+ }
59
+ return value;
60
+ }
package/src/store.js CHANGED
@@ -36,6 +36,8 @@ import { createBackup } from './backup.js';
36
36
  import { normalizeProfile, assertProfileRoots } from './profile.js';
37
37
  import { normalizeEntities, explainMapping, joinTableRoots } from './model.js';
38
38
  import { entityCore } from './entity.js';
39
+ import { verifyPhysical } from './physical.js';
40
+ import { trustedSql, synchronousBody } from './sql.js';
39
41
  import { createTracker, membershipKeys } from './tracker.js';
40
42
  import { createCaptureEngine, DEFAULT_RETENTION } from './capture.js';
41
43
  import { createReplicationEngine } from './replication.js';
@@ -46,7 +48,7 @@ import { createLiveRegistry, classifyLiveQuery, LIVE_DEFAULTS } from './live.js'
46
48
  import { classifyEntityLive } from './live-join.js';
47
49
  import { normalizeEventTime } from './live-time.js';
48
50
  import { createJobEngine } from './jobs.js';
49
- import { introspectModel } from './introspect.js';
51
+ import { introspectModel, readSchema } from './introspect.js';
50
52
  import { collectEntityRoots, entityRoot } from './plan.js';
51
53
  import {
52
54
  DERIVE_KINDS, PHYSICAL_KINDS, PRECISION_MIN, PRECISION_MAX, DIMS_MIN, DIMS_MAX,
@@ -486,6 +488,7 @@ function immediately(connection, fn, retry = true) {
486
488
  function ensureShape(connection, collections, plans, readOnly) {
487
489
  const dialect = connection.dialect;
488
490
  const names = [...collections.keys()];
491
+ if (names.length === 0) return null;
489
492
  // a read-only store creates nothing, and cannot take a write lock
490
493
  const bracket = readOnly ? (fn) => fn() : (fn) => immediately(connection, fn);
491
494
  return bracket(() => {
@@ -537,6 +540,8 @@ function ensureEntityShape(connection, entityPlans, entities, readOnly) {
537
540
  const name = names[i];
538
541
  const plan = entityPlans.get(name);
539
542
  const docPath = entities.get(name)?.docPath ?? `/entities/${name}`;
543
+ if (plan.physical) return chain(readSchema(connection), (schema) =>
544
+ chain(verifyPhysical(connection, plan.physical, schema), () => step(i + 1)));
540
545
  return chain(connection.prepare(dialect.introspect.tableExists()), (statement) =>
541
546
  chain(statement.get([name]), (row) => {
542
547
  if (row === undefined) {
@@ -824,10 +829,11 @@ function asyncCollection(core, live) {
824
829
  */
825
830
  function writeSchemaOf(entity) {
826
831
  const schema = entity.schema;
827
- const auto = entity.keys.find((key) => entity.properties.get(key).default === 'auto');
828
- if (auto === undefined || !Array.isArray(schema?.required) || !schema.required.includes(auto))
829
- return schema;
830
- const out = { ...schema, required: schema.required.filter((name) => name !== auto) };
832
+ const generated = new Set(entity.keys.filter((key) => entity.properties.get(key).default === 'auto'));
833
+ for (const column of entity.physical?.columns ?? [])
834
+ if (column.databaseDefault || column.generated) generated.add(column.name);
835
+ if (!Array.isArray(schema?.required) || !schema.required.some((name) => generated.has(name))) return schema;
836
+ const out = { ...schema, required: schema.required.filter((name) => !generated.has(name)) };
831
837
  if (out.required.length === 0) delete out.required;
832
838
  return out;
833
839
  }
@@ -971,6 +977,10 @@ export function openStore(model, options) {
971
977
  collections = normalizeModel(model, options.expressions);
972
978
  entities = normalizeEntities(model);
973
979
  mapping = entities.size > 0 ? explainMapping(model) : null;
980
+ if ([...entities.values()].some((e) => e.physical !== null) && (options.capture || options.replication))
981
+ throw new DbCompileError('JD0051', 'column adoption preserves application triggers; complete capture is not qualified');
982
+ if (options.adopt === true && (options.capture || options.replication))
983
+ throw new DbCompileError('JD0005', 'adoption creates no infrastructure; configure it through an explicit migration');
974
984
  if (options.replication !== undefined && [...collections.keys(), ...entities.keys()]
975
985
  .some((name) => name.toLowerCase().startsWith('_jaren_replica')))
976
986
  throw new DbCompileError('JD0060', 'replication reserves table names beginning with _jaren_replica');
@@ -1425,8 +1435,8 @@ export function openStore(model, options) {
1425
1435
  chain(registerExpressionFunctions(connection, expressionNames,
1426
1436
  options.expressions ?? {}), () =>
1427
1437
  chain(needsDeriveFunctions ? registerDeriveFunctions(connection) : null, () =>
1428
- chain(ensureShape(connection, collections, plans, readOnly), () =>
1429
- chain(ensureEntityShape(connection, entityPlans, entities, readOnly), () => {
1438
+ chain(ensureShape(connection, collections, plans, readOnly || options.adopt === true), () =>
1439
+ chain(ensureEntityShape(connection, entityPlans, entities, readOnly || options.adopt === true), () => {
1430
1440
  /** @type {Map<string, any>} */
1431
1441
  const cores = new Map();
1432
1442
  const coreFor = (name) => {
@@ -1598,6 +1608,7 @@ export function openStore(model, options) {
1598
1608
  }
1599
1609
  const jobsEngine = !jobsRequested ? null : createJobEngine({
1600
1610
  connection,
1611
+ adopt: options.adopt === true,
1601
1612
  bracket: firstOpen,
1602
1613
  // the WORKER's control-plane I/O (claims, renewals, its
1603
1614
  // checkpoint stores, its settlements) is root-owned and takes
@@ -1994,6 +2005,7 @@ export function openStore(model, options) {
1994
2005
  return next(0);
1995
2006
  };
1996
2007
  ops = {
2008
+ mutate: (document) => core.mutate(document),
1997
2009
  create: (doc) => {
1998
2010
  const memberships = membershipsOf(doc);
1999
2011
  if (memberships.length === 0)
@@ -2066,6 +2078,7 @@ export function openStore(model, options) {
2066
2078
  create: lift((doc) => ops.create(doc)),
2067
2079
  get: lift((key) => ops.get(key)),
2068
2080
  update: lift((key, changes) => ops.update(key, changes)),
2081
+ mutate: lift((document) => ops.mutate(document)),
2069
2082
  delete: lift((key) => ops.delete(key)),
2070
2083
  load: lift((spec, loadOptions) => ops.load(spec, loadOptions)),
2071
2084
  loadCursor: (spec, cursorOptions) => ops.loadCursor(spec, cursorOptions),
@@ -2252,7 +2265,8 @@ export function openStore(model, options) {
2252
2265
  for (const member of names) {
2253
2266
  if (typeof handle[member] !== 'function') continue;
2254
2267
  out[member] = (/** @type {any[]} */ ...args) =>
2255
- lift(() => gated(() => handle[member](...args)))();
2268
+ lift(() => gated(() => handle[member](...args), undefined,
2269
+ member === 'page' ? args[1]?.signal : undefined))();
2256
2270
  }
2257
2271
  for (const member of valued) {
2258
2272
  if (typeof handle[member] !== 'function') continue;
@@ -2315,7 +2329,7 @@ export function openStore(model, options) {
2315
2329
  // collection's `query` does: admitted one item at a time,
2316
2330
  // never held across the caller's loop
2317
2331
  handle = gatedMembers(inner,
2318
- ['create', 'get', 'update', 'delete', 'load', 'page', 'explain'],
2332
+ ['create', 'get', 'update', 'mutate', 'delete', 'load', 'page', 'explain'],
2319
2333
  ['execute']);
2320
2334
  const untracked = gatedMembers(inner.asNoTracking(), ['get', 'load']);
2321
2335
  handle = Object.freeze({
@@ -2434,6 +2448,7 @@ export function openStore(model, options) {
2434
2448
  get: lift((...args) => gated(() => jobsEngine.get(...args), 'a root job read')),
2435
2449
  counts: lift(() => gated(() => jobsEngine.counts(), 'a root job read')),
2436
2450
  claim: lift((...args) => gated(() => jobsEngine.claim(...args), 'a root job claim')),
2451
+ assertLease: lift((...args) => gated(() => jobsEngine.assertLease(...args), 'a root lease check')),
2437
2452
  renew: lift((...args) => gated(() => jobsEngine.renew(...args), 'a root lease renewal')),
2438
2453
  complete: lift((...args) => gated(() => jobsEngine.complete(...args), 'a root job settlement')),
2439
2454
  fail: lift((...args) => gated(() => jobsEngine.fail(...args), 'a root job settlement')),
@@ -2598,7 +2613,7 @@ export function openStore(model, options) {
2598
2613
  scopedMembers(identity, inner.asNoTracking(), ['get', 'load']));
2599
2614
  return Object.freeze({
2600
2615
  ...scopedMembers(identity, inner,
2601
- ['create', 'get', 'update', 'delete', 'load', 'page', 'explain'],
2616
+ ['create', 'get', 'update', 'mutate', 'delete', 'load', 'page', 'explain'],
2602
2617
  ['execute', 'add', 'put', 'remove', 'discard', 'link', 'unlink']),
2603
2618
  cursor: (/** @type {any} */ document, /** @type {any} */ queryOptions) =>
2604
2619
  scopedCursor(identity, () => inner.cursor(document, queryOptions)),
@@ -2771,7 +2786,21 @@ export function openStore(model, options) {
2771
2786
  });
2772
2787
  };
2773
2788
 
2789
+ const sql = trustedSql({ connection, readOnly, requireScope: () => requireScope(identity),
2790
+ beforeWrite: () => {
2791
+ if ([...entities.values()].some((e) => e.invariants.some((r) => r.enforcement === 'store')))
2792
+ throw new DbRuntimeError('JD2095', 'trusted SQL cannot bypass store-only invariants');
2793
+ if (capture !== null) throw new DbRuntimeError('JD0051', 'trusted SQL writes cannot guarantee complete live/capture/replication coverage');
2794
+ myWork.tracker?.assertSqlWritable();
2795
+ if (rootWork !== myWork) rootWork.tracker?.assertSqlWritable();
2796
+ },
2797
+ afterWrite: () => {
2798
+ myWork.tracker?.invalidate();
2799
+ if (rootWork !== myWork) rootWork.tracker?.invalidate();
2800
+ },
2801
+ });
2774
2802
  const members = {
2803
+ sql: override(sql),
2775
2804
  transaction: override((/** @type {any} */ fn) => lift(() => nested(fn))()),
2776
2805
  collection: override(collectionFor),
2777
2806
  entity: override(entityFor),
@@ -2865,6 +2894,10 @@ export function openStore(model, options) {
2865
2894
  requireScope(identity);
2866
2895
  return jobsEngine.claim(...args);
2867
2896
  }),
2897
+ assertLease: lift((/** @type {any[]} */ ...args) => {
2898
+ requireScope(identity);
2899
+ return jobsEngine.assertLease(...args);
2900
+ }),
2868
2901
  renew: lift((/** @type {any[]} */ ...args) => {
2869
2902
  requireScope(identity);
2870
2903
  return jobsEngine.renew(...args);
@@ -2931,7 +2964,8 @@ export function openStore(model, options) {
2931
2964
  }
2932
2965
  return handle;
2933
2966
  },
2934
- transaction: nested,
2967
+ sql,
2968
+ transaction: (fn) => nested((tx) => synchronousBody(fn, tx)),
2935
2969
  savepoints: Object.freeze({
2936
2970
  create: savepointCreate,
2937
2971
  rollbackTo: savepointRollbackTo,
@@ -3011,7 +3045,7 @@ export function openStore(model, options) {
3011
3045
  }
3012
3046
  return handle;
3013
3047
  },
3014
- transaction: (fn) => {
3048
+ transaction: (fn, transactionOptions) => {
3015
3049
  // the synchronous surface answers values: while a
3016
3050
  // transaction owns the connection it could only QUEUE,
3017
3051
  // which handed a Promise back under a value's type
@@ -3021,7 +3055,9 @@ export function openStore(model, options) {
3021
3055
  + 'settle — nest through the store the callback received, or use the '
3022
3056
  + 'asynchronous store.transaction()');
3023
3057
  }
3024
- return topLevelTransaction(fn);
3058
+ const mode = transactionOptions?.mode;
3059
+ if (mode !== undefined && mode !== 'deferred' && mode !== 'immediate') throw new TypeError('invalid transaction mode');
3060
+ return topLevelTransaction((tx) => synchronousBody(fn, tx), undefined, undefined, mode);
3025
3061
  },
3026
3062
  entity(name) {
3027
3063
  let handle = gatedSyncEntities.get(name);
package/src/tracker.js CHANGED
@@ -31,7 +31,7 @@
31
31
  import { createJSONPatch } from '@jarenjs/json/patch';
32
32
  import { parseJSONPointer } from '@jarenjs/json/pointer';
33
33
 
34
- import { DbCompileError, DbRuntimeError } from './errors.js';
34
+ import { DbCompileError, DbRuntimeError, wrapDriverError } from './errors.js';
35
35
  import { chain, attempt } from './driver.js';
36
36
  import { translatePatch } from './patch-sql.js';
37
37
 
@@ -383,12 +383,15 @@ export function createTracker(context) {
383
383
  fallback = true;
384
384
  continue;
385
385
  }
386
+ if (plan.scalarColumns.some((c) => c.name === first && c.generated))
387
+ throw contractError(entityName, `'${first}' is generated by the database`);
386
388
  const value = op.op === 'remove' ? undefined : op.value;
387
389
  columnSets.set(first, plan.encodeColumn(first, value));
388
390
  // the epoch string ALSO lives in the document
389
391
  if (isEpoch) docOps.push(op);
390
392
  continue;
391
393
  }
394
+ if (plan.document === false) throw contractError(entityName, `'${first}' is not a mapped column`);
392
395
  docOps.push(op);
393
396
  }
394
397
  let docBuild = null;
@@ -505,6 +508,8 @@ export function createTracker(context) {
505
508
  for (const record of records.values()) {
506
509
  const core = coreFor(record.entity);
507
510
  if (record.pendingInsert === true) {
511
+ core.plan.writable?.();
512
+ if (core.plan.document !== false) core.plan.checkMutation?.('insert', null, record.current);
508
513
  let list = inserts.get(record.entity);
509
514
  if (list === undefined) {
510
515
  list = [];
@@ -522,7 +527,9 @@ export function createTracker(context) {
522
527
  // probe before stamping: an update stamp must never turn a
523
528
  // deep-equal replacement into a phantom write
524
529
  if (createJSONPatch(record.snapshot, record.current).length === 0) continue;
530
+ core.plan.writable?.();
525
531
  record.stamped = core.stampUpdated(record.current);
532
+ if (core.plan.document !== false) core.plan.checkMutation?.('update', record.snapshot, record.stamped);
526
533
  const parts = partitionDiff(record.entity, record);
527
534
  for (const member of parts.m2mMembers) {
528
535
  joinOps.push(joinDiff(record.entity, record.snapshot, record.stamped,
@@ -546,6 +553,8 @@ export function createTracker(context) {
546
553
  /** @type {any[]} */
547
554
  const deletes = [];
548
555
  for (const removal of removals.values()) {
556
+ coreFor(removal.entity).plan.writable?.();
557
+ coreFor(removal.entity).plan.checkMutation?.('delete', removal.snapshot, null);
549
558
  deletes.push(removal);
550
559
  if (coreFor(removal.entity).plan.version === null)
551
560
  unversioned.add(removal.entity);
@@ -620,21 +629,21 @@ export function createTracker(context) {
620
629
  }
621
630
  for (const group of shapes.values()) {
622
631
  const names = group[0].split.values.map((value) => value.name);
623
- const paramsPerRow = names.length + 1;
624
- const rowsPerBatch = Math.max(1, Math.min(BATCH_ROW_BOUND,
632
+ const paramsPerRow = names.length + (plan.document === false ? 0 : 1);
633
+ const rowsPerBatch = names.length === 0 ? 1 : Math.max(1, Math.min(BATCH_ROW_BOUND,
625
634
  Math.floor(BATCH_PARAM_BUDGET / paramsPerRow)));
626
635
  for (let at = 0; at < group.length; at += rowsPerBatch) {
627
636
  const batch = group.slice(at, at + rowsPerBatch);
628
637
  const returning = plan.autoKey !== null && !names.includes(plan.autoKey);
629
638
  const rowSql = (base) => `(${[...names.map((_, i) => parameterAt(base + i + 1)),
630
- dialect.jsonEncode(parameterAt(base + names.length + 1))].join(', ')})`;
631
- const sql = `INSERT INTO ${q(plan.table)} `
632
- + `(${[...names.map(q), q('doc')].join(', ')}) VALUES `
633
- + batch.map((_, i) => rowSql(i * paramsPerRow)).join(', ')
634
- + (returning ? ` RETURNING ${q(plan.autoKey)} AS ${q('key')}` : '');
639
+ ...(plan.document === false ? [] : [dialect.jsonEncode(parameterAt(base + names.length + 1))])].join(', ')})`;
640
+ const sql = (plan.document === false && names.length === 0 ? `INSERT INTO ${q(plan.table)} DEFAULT VALUES` : `INSERT INTO ${q(plan.table)} `
641
+ + `(${[...names.map((n) => q(plan.physicalName(n))), ...(plan.document === false ? [] : [q('doc')])].join(', ')}) VALUES `
642
+ + batch.map((_, i) => rowSql(i * paramsPerRow)).join(', '))
643
+ + (returning ? ` RETURNING ${q(plan.physicalName(plan.autoKey))} AS ${q('key')}` : '');
635
644
  const params = batch.flatMap(({ split }) => [
636
645
  ...split.values.map((value) => value.value),
637
- JSON.stringify(split.rest),
646
+ ...(plan.document === false ? [] : [JSON.stringify(split.rest)]),
638
647
  ]);
639
648
  statements.push({
640
649
  kind: 'insert', entity: entityName, sql, params, returning,
@@ -653,15 +662,17 @@ export function createTracker(context) {
653
662
  const split = plan.split(record.stamped);
654
663
  for (const value of split.values) {
655
664
  if (value.name === plan.version) continue;
656
- assignments.push(`${q(value.name)} = ${parameterAt(params.length + 1)}`);
665
+ assignments.push(`${q(plan.physicalName(value.name))} = ${parameterAt(params.length + 1)}`);
657
666
  params.push(value.value);
658
667
  }
659
- assignments.push(`${q('doc')} = ${dialect.jsonEncode(parameterAt(params.length + 1))}`);
660
- params.push(JSON.stringify(split.rest));
668
+ if (plan.document !== false) {
669
+ assignments.push(`${q('doc')} = ${dialect.jsonEncode(parameterAt(params.length + 1))}`);
670
+ params.push(JSON.stringify(split.rest));
671
+ }
661
672
  }
662
673
  else {
663
674
  for (const [name, value] of parts.columnSets) {
664
- assignments.push(`${q(name)} = ${parameterAt(params.length + 1)}`);
675
+ assignments.push(`${q(plan.physicalName(name))} = ${parameterAt(params.length + 1)}`);
665
676
  params.push(value);
666
677
  }
667
678
  if (parts.docBuild !== null) {
@@ -673,16 +684,16 @@ export function createTracker(context) {
673
684
  const snapshotVersion = plan.version === null
674
685
  ? null : Number(record.snapshot[plan.version]) || 0;
675
686
  if (plan.version !== null) {
676
- assignments.push(`${q(plan.version)} = ${parameterAt(params.length + 1)}`);
687
+ assignments.push(`${q(plan.physicalName(plan.version))} = ${parameterAt(params.length + 1)}`);
677
688
  params.push(snapshotVersion + 1);
678
689
  }
679
690
  const wheres = plan.keys.map((key) => {
680
- params.push(record.snapshot[key]);
681
- return `${q(key)} = ${parameterAt(params.length)}`;
691
+ params.push(plan.encodeColumn(key, record.snapshot[key]));
692
+ return `${q(plan.physicalName(key))} = ${parameterAt(params.length)}`;
682
693
  });
683
694
  if (plan.version !== null) {
684
695
  params.push(snapshotVersion);
685
- wheres.push(`${q(plan.version)} = ${parameterAt(params.length)}`);
696
+ wheres.push(`${q(plan.physicalName(plan.version))} = ${parameterAt(params.length)}`);
686
697
  }
687
698
  statements.push({
688
699
  kind: 'update', entity: record.entity, record,
@@ -729,13 +740,13 @@ export function createTracker(context) {
729
740
  const plan = coreFor(entityName).plan;
730
741
  for (const removal of deletes) {
731
742
  if (removal.entity !== entityName) continue;
732
- const params = [...removal.parts];
733
- const wheres = plan.keys.map((key, i) => `${q(key)} = ${parameterAt(i + 1)}`);
743
+ const params = removal.parts.map((v, i) => plan.encodeColumn(plan.keys[i], v));
744
+ const wheres = plan.keys.map((key, i) => `${q(plan.physicalName(key))} = ${parameterAt(i + 1)}`);
734
745
  const snapshotVersion = plan.version !== null && removal.snapshot !== null
735
746
  ? Number(removal.snapshot[plan.version]) || 0 : null;
736
747
  if (snapshotVersion !== null) {
737
748
  params.push(snapshotVersion);
738
- wheres.push(`${q(plan.version)} = ${parameterAt(params.length)}`);
749
+ wheres.push(`${q(plan.physicalName(plan.version))} = ${parameterAt(params.length)}`);
739
750
  }
740
751
  statements.push({
741
752
  kind: 'delete', entity: entityName, removal,
@@ -772,14 +783,9 @@ export function createTracker(context) {
772
783
  };
773
784
 
774
785
  const wrapDb = (error, statement) => {
775
- if (typeof (/** @type {any} */ (error))?.code === 'string'
776
- && String((/** @type {any} */ (error)).code).startsWith('JD')) return error;
777
- return new DbRuntimeError('JD2005',
778
- `the database rejected the operation: ${/** @type {any} */ (error)?.message ?? String(error)}`,
779
- {
786
+ return wrapDriverError(error, {
780
787
  docPath: entities.get(statement.entity)?.docPath,
781
788
  collection: statement.entity,
782
- cause: error,
783
789
  });
784
790
  };
785
791
 
@@ -787,6 +793,24 @@ export function createTracker(context) {
787
793
  const next = (i) => {
788
794
  if (i >= statements.length) return report;
789
795
  const statement = statements[i];
796
+ const advance = () => {
797
+ if (!['insert', 'update'].includes(statement.kind) || coreFor(statement.entity).plan.document !== false) return next(i + 1);
798
+ const core = coreFor(statement.entity);
799
+ const records = statement.kind === 'insert' ? statement.records : [statement.record];
800
+ statement.stored = [];
801
+ const refresh = (at) => {
802
+ if (at >= records.length) return next(i + 1);
803
+ const record = records[at];
804
+ const key = statement.returning ? { ...record.current, [core.plan.autoKey]: statement.generatedKeys[at] } : record.current;
805
+ return chain(core.get(key), (stored) => {
806
+ core.validateOnly(stored);
807
+ core.plan.checkMutation(statement.kind, statement.kind === 'insert' ? null : record.snapshot, stored);
808
+ statement.stored.push(deepFreeze(stored));
809
+ return refresh(at + 1);
810
+ });
811
+ };
812
+ return refresh(0);
813
+ };
790
814
  // a join row whose own key the save allocates: the INSERT that
791
815
  // allocates it has already run (inserts precede join rows), so the
792
816
  // key is on the record by now
@@ -798,13 +822,7 @@ export function createTracker(context) {
798
822
  }
799
823
  return chain(connection.prepare(statement.sql), (prepared) => {
800
824
  if (statement.kind === 'insert' && statement.returning === true) {
801
- let fetched;
802
- try {
803
- fetched = prepared.all(statement.params);
804
- }
805
- catch (error) {
806
- throw wrapDb(error, statement);
807
- }
825
+ const fetched = attempt(() => prepared.all(statement.params), (error) => wrapDb(error, statement));
808
826
  return chain(fetched, (rows) => {
809
827
  // auto keys allocate monotonically in insertion order —
810
828
  // sort ascending to pair rows with records (asserted by
@@ -816,7 +834,7 @@ export function createTracker(context) {
816
834
  statement.records.forEach((record, at) => { record.allocatedKey = keys[at]; });
817
835
  report.inserted += statement.records.length;
818
836
  report.statements.push({ sql: statement.sql, rows: statement.records.length });
819
- return next(i + 1);
837
+ return advance();
820
838
  });
821
839
  }
822
840
  return chain(
@@ -840,7 +858,7 @@ export function createTracker(context) {
840
858
  }
841
859
  else if (statement.kind === 'join-insert') report.joinInserted += changed;
842
860
  else if (statement.kind === 'join-delete') report.joinDeleted += changed;
843
- return next(i + 1);
861
+ return advance();
844
862
  });
845
863
  });
846
864
  });
@@ -949,9 +967,9 @@ export function createTracker(context) {
949
967
  // the row this statement wrote is the one the save PLANNED; an
950
968
  // edit made to the same record afterwards is not in the database
951
969
  const planned = undo.fields.get(record)?.current ?? record.current;
952
- const doc = statement.returning === true
970
+ const doc = statement.stored?.[i] ?? (statement.returning === true
953
971
  ? deepFreeze({ ...planned, [plan.autoKey]: statement.generatedKeys[i] })
954
- : planned;
972
+ : planned);
955
973
  // re-key under the real identity
956
974
  records.delete(record.pendingKey);
957
975
  const key = recordKeyFor(statement.entity, doc);
@@ -968,9 +986,9 @@ export function createTracker(context) {
968
986
  const record = statement.record;
969
987
  const plan = coreFor(statement.entity).plan;
970
988
  const before = record.snapshot;
971
- const saved = statement.newVersion === null
989
+ const saved = statement.stored?.[0] ?? (statement.newVersion === null
972
990
  ? record.stamped
973
- : deepFreeze({ ...record.stamped, [plan.version]: statement.newVersion });
991
+ : deepFreeze({ ...record.stamped, [plan.version]: statement.newVersion }));
974
992
  const pending = untouched(record) ? null : record.current;
975
993
  record.snapshot = deepFreeze(saved);
976
994
  record.current = pending ?? record.snapshot;
@@ -1069,7 +1087,13 @@ export function createTracker(context) {
1069
1087
  }
1070
1088
  };
1071
1089
 
1090
+ const assertSqlWritable = () => {
1091
+ if (removals.size || memberships.size || [...records.values()].some((r) => r.pendingInsert || r.current !== r.snapshot))
1092
+ throw new DbRuntimeError('JD2040', 'trusted SQL requires saving or discarding pending tracked changes first');
1093
+ };
1094
+ const invalidate = () => { assertSqlWritable(); records.clear(); };
1072
1095
  return {
1096
+ assertSqlWritable, invalidate,
1073
1097
  register, registerGraph, add, put, remove, discard, link, unlink, counts, saveChanges,
1074
1098
  };
1075
1099
  }