@jarenjs/linq 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.
- package/ARCHITECTURE.md +8 -10
- package/README.md +15 -1
- package/docs/AI-PEN.md +1 -1
- package/docs/APP-PEN.md +3 -3
- package/docs/CHARTS-PEN.md +1 -1
- package/docs/CONTRACT-PEN.md +4 -4
- package/docs/DB-CLIENT.md +200 -8
- package/docs/FLOW-PEN.md +3 -3
- package/docs/FORMS-PEN.md +2 -2
- package/docs/FORMULA-PEN.md +41 -0
- package/docs/JSLT-PEN.md +4 -4
- package/docs/JTLT-PEN.md +1 -1
- package/docs/LINQ-FORMAT.md +60 -43
- package/docs/MESSAGES-PEN.md +1 -1
- package/docs/MIGRATION-PEN.md +3 -3
- package/docs/MODEL-PEN.md +27 -3
- package/docs/PROJECT-PEN.md +10 -4
- package/docs/QUERY-PEN.md +78 -51
- package/docs/SCHEMA-PEN.md +2 -2
- package/package.json +10 -6
- package/src/db/effects.js +148 -0
- package/src/db/handle.js +2 -0
- package/src/db/index.js +6 -0
- package/src/db/ingest.js +153 -0
- package/src/db/open.js +3 -0
- package/src/db/range.js +261 -0
- package/src/db/receipts.js +151 -0
- package/src/db/records.js +61 -0
- package/src/db/runs.js +164 -0
- package/src/db/search.js +86 -0
- package/src/errors.js +3 -1
- package/src/expression.js +5 -2
- package/src/federate.js +157 -139
- package/src/formula/index.js +15 -0
- package/src/messages/vocabulary.js +2 -1
- package/src/model/define.js +2 -0
- package/src/model/entity.js +15 -0
- package/src/project/index.js +6 -5
- package/types/db.d.ts +101 -0
- package/types/formula.d.ts +9 -0
- package/types/index.d.ts +14 -2
- package/types/message-vocabulary.d.ts +2 -1
- package/types/model.d.ts +18 -0
- package/types/project.d.ts +9 -3
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Business receipts and independent fenced leases over application-owned tables. */
|
|
3
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
4
|
+
import { resolveRuntime } from '@jarenjs/core/runtime';
|
|
5
|
+
import { mappedRecords, recordTransaction, copyRecord, refuseRecord } from './records.js';
|
|
6
|
+
|
|
7
|
+
const fields = ['tenant', 'environment', 'aggregate', 'op', 'key', 'hashVersion', 'hash'];
|
|
8
|
+
/** @param {any} identity */
|
|
9
|
+
function identityOf(identity) {
|
|
10
|
+
if (!identity || fields.some((name) => typeof identity[name] !== 'string' || !identity[name] || identity[name].length > 4096))
|
|
11
|
+
throw new TypeError('command identity needs bounded tenant/environment/aggregate/op/key/hashVersion/hash strings');
|
|
12
|
+
return Object.fromEntries(fields.map((name) => [name, identity[name]]));
|
|
13
|
+
}
|
|
14
|
+
const idOf = (identity) => canonicalizeJson(fields.slice(0, 5).map((name) => identity[name]));
|
|
15
|
+
const matches = (record, identity) => canonicalizeJson(record.identity) === canonicalizeJson(identity);
|
|
16
|
+
const zero = Object.freeze({ changes: 0, writes: 0, revisions: 0 });
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* No TTL applies to business receipts. The optional lease table is recoverable
|
|
20
|
+
* execution authority only. The callback receives the exact transaction client
|
|
21
|
+
* so domain changes and outbox enqueues commit with the validated outcome.
|
|
22
|
+
* @param {any} client
|
|
23
|
+
* @param {{ receipts: any, leases?: any, runtime?: any }} options
|
|
24
|
+
*/
|
|
25
|
+
export function createDbReceipts(client, options) {
|
|
26
|
+
const receipts = mappedRecords(client, options?.receipts);
|
|
27
|
+
const leases = options.leases === undefined ? null : mappedRecords(client, options.leases);
|
|
28
|
+
if (leases?.name === receipts.name) throw new TypeError('receipts and leases must use distinct collections');
|
|
29
|
+
const runtime = resolveRuntime(options.runtime);
|
|
30
|
+
const inside = (fn) => recordTransaction(client, fn);
|
|
31
|
+
const replay = (record, identity) => {
|
|
32
|
+
if (!matches(record, identity)) return { state: 'refused', reason: 'identity-mismatch', ...zero };
|
|
33
|
+
return { state: 'replay', historic: true, receipt: copyRecord(record), ...zero };
|
|
34
|
+
};
|
|
35
|
+
const checkLease = async (tx, id, lease) => {
|
|
36
|
+
const current = leases && await leases.get(tx, id);
|
|
37
|
+
if (!current || current.token !== lease?.token || current.expiresAt <= runtime.now()) throw refuseRecord('stale command lease');
|
|
38
|
+
return current;
|
|
39
|
+
};
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
/** Authorize before calling: this low-level repository is a trusted host capability.
|
|
42
|
+
* @param {any} identity */
|
|
43
|
+
lookup(identity) {
|
|
44
|
+
const normalized = identityOf(identity);
|
|
45
|
+
return inside(async (tx) => {
|
|
46
|
+
const record = await receipts.get(tx, idOf(normalized));
|
|
47
|
+
return record ? replay(record, normalized) : { state: 'absent', ...zero };
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
/** @param {any} identity @param {(tx: any) => Promise<{ outcome: any, references?: any[] }>} work
|
|
51
|
+
* @param {{ lease?: any }} [context] */
|
|
52
|
+
execute(identity, work, context = {}) {
|
|
53
|
+
const normalized = identityOf(identity), id = idOf(normalized);
|
|
54
|
+
return inside(async (tx) => {
|
|
55
|
+
const prior = await receipts.get(tx, id);
|
|
56
|
+
if (prior) return replay(prior, normalized);
|
|
57
|
+
if (context.lease !== undefined) {
|
|
58
|
+
const claimed = await checkLease(tx, id, context.lease);
|
|
59
|
+
if (!matches(claimed, normalized)) throw refuseRecord('command lease identity mismatch');
|
|
60
|
+
}
|
|
61
|
+
else if (leases && await leases.get(tx, id)) return { state: 'refused', reason: 'lease-required', ...zero };
|
|
62
|
+
const result = copyRecord(await work(tx));
|
|
63
|
+
const record = { id, identity: normalized, outcome: result.outcome, references: result.references ?? [],
|
|
64
|
+
createdAt: runtime.now(), revision: 1, compacted: false };
|
|
65
|
+
await receipts.put(tx, record, true);
|
|
66
|
+
if (context.lease !== undefined) {
|
|
67
|
+
await checkLease(tx, id, context.lease);
|
|
68
|
+
await leases.delete(tx, id);
|
|
69
|
+
}
|
|
70
|
+
return { state: 'committed', historic: false, receipt: record, changes: 1, writes: 1, revisions: 1 };
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
/** Claim/reclaim only absent receipts; lease expiry never overrides history.
|
|
74
|
+
* @param {any} identity @param {{ leaseMs: number }} options */
|
|
75
|
+
claim(identity, { leaseMs }) {
|
|
76
|
+
if (!leases || !Number.isSafeInteger(leaseMs) || leaseMs < 1) throw new TypeError('claim needs a lease collection and positive leaseMs');
|
|
77
|
+
const normalized = identityOf(identity), id = idOf(normalized);
|
|
78
|
+
return inside(async (tx) => {
|
|
79
|
+
const receipt = await receipts.get(tx, id);
|
|
80
|
+
if (receipt) return replay(receipt, normalized);
|
|
81
|
+
const prior = await leases.get(tx, id);
|
|
82
|
+
if (prior && !matches(prior, normalized)) return { state: 'refused', reason: 'identity-mismatch', ...zero };
|
|
83
|
+
if (prior && prior.expiresAt > runtime.now()) return { state: 'in-progress', ...zero };
|
|
84
|
+
const lease = { id, identity: normalized, token: runtime.uuid(), generation: (prior?.generation ?? 0) + 1,
|
|
85
|
+
expiresAt: runtime.now() + leaseMs };
|
|
86
|
+
await leases.put(tx, lease);
|
|
87
|
+
return { state: 'claimed', lease };
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
/** Release one failed attempt; a receipt is never removed.
|
|
91
|
+
* @param {any} identity @param {any} lease */
|
|
92
|
+
release(identity, lease) {
|
|
93
|
+
const id = idOf(identityOf(identity));
|
|
94
|
+
return inside(async (tx) => { await checkLease(tx, id, lease); await leases.delete(tx, id); return { changes: 1, writes: 1, revisions: 0 }; });
|
|
95
|
+
},
|
|
96
|
+
/** Sweep a bounded, caller-selected batch of lease identities, never receipts.
|
|
97
|
+
* @param {any[]} identities */
|
|
98
|
+
sweep(identities) {
|
|
99
|
+
if (!leases || !Array.isArray(identities) || identities.length > 1000) throw new TypeError('sweep needs leases and at most 1000 identities');
|
|
100
|
+
const ids = [...new Set(identities.map((identity) => idOf(identityOf(identity))))];
|
|
101
|
+
return inside(async (tx) => {
|
|
102
|
+
let changes = 0;
|
|
103
|
+
for (const id of ids) {
|
|
104
|
+
const lease = await leases.get(tx, id);
|
|
105
|
+
if (lease && lease.expiresAt <= runtime.now()) { await leases.delete(tx, id); changes++; }
|
|
106
|
+
}
|
|
107
|
+
return { changes, writes: changes, revisions: 0 };
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
/** Import explicit immutable outcomes; a preexisting receipt always wins.
|
|
111
|
+
* No expiring claim is interpreted as a business outcome.
|
|
112
|
+
* @param {any[]} records */
|
|
113
|
+
migrate(records) {
|
|
114
|
+
if (!Array.isArray(records) || records.length > 1000) throw new TypeError('migration is bounded to 1000 explicit receipts');
|
|
115
|
+
const batch = records.map((record) => {
|
|
116
|
+
const identity = identityOf(record.identity);
|
|
117
|
+
if (!Object.hasOwn(record, 'outcome') || !Array.isArray(record.references)) throw refuseRecord('migration needs an outcome and stable references');
|
|
118
|
+
return copyRecord({ ...record, id: idOf(identity), identity, revision: 1, compacted: false });
|
|
119
|
+
});
|
|
120
|
+
return inside(async (tx) => {
|
|
121
|
+
let changes = 0;
|
|
122
|
+
for (const record of batch) {
|
|
123
|
+
const prior = await receipts.get(tx, record.id);
|
|
124
|
+
if (prior) {
|
|
125
|
+
if (!matches(prior, record.identity) || canonicalizeJson(prior.outcome) !== canonicalizeJson(record.outcome)
|
|
126
|
+
|| canonicalizeJson(prior.references) !== canonicalizeJson(record.references)) throw refuseRecord('receipt migration collision');
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
await receipts.put(tx, record, true); changes++;
|
|
130
|
+
}
|
|
131
|
+
return { changes, writes: changes, revisions: changes };
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
/** Compaction may remove only auxiliary data; identity/outcome/references stay.
|
|
135
|
+
* @param {any} identity @param {{ retainReplay: true, retainReferences: true, actor: string, reason: string }} policy */
|
|
136
|
+
compact(identity, policy) {
|
|
137
|
+
if (policy?.retainReplay !== true || policy?.retainReferences !== true || !policy.actor || !policy.reason)
|
|
138
|
+
throw refuseRecord('erasure refused: explicit retention and actor/reason are required');
|
|
139
|
+
const normalized = identityOf(identity), id = idOf(normalized);
|
|
140
|
+
return inside(async (tx) => {
|
|
141
|
+
const record = await receipts.get(tx, id);
|
|
142
|
+
if (!record || !matches(record, normalized)) throw refuseRecord('receipt compaction identity mismatch');
|
|
143
|
+
if (record.compacted) return zero;
|
|
144
|
+
const { auxiliary: _discarded, ...retained } = record;
|
|
145
|
+
await receipts.put(tx, { ...retained, compacted: true, revision: record.revision + 1,
|
|
146
|
+
retention: { actor: policy.actor, reason: policy.reason } });
|
|
147
|
+
return { changes: 1, writes: 1, revisions: 1 };
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Structural record mapping shared by durable adapters; the store owns transactions. */
|
|
3
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
4
|
+
import { isThenable } from '@jarenjs/core/function';
|
|
5
|
+
import { LinqRuntimeError } from '../errors.js';
|
|
6
|
+
|
|
7
|
+
/** @param {any} value */
|
|
8
|
+
export const copyRecord = (value) => JSON.parse(canonicalizeJson(value));
|
|
9
|
+
/** @param {string} reason */
|
|
10
|
+
export const refuseRecord = (reason) => new LinqRuntimeError('JL2009', reason);
|
|
11
|
+
/** @param {any} client @param {Function} fn */
|
|
12
|
+
export const recordTransaction = (client, fn) => typeof client.close === 'function'
|
|
13
|
+
? client.transaction(fn, { mode: 'immediate' }) : client.transaction(fn);
|
|
14
|
+
|
|
15
|
+
/** Map canonical records to declared application fields without inferring a schema.
|
|
16
|
+
* @param {any} client @param {string | { collection?: string, entity?: string, key?: Function, read: Function, write: Function }} mapping */
|
|
17
|
+
export function mappedRecords(client, mapping) {
|
|
18
|
+
const spec = typeof mapping === 'string' ? { collection: mapping, read: copyRecord, write: copyRecord } : mapping;
|
|
19
|
+
const entity = spec?.entity !== undefined;
|
|
20
|
+
const owner = entity ? 'entities' : 'collections';
|
|
21
|
+
const name = entity ? spec.entity : spec?.collection;
|
|
22
|
+
if (!client || typeof client.transaction !== 'function' || !spec || !Object.hasOwn(client[owner] ?? {}, name)
|
|
23
|
+
|| (entity && spec.collection !== undefined) || (spec.key !== undefined && typeof spec.key !== 'function')
|
|
24
|
+
|| typeof spec.read !== 'function' || typeof spec.write !== 'function')
|
|
25
|
+
throw new TypeError('record mapping needs a declared collection or entity and synchronous read/write functions');
|
|
26
|
+
const convert = (fn, value) => {
|
|
27
|
+
const result = fn(copyRecord(value));
|
|
28
|
+
if (isThenable(result)) throw new TypeError('record mappings must be synchronous');
|
|
29
|
+
return copyRecord(result);
|
|
30
|
+
};
|
|
31
|
+
const keyOf = (id) => spec.key === undefined ? id : convert(spec.key, id);
|
|
32
|
+
return Object.freeze({
|
|
33
|
+
name: `${owner}/${name}`,
|
|
34
|
+
async get(tx, id) {
|
|
35
|
+
const value = await tx[owner][name].get(keyOf(id));
|
|
36
|
+
if (value === undefined) return undefined;
|
|
37
|
+
const record = convert(spec.read, value);
|
|
38
|
+
if (record.id !== id) throw refuseRecord('record mapping changed identity');
|
|
39
|
+
return record;
|
|
40
|
+
},
|
|
41
|
+
async put(tx, record, insert = false) {
|
|
42
|
+
const stored = convert(spec.write, record);
|
|
43
|
+
if (canonicalizeJson(convert(spec.read, stored)) !== canonicalizeJson(record))
|
|
44
|
+
throw refuseRecord('record mapping is not lossless');
|
|
45
|
+
const target = tx[owner][name], key = keyOf(record.id);
|
|
46
|
+
if (entity) {
|
|
47
|
+
if (insert || await target.get(key) === undefined) await target.create(stored);
|
|
48
|
+
else await target.update(key, stored);
|
|
49
|
+
const persisted = await target.get(key);
|
|
50
|
+
if (persisted === undefined || canonicalizeJson(convert(spec.read, persisted)) !== canonicalizeJson(record))
|
|
51
|
+
throw refuseRecord('record mapping changed the physical key or stored value');
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const written = await (insert ? target.insert(stored) : target.put(stored, key));
|
|
55
|
+
if (canonicalizeJson(written) !== canonicalizeJson(key)) throw refuseRecord('record mapping changed the physical key');
|
|
56
|
+
}
|
|
57
|
+
return key;
|
|
58
|
+
},
|
|
59
|
+
delete: (tx, id) => tx[owner][name].delete(keyOf(id)),
|
|
60
|
+
});
|
|
61
|
+
}
|
package/src/db/runs.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Existing domain run identities and bounded event pages over mapped application tables. */
|
|
3
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
4
|
+
import { mappedRecords, recordTransaction, copyRecord, refuseRecord } from './records.js';
|
|
5
|
+
|
|
6
|
+
const zero = Object.freeze({ changes: 0, writes: 0, revisions: 0 });
|
|
7
|
+
/**
|
|
8
|
+
* Checkpoints remain the existing workflow's format. Status mapping and public
|
|
9
|
+
* summaries are application policy; events never contain the checkpoint input,
|
|
10
|
+
* provider credentials, live resources or raw exceptions.
|
|
11
|
+
* @param {any} client
|
|
12
|
+
* @param {{ runs: any, events: any, statuses?: Record<string, string>,
|
|
13
|
+
* summary?: (snapshot: any) => any, canReset?: (tx: any, record: any) => any,
|
|
14
|
+
* maxPage?: number, maxBytes?: number }} options
|
|
15
|
+
*/
|
|
16
|
+
export function createDbRunStore(client, options) {
|
|
17
|
+
const runs = mappedRecords(client, options?.runs), events = mappedRecords(client, options?.events);
|
|
18
|
+
if (runs.name === events.name) throw new TypeError('runs and events must be distinct collections');
|
|
19
|
+
const { maxPage = 128, maxBytes = 262144 } = options;
|
|
20
|
+
for (const limit of [maxPage, maxBytes]) if (!Number.isSafeInteger(limit) || limit < 1) throw new TypeError('run observation limits must be positive finite integers');
|
|
21
|
+
const statuses = { running: 'running', waiting: 'waiting', done: 'done', failed: 'failed', cancelled: 'cancelled', ...options.statuses };
|
|
22
|
+
if (Object.values(statuses).some((value) => typeof value !== 'string' || !value)) throw new TypeError('status mapping values must be nonempty strings');
|
|
23
|
+
const summary = options.summary ?? ((snapshot) => ({ status: snapshot.status, generation: snapshot.generation }));
|
|
24
|
+
const inside = (fn) => recordTransaction(client, fn);
|
|
25
|
+
const bytes = (value) => new TextEncoder().encode(canonicalizeJson(value)).byteLength;
|
|
26
|
+
const bounded = (page) => {
|
|
27
|
+
if (bytes(page) > maxBytes) throw refuseRecord('run page exceeds byte budget');
|
|
28
|
+
return page;
|
|
29
|
+
};
|
|
30
|
+
const eventId = (id, revision) => canonicalizeJson([id, revision]);
|
|
31
|
+
const requireRun = async (tx, id) => {
|
|
32
|
+
const record = await runs.get(tx, id);
|
|
33
|
+
if (!record || !Number.isSafeInteger(record.revision) || record.revision < 0) throw refuseRecord('unknown or malformed domain run');
|
|
34
|
+
return record;
|
|
35
|
+
};
|
|
36
|
+
const guard = async (tx, record, lease) => {
|
|
37
|
+
if (record.jobId !== lease?.jobId) throw refuseRecord('foreign run job');
|
|
38
|
+
await tx.jobs.assertLease(lease);
|
|
39
|
+
};
|
|
40
|
+
const same = (record, identity) => record.workflow === identity.workflow && record.schemaVersion === identity.schemaVersion && record.jobId === identity.jobId;
|
|
41
|
+
const write = async (tx, record) => {
|
|
42
|
+
if (!Number.isSafeInteger(record.revision + 1)) throw refuseRecord('run revision exhausted');
|
|
43
|
+
record.revision++;
|
|
44
|
+
const event = { id: eventId(record.id, record.revision), runId: record.id, revision: record.revision,
|
|
45
|
+
status: record.status, summary: record.summary, cancelRequested: record.cancelRequested };
|
|
46
|
+
if (bytes({ state: 'page', events: [event], cursor: record.revision, revision: record.revision, more: false, status: record.status, summary: record.summary }) > maxBytes) throw refuseRecord('public run summary exceeds byte budget');
|
|
47
|
+
await runs.put(tx, record);
|
|
48
|
+
await events.put(tx, event, true);
|
|
49
|
+
return { record, changes: 1, writes: 2, revisions: 1 };
|
|
50
|
+
};
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
/** Attach without renaming or re-enqueueing an existing domain run.
|
|
53
|
+
* @param {{ id: string, jobId: string, workflow: string, schemaVersion: string }} identity @param {any} lease */
|
|
54
|
+
attach(identity, lease) {
|
|
55
|
+
const frozen = copyRecord(identity);
|
|
56
|
+
if (['id', 'jobId', 'workflow', 'schemaVersion'].some((name) => typeof frozen[name] !== 'string' || !frozen[name])) throw new TypeError('run identity needs id/jobId/workflow/schemaVersion');
|
|
57
|
+
return inside(async (tx) => {
|
|
58
|
+
if (frozen.jobId !== lease?.jobId) throw refuseRecord('foreign run job');
|
|
59
|
+
await tx.jobs.assertLease(lease);
|
|
60
|
+
const prior = await runs.get(tx, frozen.id);
|
|
61
|
+
if (prior) {
|
|
62
|
+
if (!same(prior, frozen)) throw refuseRecord('incompatible run workflow or schema identity');
|
|
63
|
+
return { record: prior, ...zero };
|
|
64
|
+
}
|
|
65
|
+
return write(tx, { ...frozen, revision: 0, status: statuses.running, summary: null, checkpoint: null, cancelRequested: false });
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
/** Verify provenance before exposing any old checkpoint to the engine.
|
|
69
|
+
* @param {string} id @param {any} identity @param {any} lease */
|
|
70
|
+
load(id, identity, lease) {
|
|
71
|
+
return inside(async (tx) => {
|
|
72
|
+
const record = await requireRun(tx, id);
|
|
73
|
+
await guard(tx, record, lease);
|
|
74
|
+
if (!same(record, identity)) throw refuseRecord('incompatible run workflow or schema identity');
|
|
75
|
+
if (record.cancelRequested) throw refuseRecord('run cancellation stops admission');
|
|
76
|
+
return record.checkpoint;
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
/** Existing workflow CAS, mapped status/event and checkpoint co-commit.
|
|
80
|
+
* @param {string} id @param {any} snapshot @param {number} expectedGeneration @param {any} lease */
|
|
81
|
+
save(id, snapshot, expectedGeneration, lease) {
|
|
82
|
+
const checkpoint = copyRecord(snapshot);
|
|
83
|
+
return inside(async (tx) => {
|
|
84
|
+
const record = await requireRun(tx, id);
|
|
85
|
+
await guard(tx, record, lease);
|
|
86
|
+
if (record.cancelRequested) throw refuseRecord('run cancellation stops admission');
|
|
87
|
+
if ((record.checkpoint?.generation ?? 0) !== expectedGeneration) return false;
|
|
88
|
+
if (checkpoint.runId !== id || checkpoint.generation !== expectedGeneration + 1 || !Object.hasOwn(statuses, checkpoint.status))
|
|
89
|
+
throw refuseRecord('checkpoint run, generation or status mismatch');
|
|
90
|
+
record.checkpoint = checkpoint;
|
|
91
|
+
record.status = statuses[checkpoint.status];
|
|
92
|
+
record.summary = copyRecord(summary(checkpoint));
|
|
93
|
+
await write(tx, record);
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
/** Trusted read: public authorization belongs to the contract handler.
|
|
98
|
+
* @param {string} id */
|
|
99
|
+
get: (id) => inside((tx) => requireRun(tx, id)),
|
|
100
|
+
/** Bounded revision cursor. Missing history explicitly requires a fresh summary.
|
|
101
|
+
* @param {string} id @param {{ after?: number, limit?: number }} [options] */
|
|
102
|
+
page(id, { after = 0, limit = maxPage } = {}) {
|
|
103
|
+
if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > maxPage) throw new TypeError('run page needs a revision cursor and bounded limit');
|
|
104
|
+
return inside(async (tx) => {
|
|
105
|
+
const record = await requireRun(tx, id);
|
|
106
|
+
if (after > record.revision) throw refuseRecord('future run cursor');
|
|
107
|
+
const page = [], end = Math.min(record.revision, after + limit);
|
|
108
|
+
const envelope = { state: 'page', events: page, cursor: after, revision: record.revision, more: true, status: record.status, summary: record.summary };
|
|
109
|
+
for (let revision = after + 1; revision <= end; revision++) {
|
|
110
|
+
const event = await events.get(tx, eventId(id, revision));
|
|
111
|
+
if (!event) return bounded({ state: 'reset-required', cursor: record.revision, revision: record.revision, events: [], summary: record.summary, status: record.status });
|
|
112
|
+
if (event.runId !== id || event.revision !== revision) throw refuseRecord('run event identity mismatch');
|
|
113
|
+
if (bytes({ ...envelope, cursor: revision, events: [...page, event] }) > maxBytes) break;
|
|
114
|
+
page.push(event);
|
|
115
|
+
}
|
|
116
|
+
const cursor = page.at(-1)?.revision ?? after;
|
|
117
|
+
if (cursor === after && cursor < record.revision) throw refuseRecord('run page exceeds byte budget');
|
|
118
|
+
return bounded({ state: 'page', events: page, cursor, revision: record.revision, more: cursor < record.revision,
|
|
119
|
+
status: record.status, summary: record.summary });
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
/** Current authority is required in the caller's command; revision rejects stale intent.
|
|
123
|
+
* @param {string} id @param {number} expectedRevision @param {{ actor: string, reason: string }} evidence */
|
|
124
|
+
requestCancel(id, expectedRevision, evidence) {
|
|
125
|
+
if (!evidence?.actor || !evidence.reason) throw refuseRecord('cancellation needs actor and reason');
|
|
126
|
+
const proof = copyRecord(evidence);
|
|
127
|
+
return inside(async (tx) => {
|
|
128
|
+
const record = await requireRun(tx, id);
|
|
129
|
+
if (record.cancelRequested) return { record, ...zero };
|
|
130
|
+
if (record.revision !== expectedRevision) throw refuseRecord('stale cancellation revision');
|
|
131
|
+
if (record.status === statuses.done) throw refuseRecord('completed run cannot be cancelled');
|
|
132
|
+
record.cancelRequested = true; record.cancellation = proof;
|
|
133
|
+
return write(tx, record);
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
/** Called after workers drain and before resources close.
|
|
137
|
+
* @param {string} id @param {'cancelled' | 'failed'} state @param {any} lease */
|
|
138
|
+
finish(id, state, lease) {
|
|
139
|
+
if (!['cancelled', 'failed'].includes(state)) throw new TypeError('finish state must be cancelled or failed');
|
|
140
|
+
return inside(async (tx) => {
|
|
141
|
+
const record = await requireRun(tx, id);
|
|
142
|
+
await guard(tx, record, lease);
|
|
143
|
+
if (record.status === statuses[state]) return { record, ...zero };
|
|
144
|
+
record.status = statuses[state]; record.summary = { status: state };
|
|
145
|
+
return write(tx, record);
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
/** Reset touches only this run checkpoint, never receipts/effects. Host safety
|
|
149
|
+
* must consult authoritative history in this transaction; default is refusal.
|
|
150
|
+
* @param {string} id @param {number} expectedRevision @param {any} evidence @param {any} lease */
|
|
151
|
+
reset(id, expectedRevision, evidence, lease) {
|
|
152
|
+
if (!evidence?.actor || !evidence.reason || typeof options.canReset !== 'function') throw refuseRecord('reset needs explicit actor/reason and a transactional safety policy');
|
|
153
|
+
return inside(async (tx) => {
|
|
154
|
+
const record = await requireRun(tx, id);
|
|
155
|
+
await guard(tx, record, lease);
|
|
156
|
+
if (record.revision !== expectedRevision || ![statuses.cancelled, statuses.failed, statuses.waiting].includes(record.status)) throw refuseRecord('reset needs an inactive current revision');
|
|
157
|
+
if (await options.canReset(tx, copyRecord(record)) !== true) throw refuseRecord('receipt or unresolved effect prevents reset');
|
|
158
|
+
record.checkpoint = null; record.cancelRequested = false; record.status = statuses.running;
|
|
159
|
+
record.summary = { status: 'reset' }; record.reset = copyRecord(evidence);
|
|
160
|
+
return write(tx, record);
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
package/src/db/search.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Structural lexical ranges reuse the shared resident range implementation. */
|
|
3
|
+
import { createArrayRangeProvider, rangeBytes, rangeIdentity } from '@jarenjs/core/range';
|
|
4
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Materialize only a credited, complete lexical membership. The source retains
|
|
8
|
+
* authoritative rows; this adapter owns one bounded range copy and its cursors.
|
|
9
|
+
* @param {any} source @param {string} text @param {any} [spec] @param {any} [options]
|
|
10
|
+
* @returns {Promise<any>}
|
|
11
|
+
*/
|
|
12
|
+
export async function createLexicalRangeProvider(source, text, spec = {}, options = {}) {
|
|
13
|
+
const maxMatches = options.maxMatches ?? 1000, maxSourceBytes = options.maxSourceBytes ?? 1024 * 1024;
|
|
14
|
+
if (!Number.isSafeInteger(maxMatches) || maxMatches < 1 || !Number.isSafeInteger(maxSourceBytes) || maxSourceBytes < 2)
|
|
15
|
+
throw new TypeError('Invalid lexical range credits');
|
|
16
|
+
if (spec.after !== undefined || spec.limit !== undefined) throw new TypeError('Lexical ranges own continuation and result limits');
|
|
17
|
+
spec = structuredClone(spec);
|
|
18
|
+
const query = options.query ?? canonicalizeJson({ text, spec, source: options.source ?? null });
|
|
19
|
+
let disposed = false, dirty = false, epoch = 0, provider = null, refreshing = null;
|
|
20
|
+
const observers = new Set();
|
|
21
|
+
const unsubscribe = source.subscribe((event) => {
|
|
22
|
+
dirty = true; epoch++;
|
|
23
|
+
for (const observer of observers) { try { observer({ ...event, query, type: 'reset', revision: epoch, snapshot: source.sourceRevision }); }
|
|
24
|
+
catch { /* Subscribers cannot suppress a sibling's reset. */ } }
|
|
25
|
+
});
|
|
26
|
+
const refresh = async () => {
|
|
27
|
+
if (disposed) throw new Error('Lexical range disposed');
|
|
28
|
+
if (refreshing) return refreshing;
|
|
29
|
+
refreshing = (async () => {
|
|
30
|
+
const result = await source.search(text, { ...spec, limit: maxMatches });
|
|
31
|
+
if (disposed) throw new Error('Lexical range disposed');
|
|
32
|
+
if (result.state !== 'complete' || result.hasMore || result.hits.length !== result.total)
|
|
33
|
+
throw new RangeError(`Lexical range requires complete membership: ${result.reason ?? 'match-credits'}`);
|
|
34
|
+
const rows = [], snapshot = result.sourceRevision;
|
|
35
|
+
let bytes = 2;
|
|
36
|
+
for (const hit of result.hits) {
|
|
37
|
+
const row = source.row(hit.id, snapshot); bytes += rangeBytes(row) + (rows.length ? 1 : 0);
|
|
38
|
+
if (bytes > maxSourceBytes) throw new RangeError('Lexical range source byte credits');
|
|
39
|
+
rows.push(row);
|
|
40
|
+
}
|
|
41
|
+
if (provider === null) provider = createArrayRangeProvider(rows, { ...options, query, snapshot });
|
|
42
|
+
else if (provider.snapshot !== snapshot) provider.replace(rows, snapshot);
|
|
43
|
+
dirty = false; return { state: 'complete', sourceRevision: snapshot, rows: rows.length, bytes };
|
|
44
|
+
})().finally(() => { refreshing = null; });
|
|
45
|
+
return refreshing;
|
|
46
|
+
};
|
|
47
|
+
try { await refresh(); }
|
|
48
|
+
catch (error) { unsubscribe(); throw error; }
|
|
49
|
+
return {
|
|
50
|
+
query, get snapshot() { return provider.snapshot; }, capabilities: provider.capabilities, refresh,
|
|
51
|
+
indexOf: (key) => provider.indexOf(key),
|
|
52
|
+
subscribe(fn) {
|
|
53
|
+
if (disposed || typeof fn !== 'function') throw new TypeError('Invalid lexical range subscriber');
|
|
54
|
+
if (observers.size >= 8) throw new RangeError('Lexical range subscription credits');
|
|
55
|
+
observers.add(fn); return () => observers.delete(fn);
|
|
56
|
+
},
|
|
57
|
+
async request(request, signal) {
|
|
58
|
+
const refusal = (state, reason) => ({ ...rangeIdentity(request), state, reason, used: { pages: 0, rows: 0, bytes: 0, work: 0 } });
|
|
59
|
+
if (disposed) return refusal('error', 'disposed');
|
|
60
|
+
if (signal?.aborted) return refusal('error', 'cancelled');
|
|
61
|
+
const current = epoch;
|
|
62
|
+
const ready = await source.refresh();
|
|
63
|
+
if (disposed) return refusal('error', 'disposed');
|
|
64
|
+
if (signal?.aborted) return refusal('error', 'cancelled');
|
|
65
|
+
if (ready.state !== 'complete') return refusal(ready.state === 'budget-exhausted' ? ready.state : 'error', ready.reason);
|
|
66
|
+
if (dirty || current !== epoch || source.sourceRevision !== provider.snapshot) return refusal('invalidated', 'source-changed');
|
|
67
|
+
return provider.request(request, signal);
|
|
68
|
+
},
|
|
69
|
+
async *export(request, signal) {
|
|
70
|
+
const ready = await source.refresh();
|
|
71
|
+
if (ready.state !== 'complete' || dirty || disposed || source.sourceRevision !== provider.snapshot) throw new Error('Incomplete lexical snapshot export');
|
|
72
|
+
for await (const page of provider.export(request, signal)) {
|
|
73
|
+
const current = await source.refresh();
|
|
74
|
+
if (current.state !== 'complete' || dirty || disposed || source.sourceRevision !== provider.snapshot)
|
|
75
|
+
throw new Error('Incomplete lexical snapshot export');
|
|
76
|
+
yield page;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
stats: () => ({ ...provider.stats(), refreshing: refreshing ? 1 : 0, subscriptions: observers.size, dirty }),
|
|
80
|
+
async dispose() {
|
|
81
|
+
disposed = true; unsubscribe(); observers.clear();
|
|
82
|
+
await Promise.allSettled([refreshing]); await provider.dispose();
|
|
83
|
+
if (options.disposeSource) await source.dispose();
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
package/src/errors.js
CHANGED
|
@@ -15,7 +15,8 @@ import { CodedError } from '@jarenjs/core/errors';
|
|
|
15
15
|
* this package can raise, proven in sync with QUERY-PEN.md §9's
|
|
16
16
|
* normative table by a test.
|
|
17
17
|
*/
|
|
18
|
-
|
|
18
|
+
// Freezing an unexported literal has no observable effect when the table is unused.
|
|
19
|
+
export const LINQ_CODES = /* @__PURE__ */ Object.freeze({
|
|
19
20
|
JL0001: 'from() received neither an iterable nor a provider',
|
|
20
21
|
JL0002: 'an expression proxy escaped its capture callback',
|
|
21
22
|
JL0003: 'ofType/cast need an injected compileTypeTest',
|
|
@@ -38,6 +39,7 @@ export const LINQ_CODES = Object.freeze({
|
|
|
38
39
|
JL2006: 'a provider answered an element terminal with something other than one array',
|
|
39
40
|
JL2007: 'a ledger settlement named a ref that settles no started record',
|
|
40
41
|
JL2008: 'a federated fetch reached its row or byte budget',
|
|
42
|
+
JL2009: 'a durable mapped record violated identity, retention or fencing',
|
|
41
43
|
});
|
|
42
44
|
|
|
43
45
|
/**
|
package/src/expression.js
CHANGED
|
@@ -307,8 +307,7 @@ function shiftArgs(record, amount, unit) {
|
|
|
307
307
|
function literalSpec(spec, method) {
|
|
308
308
|
if (!isPlainJson(spec) || spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
309
309
|
throw new LinqBuildError('JL0005',
|
|
310
|
-
`${method}() takes a plain literal spec object
|
|
311
|
-
+ ' compiles, so it cannot be an expression or carry a captured value');
|
|
310
|
+
`${method}() takes a plain literal spec object, not an expression or captured value`);
|
|
312
311
|
}
|
|
313
312
|
return spec;
|
|
314
313
|
}
|
|
@@ -336,6 +335,10 @@ const METHODS = {
|
|
|
336
335
|
// §8.7 strings
|
|
337
336
|
startsWith: binary('$starts-with'), endsWith: binary('$ends-with'),
|
|
338
337
|
contains: binary('$contains'), matches: binary('$match'),
|
|
338
|
+
lexical(record, provider, spec = {}) {
|
|
339
|
+
if (typeof provider !== 'string' || !provider) throw new LinqBuildError('JL0005', 'Lexical name');
|
|
340
|
+
return makeExpr({ $lexical: [provider, record.doc, literalSpec(spec, 'lexical')] }, record.epoch, false);
|
|
341
|
+
},
|
|
339
342
|
upper: unary('$upper'), lower: unary('$lower'),
|
|
340
343
|
length: unary('$string-length'),
|
|
341
344
|
concat: binary('$concat'),
|