@jarenjs/db 0.75.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.
- package/ARCHITECTURE.md +20 -0
- package/README.md +25 -0
- package/docs/HOSTS.md +17 -0
- package/docs/JOBS-FORMAT.md +26 -0
- package/docs/LIVE-FORMAT.md +4 -0
- package/docs/MIGRATION-FORMAT.md +34 -0
- package/docs/MODEL-FORMAT.md +132 -1
- package/docs/NATIVE-PLANS.md +111 -0
- package/docs/SEARCH.md +55 -0
- package/package.json +8 -4
- package/schemas/jaren-migration.draft-07.schema.json +54 -5
- package/schemas/jaren-migration.schema.json +49 -0
- package/schemas/jaren-model.authoring.schema.json +360 -0
- package/schemas/jaren-model.draft-07.schema.json +128 -0
- package/schemas/jaren-model.schema.json +128 -0
- package/src/algebra.js +17 -1
- package/src/backup.js +12 -7
- package/src/cursor.js +27 -4
- package/src/dag-job.js +2 -1
- package/src/ddl.js +13 -0
- package/src/dialect.js +10 -0
- package/src/dialects/check-read.js +3 -3
- package/src/dialects/invariant-sql.js +117 -0
- package/src/dialects/postgres.js +14 -2
- package/src/dialects/sqlite.js +18 -2
- package/src/driver.js +1 -0
- package/src/drivers/bun.js +22 -4
- package/src/emit.js +80 -13
- package/src/entity.js +98 -41
- package/src/errors.js +8 -0
- package/src/graph.js +8 -1
- package/src/index.js +3 -0
- package/src/introspect.js +44 -7
- package/src/invariants.js +45 -0
- package/src/jobs.js +39 -6
- package/src/live.js +4 -1
- package/src/migrate.js +136 -22
- package/src/model.js +12 -0
- package/src/mutation.js +165 -0
- package/src/physical.js +147 -0
- package/src/plan.js +114 -20
- package/src/query.js +144 -70
- package/src/search.js +144 -0
- package/src/sql.js +60 -0
- package/src/store.js +49 -13
- package/src/tracker.js +63 -39
- package/src/window.js +1 -0
- package/types/index.d.ts +58 -3
- package/types/search.d.ts +20 -0
- 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
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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'];
|