@jarenjs/db 0.75.0 → 0.83.3

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 (50) hide show
  1. package/ARCHITECTURE.md +20 -0
  2. package/README.md +25 -0
  3. package/docs/HOSTS.md +17 -0
  4. package/docs/JOBS-FORMAT.md +26 -0
  5. package/docs/LIVE-FORMAT.md +4 -0
  6. package/docs/MIGRATION-FORMAT.md +34 -0
  7. package/docs/MODEL-FORMAT.md +132 -1
  8. package/docs/NATIVE-PLANS.md +111 -0
  9. package/docs/SEARCH.md +55 -0
  10. package/package.json +8 -4
  11. package/schemas/jaren-migration.draft-07.schema.json +54 -5
  12. package/schemas/jaren-migration.schema.json +49 -0
  13. package/schemas/jaren-model.authoring.schema.json +360 -0
  14. package/schemas/jaren-model.draft-07.schema.json +128 -0
  15. package/schemas/jaren-model.schema.json +128 -0
  16. package/src/algebra.js +17 -1
  17. package/src/backup.js +12 -7
  18. package/src/cursor.js +27 -4
  19. package/src/dag-job.js +2 -1
  20. package/src/ddl.js +13 -0
  21. package/src/dialect.js +10 -0
  22. package/src/dialects/check-read.js +3 -3
  23. package/src/dialects/invariant-sql.js +117 -0
  24. package/src/dialects/postgres.js +14 -2
  25. package/src/dialects/sqlite.js +18 -2
  26. package/src/driver.js +1 -0
  27. package/src/drivers/bun.js +22 -4
  28. package/src/emit.js +80 -13
  29. package/src/entity.js +98 -41
  30. package/src/errors.js +8 -0
  31. package/src/graph.js +8 -1
  32. package/src/index.js +3 -0
  33. package/src/introspect.js +44 -7
  34. package/src/invariants.js +45 -0
  35. package/src/jobs.js +39 -6
  36. package/src/live.js +4 -1
  37. package/src/migrate.js +136 -22
  38. package/src/model.js +12 -0
  39. package/src/mutation.js +165 -0
  40. package/src/physical.js +147 -0
  41. package/src/plan.js +114 -20
  42. package/src/query.js +144 -70
  43. package/src/search.js +144 -0
  44. package/src/sql.js +60 -0
  45. package/src/store.js +49 -13
  46. package/src/tracker.js +63 -39
  47. package/src/window.js +1 -0
  48. package/types/index.d.ts +58 -3
  49. package/types/search.d.ts +20 -0
  50. package/types/typed.d.ts +1 -0
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
  }
package/src/window.js CHANGED
@@ -100,6 +100,7 @@ export function createSortedWindow(terms, limit) {
100
100
  return {
101
101
  compare,
102
102
  size: () => entries.length,
103
+ clear: () => { entries.length = 0; byToken.clear(); },
103
104
  /** The visible slice: the first `limit` entries (all, unbounded). */
104
105
  visible: () => (limit === null ? [...entries] : entries.slice(0, limit)),
105
106
  /**
package/types/index.d.ts CHANGED
@@ -291,6 +291,8 @@ export interface LoadContinuation {
291
291
  * write may change; `'live'` (the default) reports the truth in
292
292
  * `snapshot`. */
293
293
  export interface PageOptions<C = LoadContinuation> extends EntityCursorOptions {
294
+ /** False admits no lookahead row; a full page reports hasMore: null. */
295
+ lookahead?: boolean;
294
296
  limit?: number;
295
297
  after?: C;
296
298
  maxBytes?: number | null;
@@ -305,8 +307,11 @@ export interface PageOptions<C = LoadContinuation> extends EntityCursorOptions {
305
307
  export interface Page<T, C = LoadContinuation> {
306
308
  readonly items: T[];
307
309
  readonly continuation: C | null;
308
- readonly hasMore: boolean;
310
+ readonly hasMore: boolean | null;
309
311
  readonly snapshot: boolean;
312
+ /** Present with lookahead:false; includes a row consumed at the byte boundary.
313
+ * Bytes count the serialized payloads consumed, excluding array punctuation. */
314
+ readonly work?: { readonly rows: number; readonly bytes: number };
310
315
  }
311
316
 
312
317
  export interface LoadExplanation {
@@ -594,12 +599,26 @@ export interface UntrackedReads<T = unknown> {
594
599
  load(spec?: LoadSpec): Promise<T[]>;
595
600
  }
596
601
 
602
+ /** Closed native mutation forms over declared SQLite column layouts. */
603
+ export type EntityMutation = {
604
+ returning?: readonly string[]; maxRows?: number; maxBytes?: number;
605
+ } & ({ op: 'update'; key: EntityKeyArg; expectedRevision?: number; set: Readonly<Record<string, unknown>> }
606
+ | { op: 'upsert'; values: Readonly<Record<string, unknown>>; conflict: readonly string[]; update: readonly string[] }
607
+ | { op: 'insert-select'; source: string; where?: unknown; select: Readonly<Record<string, string | { $literal: unknown }>>;
608
+ conflict: readonly string[]; onConflict: 'nothing' });
609
+ export interface MutationResult {
610
+ readonly mode: 'native'; readonly affected: number; readonly rows: ReadonlyArray<Readonly<Record<string, unknown>>>;
611
+ readonly admitted: { readonly statements: number; readonly rows: number; readonly bytes: number };
612
+ }
613
+
597
614
  export interface EntitySet<T = unknown, I = unknown> {
598
615
  /** The provider phantom: a chain over this set infers its item type. */
599
616
  readonly __item?: T;
600
617
  create(doc: I): Promise<Readonly<T>>;
601
618
  get(key: EntityKeyArg): Promise<Readonly<T> | undefined>;
602
619
  update(key: EntityKeyArg, changes: Partial<T>): Promise<Readonly<T>>;
620
+ /** One bounded native SQLite column mutation; unsupported shapes refuse JD0038. */
621
+ mutate(document: EntityMutation): Promise<MutationResult>;
603
622
  delete(key: EntityKeyArg): Promise<boolean>;
604
623
  load(spec?: LoadSpec): Promise<ReadonlyArray<Readonly<T>>>;
605
624
  /** The graph cursor: one root graph per pull, its includes attached
@@ -695,7 +714,7 @@ export interface SyncStore {
695
714
  * the consumer's words; the handle's writes take it and reads answer it. */
696
715
  collection<T = unknown>(name: string): SyncCollection<T>;
697
716
  entity(name: string): SyncEntitySet;
698
- transaction<R>(fn: (store: TransactionStore) => R): R;
717
+ transaction<R>(fn: (store: TransactionStore) => R, options?: { mode?: 'deferred' | 'immediate' }): R;
699
718
  execute?<R = unknown>(document: unknown, options?: ExecuteOptions): SequenceResult<R>;
700
719
  explain?(document: unknown, options?: ExecuteOptions): unknown;
701
720
  /** The entity roots this store-level provider serves (present with
@@ -813,7 +832,7 @@ export interface TransactionScopeOptions {
813
832
  * upgrade `SQLITE_BUSY` the busy handler cannot retry — what a claim
814
833
  * needs under concurrent writers; `'deferred'` (the default) is the
815
834
  * savepoint as always. A nested `tx.transaction()` is a savepoint
816
- * whichever mode the root chose; the synchronous twin has no mode. */
835
+ * whichever mode the root chose; the root synchronous twin accepts the same mode. */
817
836
  mode?: 'deferred' | 'immediate';
818
837
  }
819
838
 
@@ -845,6 +864,7 @@ export interface SyncSavepointController {
845
864
  /** The synchronous surface a transaction view carries: the store's,
846
865
  * plus the transaction-only savepoint group. */
847
866
  export interface TransactionSyncStore extends SyncStore {
867
+ readonly sql: TrustedSyncSql;
848
868
  readonly savepoints: SyncSavepointController;
849
869
  }
850
870
 
@@ -871,6 +891,7 @@ export interface TransactionStore extends Omit<Store,
871
891
  | 'jobs' | 'replication'> {
872
892
  /** The transactional outbox (JOBS-FORMAT §3): no administration here —
873
893
  * an admin operation is a root call. */
894
+ readonly sql: TrustedSql;
874
895
  readonly jobs?: JobsApi;
875
896
  transaction<R>(fn: (store: TransactionStore) => R | Promise<R>): Promise<Awaited<R>>;
876
897
  /** Named partial rollback over the transaction's one savepoint stack
@@ -1051,6 +1072,8 @@ export interface LiveBounds {
1051
1072
  }
1052
1073
 
1053
1074
  export interface OpenStoreOptions {
1075
+ /** Verify existing objects and create no schema or infrastructure. */
1076
+ adopt?: boolean;
1054
1077
  replication?: ReplicationOptions;
1055
1078
  driver: Driver;
1056
1079
  path?: string;
@@ -1775,6 +1798,8 @@ export interface JobWorker {
1775
1798
  }
1776
1799
 
1777
1800
  export interface JobWorkerOptions {
1801
+ /** Admit only when current durable effect policy returns true. */
1802
+ effectSafety?: (job: ClaimedJob, context: { lease(): JobLease; signal: AbortSignal }) => boolean | Promise<boolean>;
1778
1803
  /**
1779
1804
  * `checkpoints` is bound to THIS attempt and follows its current
1780
1805
  * lease, so a renewal does not strand it. `signal` aborts for either
@@ -1784,6 +1809,9 @@ export interface JobWorkerOptions {
1784
1809
  */
1785
1810
  handlers: Record<string, (payload: unknown, context: {
1786
1811
  job: ClaimedJob;
1812
+ lease(): JobLease;
1813
+ /** Pause as cancelled, permitting explicit requeue for reconciliation. */
1814
+ pause(): Promise<boolean>;
1787
1815
  checkpoints: { load(runId: string): unknown;
1788
1816
  save(runId: string, nodeId: string, value: unknown): unknown;
1789
1817
  complete(runId: string, result: unknown): unknown };
@@ -1824,6 +1852,8 @@ export interface JobsApi {
1824
1852
  * expired), never a silent `false`.
1825
1853
  */
1826
1854
  renew(lease: JobLease, options?: { leaseMs?: number }): Promise<JobLease>;
1855
+ /** Check the existing token/expiry fence without writing. */
1856
+ assertLease(lease: JobLease): Promise<boolean>;
1827
1857
  /** Settle the attempt this lease holds. `true`, or one of the three
1828
1858
  * coded refusals above — a caller that cannot tell "already done"
1829
1859
  * from "you are stale" guesses, and guesses wrong. */
@@ -1899,6 +1929,7 @@ export interface JobsOptions {
1899
1929
  }
1900
1930
 
1901
1931
  export declare function createDagJobRunner(store: Store, options: {
1932
+ effectSafety?: JobWorkerOptions['effectSafety'];
1902
1933
  compileDag: Function;
1903
1934
  documents: Record<string, unknown>;
1904
1935
  tasks?: Record<string, Function | { run: Function; version?: string; taskVersions?: Record<string, string> }>;
@@ -1982,3 +2013,27 @@ export interface ReplicationSnapshot {
1982
2013
  receipts: ReplicationEnvelope[];
1983
2014
  }
1984
2015
  export declare function normalizeReplicationSnapshot(document: unknown): ReplicationSnapshot;
2016
+
2017
+ /** Trusted prepared SQL; statements belong to one transaction scope. */
2018
+ export interface TrustedSql {
2019
+ prepare(sql: string, options: { access: 'read' | 'write'; affects?: readonly string[] }): {
2020
+ run(params?: readonly unknown[]): unknown;
2021
+ get(params?: readonly unknown[]): Record<string, any> | undefined | Promise<Record<string, any> | undefined>;
2022
+ all(params?: readonly unknown[]): Record<string, any>[] | Promise<Record<string, any>[]>;
2023
+ close(): void;
2024
+ };
2025
+ }
2026
+ export interface TrustedSyncSql {
2027
+ prepare(sql: string, options: { access: 'read' | 'write'; affects?: readonly string[] }): {
2028
+ run(params?: readonly unknown[]): unknown;
2029
+ get(params?: readonly unknown[]): Record<string, any> | undefined;
2030
+ all(params?: readonly unknown[]): Record<string, any>[];
2031
+ close(): void;
2032
+ };
2033
+ }
2034
+ export declare function planInvariants(model: unknown, options: { dialect: Dialect }): {
2035
+ type: 'trigger'; name: string; owner: string; rule: string; sql: string;
2036
+ }[];
2037
+ export declare function planPhysicalMigration(connection: unknown, fromModel: unknown, toModel: unknown,
2038
+ options: { id: string; steps: readonly unknown[]; dispositions: Readonly<Record<string, 'preserve' | 'replace' | 'drop'>>;
2039
+ assertions?: readonly { sql: string; params?: readonly unknown[]; expected: readonly unknown[] }[] }): unknown;
@@ -0,0 +1,20 @@
1
+ import type { Store } from './index.js';
2
+ import type { LexicalDefinition } from '@jarenjs/core/search';
3
+
4
+ export interface SearchStorage {
5
+ load(id: string): Promise<string | null>;
6
+ save(id: string, payload: string): Promise<{ changes: number }>;
7
+ }
8
+ export declare function createDbSearchStorage(store: Store, collection: string, options?: { maxBytes?: number }): SearchStorage;
9
+ export interface DbSearch {
10
+ readonly sourceRevision: string;
11
+ refresh(): Promise<any>;
12
+ search(text: string, request?: Record<string, unknown>): Promise<any>;
13
+ row(id: string, revision: string): any;
14
+ subscribe(observer: (event: any) => void): () => void;
15
+ explain(): { mode: string; nativeFTS: boolean; reason: string; maxRows: number; maxBytes: number; capture: string; externalChanges: string };
16
+ stats(): any;
17
+ dispose(): Promise<void>;
18
+ }
19
+ export declare function createDbSearch(store: Store, entity: string, definition: LexicalDefinition,
20
+ options: { source: string; maxRows?: number; maxBytes?: number; storage?: SearchStorage; snapshotKey?: string }): Promise<DbSearch>;
package/types/typed.d.ts CHANGED
@@ -101,6 +101,7 @@ export interface TypedUntrackedReads<E extends MetaMap<E>, M extends EntityMeta>
101
101
  }
102
102
 
103
103
  export interface TypedEntitySet<E extends MetaMap<E>, M extends EntityMeta> {
104
+ mutate(document: import('./index.js').EntityMutation): Promise<import('./index.js').MutationResult>;
104
105
  /** The provider phantom: `from(typed.entity('User'))` infers `User`
105
106
  * without a cast. */
106
107
  readonly __item?: M['doc'];