@jarenjs/linq 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.
- package/README.md +8 -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 +25 -8
- 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/formula/index.js +15 -0
- 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 +4 -0
- package/types/model.d.ts +18 -0
- package/types/project.d.ts +9 -3
package/src/db/ingest.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Atomic page/checkpoint staging and complete-generation publication. */
|
|
3
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
4
|
+
import { isThenable } from '@jarenjs/core/function';
|
|
5
|
+
|
|
6
|
+
const copy = (value) => JSON.parse(canonicalizeJson(value));
|
|
7
|
+
const key = (...parts) => canonicalizeJson(parts);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Adapt application-declared collections under the client's transaction owner.
|
|
11
|
+
* Reconcile is a synchronous pure policy; network I/O belongs outside this
|
|
12
|
+
* adapter. No table, schema, credentials or provider field policy is inferred.
|
|
13
|
+
* @param {any} client
|
|
14
|
+
* @param {{ staging: string, checkpoints: string, publications: string, facts?: string,
|
|
15
|
+
* reconcile?: (existing: any, incoming: any, evidence: any) => any }} options
|
|
16
|
+
*/
|
|
17
|
+
export function createDbIngestionStore(client, options) {
|
|
18
|
+
if (!client || typeof client.transaction !== 'function' || !client.collections || !options)
|
|
19
|
+
throw new TypeError('ingestion store needs a client and declared collection names');
|
|
20
|
+
const names = ['staging', 'checkpoints', 'publications', ...(options.facts ? ['facts'] : [])];
|
|
21
|
+
if (new Set(names.map((name) => options[name])).size !== names.length
|
|
22
|
+
|| names.some((name) => typeof options[name] !== 'string' || !Object.hasOwn(client.collections, options[name])))
|
|
23
|
+
throw new TypeError('ingestion collections must be distinct declared collections');
|
|
24
|
+
if (options.reconcile !== undefined && typeof options.reconcile !== 'function') throw new TypeError('reconcile must be a pure function');
|
|
25
|
+
const reconcile = options.reconcile ?? ((_existing, incoming) => incoming);
|
|
26
|
+
const inside = (fn) => typeof client.close === 'function' ? client.transaction(fn, { mode: 'immediate' }) : client.transaction(fn);
|
|
27
|
+
const fingerprint = (plan) => key(plan.source, plan.version, plan.partitions, plan.input, plan.policyRevision, plan.consistency);
|
|
28
|
+
const checkpointId = (plan) => key(plan.source, plan.generation);
|
|
29
|
+
const publications = (tx) => tx.collections[options.publications];
|
|
30
|
+
const checkpoints = (tx) => tx.collections[options.checkpoints];
|
|
31
|
+
const zero = { changes: 0, writes: 0, revisions: 0 };
|
|
32
|
+
const matching = async (tx, plan) => {
|
|
33
|
+
const checkpoint = await checkpoints(tx).get(checkpointId(plan));
|
|
34
|
+
if (!checkpoint || checkpoint.fingerprint !== fingerprint(plan)) throw new TypeError('ingestion checkpoint does not match the plan');
|
|
35
|
+
return checkpoint;
|
|
36
|
+
};
|
|
37
|
+
return Object.freeze({
|
|
38
|
+
/** @param {any} plan */
|
|
39
|
+
begin(plan) {
|
|
40
|
+
return inside(async (tx) => {
|
|
41
|
+
const published = await publications(tx).get(plan.source);
|
|
42
|
+
if (published?.fingerprint === fingerprint(plan)) return { state: 'unchanged', ...zero, manifest: published };
|
|
43
|
+
const id = checkpointId(plan);
|
|
44
|
+
const existing = await checkpoints(tx).get(id);
|
|
45
|
+
if (existing) {
|
|
46
|
+
if (existing.fingerprint !== fingerprint(plan)) return { state: 'refused', reason: 'generation-mismatch' };
|
|
47
|
+
if (existing.status !== 'staging') return { state: 'refused', reason: existing.status };
|
|
48
|
+
return { state: 'staging', checkpoint: existing, ...zero };
|
|
49
|
+
}
|
|
50
|
+
const checkpoint = { id, fingerprint: fingerprint(plan), source: plan.source, generation: plan.generation,
|
|
51
|
+
version: plan.version, expectedPublication: published?.generation ?? null, status: 'staging',
|
|
52
|
+
partitions: plan.partitions.map((partition) => ({ id: partition, cursor: null, complete: false, pages: [] })),
|
|
53
|
+
pages: 0, rows: 0, bytes: 0 };
|
|
54
|
+
await checkpoints(tx).insert(checkpoint);
|
|
55
|
+
return { state: 'staging', checkpoint, ...zero };
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
/** Page bytes and its continuation/completion evidence commit together.
|
|
59
|
+
* @param {any} plan @param {string} partition @param {any} page */
|
|
60
|
+
stage(plan, partition, page) {
|
|
61
|
+
return inside(async (tx) => {
|
|
62
|
+
const checkpoint = await matching(tx, plan);
|
|
63
|
+
if (checkpoint.status !== 'staging') return { state: 'refused', reason: checkpoint.status };
|
|
64
|
+
const part = checkpoint.partitions.find((p) => p.id === partition);
|
|
65
|
+
if (!part) return { state: 'refused', reason: 'unknown-partition' };
|
|
66
|
+
const id = key(plan.source, plan.generation, partition, page.cursor);
|
|
67
|
+
const staging = tx.collections[options.staging];
|
|
68
|
+
const existing = await staging.get(id);
|
|
69
|
+
const record = copy({ id, partition, generation: plan.generation, source: plan.source, page });
|
|
70
|
+
if (existing) {
|
|
71
|
+
if (canonicalizeJson(existing) !== canonicalizeJson(record)) return { state: 'refused', reason: 'page-conflict' };
|
|
72
|
+
return { state: 'staged', checkpoint, ...zero };
|
|
73
|
+
}
|
|
74
|
+
if (part.complete || canonicalizeJson(part.cursor) !== canonicalizeJson(page.cursor)) return { state: 'refused', reason: 'stale-cursor' };
|
|
75
|
+
await staging.insert(record);
|
|
76
|
+
part.pages.push(id);
|
|
77
|
+
part.cursor = page.continuation;
|
|
78
|
+
part.complete = page.complete === true && page.reason === null && page.version === plan.version;
|
|
79
|
+
checkpoint.pages++;
|
|
80
|
+
checkpoint.rows += page.rows.length;
|
|
81
|
+
checkpoint.bytes += page.bytes;
|
|
82
|
+
if (page.reason !== null) checkpoint.status = 'incomplete';
|
|
83
|
+
if (page.version !== plan.version) checkpoint.status = 'source-changed';
|
|
84
|
+
await checkpoints(tx).put(checkpoint, checkpoint.id);
|
|
85
|
+
return { state: 'staged', checkpoint, ...zero };
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
/** Preserve partial observations but fence the generation from publication.
|
|
89
|
+
* @param {any} plan @param {string} reason */
|
|
90
|
+
invalidate(plan, reason) {
|
|
91
|
+
return inside(async (tx) => {
|
|
92
|
+
const checkpoint = await matching(tx, plan);
|
|
93
|
+
if (checkpoint.status === 'published' || checkpoint.status === reason) return;
|
|
94
|
+
await checkpoints(tx).put({ ...checkpoint, status: reason }, checkpoint.id);
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
/** Completion evidence, effective facts and the pointer share one commit.
|
|
98
|
+
* @param {any} plan @param {any} evidence @param {{ signal?: AbortSignal }} [context] */
|
|
99
|
+
publish(plan, evidence, context = {}) {
|
|
100
|
+
return inside(async (tx) => {
|
|
101
|
+
if (context.signal?.aborted) return { state: 'refused', reason: 'cancelled' };
|
|
102
|
+
const checkpoint = await matching(tx, plan);
|
|
103
|
+
const published = await publications(tx).get(plan.source);
|
|
104
|
+
if (published?.fingerprint === fingerprint(plan)) return { state: 'unchanged', ...zero, manifest: published };
|
|
105
|
+
if (checkpoint.status !== 'staging' || checkpoint.partitions.some((part) => !part.complete)) return { state: 'refused', reason: 'incomplete' };
|
|
106
|
+
if ((published?.generation ?? null) !== checkpoint.expectedPublication) return { state: 'refused', reason: 'publication-conflict' };
|
|
107
|
+
if (!evidence || evidence.version !== plan.version || evidence.consistency !== plan.consistency)
|
|
108
|
+
return { state: 'refused', reason: 'source-changed' };
|
|
109
|
+
let changes = 0;
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const part of checkpoint.partitions) for (const id of part.pages) {
|
|
112
|
+
const record = await tx.collections[options.staging].get(id);
|
|
113
|
+
if (!record || record.page.version !== plan.version || record.page.reason !== null) throw new TypeError('publication lacks page evidence');
|
|
114
|
+
for (let i = 0; i < record.page.rows.length; i++) {
|
|
115
|
+
const providerId = record.page.ids[i];
|
|
116
|
+
const factId = key(plan.source, part.id, providerId);
|
|
117
|
+
if (seen.has(factId)) throw new TypeError('publication has duplicate source identities');
|
|
118
|
+
seen.add(factId);
|
|
119
|
+
if (!options.facts) continue;
|
|
120
|
+
const facts = tx.collections[options.facts];
|
|
121
|
+
const prior = await facts.get(factId);
|
|
122
|
+
const value = reconcile(prior?.value ?? null, copy(record.page.rows[i]), { source: plan.source, partition: part.id, providerId, version: plan.version });
|
|
123
|
+
if (isThenable(value)) throw new TypeError('reconcile must be synchronous; network I/O cannot hold a page transaction');
|
|
124
|
+
if (context.signal?.aborted) throw new TypeError('publication cancelled');
|
|
125
|
+
if (prior && canonicalizeJson(prior.value) === canonicalizeJson(value)) continue;
|
|
126
|
+
await facts.put({ id: factId, source: plan.source, partition: part.id, providerId,
|
|
127
|
+
value: copy(value), revision: (prior?.revision ?? 0) + 1 }, factId);
|
|
128
|
+
changes++;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const manifest = { id: plan.source, source: plan.source, generation: plan.generation, version: plan.version,
|
|
132
|
+
fingerprint: fingerprint(plan), partitions: copy(checkpoint.partitions), evidence: copy(evidence),
|
|
133
|
+
pages: checkpoint.pages, rows: checkpoint.rows, bytes: checkpoint.bytes };
|
|
134
|
+
if (context.signal?.aborted) throw new TypeError('publication cancelled');
|
|
135
|
+
await publications(tx).put(manifest, plan.source);
|
|
136
|
+
await checkpoints(tx).put({ ...checkpoint, status: 'published' }, checkpoint.id);
|
|
137
|
+
if (context.signal?.aborted) throw new TypeError('publication cancelled');
|
|
138
|
+
return { state: 'published', changes, writes: changes, revisions: changes, manifest };
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
/** @param {string} source */
|
|
142
|
+
current: (source) => client.collections[options.publications].get(source),
|
|
143
|
+
/** @param {any} plan */
|
|
144
|
+
inspect(plan) {
|
|
145
|
+
return inside(async (tx) => {
|
|
146
|
+
const checkpoint = await matching(tx, plan);
|
|
147
|
+
const observations = [];
|
|
148
|
+
for (const part of checkpoint.partitions) for (const id of part.pages) observations.push(await tx.collections[options.staging].get(id));
|
|
149
|
+
return { checkpoint, observations };
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
package/src/db/open.js
CHANGED
|
@@ -91,6 +91,9 @@ export async function open(model, options) {
|
|
|
91
91
|
const inner = {
|
|
92
92
|
store: tx,
|
|
93
93
|
capabilities: tx.capabilities,
|
|
94
|
+
sql: tx.sql,
|
|
95
|
+
jobs: tx.jobs,
|
|
96
|
+
sync: tx.sync,
|
|
94
97
|
...handlesOf(tx),
|
|
95
98
|
transaction: (fn) => tx.transaction((nested) => fn(transactionClient(nested))),
|
|
96
99
|
// the named-savepoint group (MODEL-FORMAT §5.2), forwarded as it
|
package/src/db/range.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Structural ranges over the store's cursors, keyset pages and committed capture. */
|
|
3
|
+
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
4
|
+
import { resolveRuntime } from '@jarenjs/core/runtime';
|
|
5
|
+
import { deepFreeze } from '@jarenjs/core/object';
|
|
6
|
+
|
|
7
|
+
/** Open a bounded source. Resident mode obtains a complete, bounded source
|
|
8
|
+
* snapshot before offering index seeks or an exact total. Sequential mode
|
|
9
|
+
* admits only first pages and continuations through the store's keyset pager.
|
|
10
|
+
* @param {any} store @param {string} entity @param {any} [spec]
|
|
11
|
+
* @param {any} [options] @returns {Promise<any>} */
|
|
12
|
+
export async function createDbRangeProvider(store, entity, spec = {}, options = {}) {
|
|
13
|
+
spec = structuredClone(spec);
|
|
14
|
+
const keys = options.keys?.slice();
|
|
15
|
+
if (!Array.isArray(keys) || !keys.length || keys.some((key) => typeof key !== 'string') || new Set(keys).size !== keys.length)
|
|
16
|
+
throw new TypeError('range provider needs distinct logical key members');
|
|
17
|
+
if (spec.take !== undefined || spec.skip !== undefined || spec.after !== undefined || spec.include !== undefined)
|
|
18
|
+
throw new TypeError('range provider owns the window and serves root rows');
|
|
19
|
+
const bounds = { rows: options.maxRows ?? 256, bytes: options.maxBytes ?? 262144,
|
|
20
|
+
pages: options.maxPages ?? 4, inFlight: options.maxInFlight ?? 2, subscriptions: options.maxSubscriptions ?? 8 };
|
|
21
|
+
if (!Object.values(bounds).every((n) => Number.isSafeInteger(n) && n > 0 && n < Number.MAX_SAFE_INTEGER))
|
|
22
|
+
throw new TypeError('range provider bounds must be finite positive safe integers');
|
|
23
|
+
const resident = options.resident === true;
|
|
24
|
+
const profile = options.profile === undefined ? undefined : structuredClone(options.profile);
|
|
25
|
+
const host = resolveRuntime(options.runtime);
|
|
26
|
+
const source = options.source ?? host.uuid();
|
|
27
|
+
const query = options.query ?? canonicalizeJson({ source, entity, spec, keys,
|
|
28
|
+
schema: options.schemaVersion ?? null, profile: profile ?? null });
|
|
29
|
+
if (typeof source !== 'string' || !source || typeof query !== 'string' || !query)
|
|
30
|
+
throw new TypeError('range source and query identities must be nonempty strings');
|
|
31
|
+
const encoder = new TextEncoder();
|
|
32
|
+
const bytesOf = (value) => encoder.encode(JSON.stringify(value)).byteLength;
|
|
33
|
+
const keyOf = (row) => {
|
|
34
|
+
const values = keys.map((key) => row[key]);
|
|
35
|
+
if (values.some((value) => typeof value !== 'string' && !(typeof value === 'number' && Number.isFinite(value))))
|
|
36
|
+
throw new TypeError('a range row must carry every stable key member');
|
|
37
|
+
return keys.length === 1 ? String(values[0]) : canonicalizeJson(values);
|
|
38
|
+
};
|
|
39
|
+
let revision = 1;
|
|
40
|
+
let dataVersion;
|
|
41
|
+
let disposed = false;
|
|
42
|
+
let generation = -1;
|
|
43
|
+
let ticket = 0;
|
|
44
|
+
let rows = null;
|
|
45
|
+
let residentBytes = 0;
|
|
46
|
+
const continuations = new Map();
|
|
47
|
+
const pending = new Map();
|
|
48
|
+
const exports = new Set();
|
|
49
|
+
const observers = new Set();
|
|
50
|
+
const stats = { sourceReads: 0, sourceRows: 0, sourceBytes: 0 };
|
|
51
|
+
const snapshot = () => `${source}-v${revision}`;
|
|
52
|
+
const invalidate = (reason) => {
|
|
53
|
+
revision++;
|
|
54
|
+
rows = null; residentBytes = 0; continuations.clear();
|
|
55
|
+
const event = Object.freeze({ type: 'reset', reason, query, snapshot: snapshot(), revision,
|
|
56
|
+
capture: 'committed-store-records; external changes detected on request' });
|
|
57
|
+
for (const observer of observers) {
|
|
58
|
+
try { observer(event); }
|
|
59
|
+
catch { /* A subscriber cannot suppress a sibling's source reset. */ }
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const unsubscribe = store.observe((record) => {
|
|
63
|
+
if (!disposed && record.collections.includes(entity)) invalidate('source-changed');
|
|
64
|
+
});
|
|
65
|
+
const checkVersion = async (over) => {
|
|
66
|
+
const current = await over.dataVersion();
|
|
67
|
+
if (dataVersion !== undefined && current !== dataVersion) invalidate('external-source-changed');
|
|
68
|
+
dataVersion = current;
|
|
69
|
+
};
|
|
70
|
+
const readPage = async (over, pageOptions, used) => {
|
|
71
|
+
stats.sourceReads++;
|
|
72
|
+
const record = (work) => {
|
|
73
|
+
if (work === undefined) return;
|
|
74
|
+
stats.sourceRows += work.rows; stats.sourceBytes += work.bytes;
|
|
75
|
+
if (used !== undefined) used.work += work.rows;
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
const page = await over.entity(entity).page(spec, {
|
|
79
|
+
...pageOptions, lookahead: false, profile,
|
|
80
|
+
});
|
|
81
|
+
record(page.work);
|
|
82
|
+
return page;
|
|
83
|
+
}
|
|
84
|
+
catch (error) { record(error?.work); throw error; }
|
|
85
|
+
};
|
|
86
|
+
const loadResident = async (over, signal, used) => {
|
|
87
|
+
const page = await readPage(over, { limit: bounds.rows + 1, maxBytes: bounds.bytes, signal }, used);
|
|
88
|
+
const loaded = page.items;
|
|
89
|
+
const bytes = bytesOf(loaded);
|
|
90
|
+
if (page.hasMore !== false || loaded.length > bounds.rows || bytes > bounds.bytes) {
|
|
91
|
+
throw new RangeError('range source exceeds resident credits');
|
|
92
|
+
}
|
|
93
|
+
const identities = loaded.map(keyOf);
|
|
94
|
+
if (new Set(identities).size !== identities.length) throw new TypeError('range keys must be unique');
|
|
95
|
+
loaded.forEach(deepFreeze);
|
|
96
|
+
rows = loaded; residentBytes = bytes;
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
await store.transaction(async (tx) => {
|
|
100
|
+
await checkVersion(tx);
|
|
101
|
+
if (resident) await loadResident(tx);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (error) { unsubscribe(); throw error; }
|
|
105
|
+
|
|
106
|
+
const capabilities = Object.freeze({ seekIndex: resident && options.seekIndex !== false, seekKey: false,
|
|
107
|
+
continuation: true, live: true, exactTotal: resident && options.exactTotal !== false, completeExport: resident });
|
|
108
|
+
const provider = {
|
|
109
|
+
capabilities,
|
|
110
|
+
get query() { return query; },
|
|
111
|
+
get snapshot() { return snapshot(); },
|
|
112
|
+
stats: () => ({ ...stats, pending: pending.size, pages: continuations.size,
|
|
113
|
+
rows: rows?.length ?? 0, bytes: residentBytes, subscriptions: observers.size, exports: exports.size, disposed }),
|
|
114
|
+
subscribe(observer) {
|
|
115
|
+
if (disposed) throw new Error('range provider is disposed');
|
|
116
|
+
if (typeof observer !== 'function') throw new TypeError('range subscriber must be callable');
|
|
117
|
+
if (observers.size >= bounds.subscriptions) throw new RangeError('range subscription bound reached');
|
|
118
|
+
observers.add(observer);
|
|
119
|
+
return () => observers.delete(observer);
|
|
120
|
+
},
|
|
121
|
+
request(request, signal) {
|
|
122
|
+
request = structuredClone(request);
|
|
123
|
+
const identity = Object.fromEntries(['generation', 'requestId', 'query', 'snapshot'].map((key) => [key, request?.[key]]));
|
|
124
|
+
const used = { pages: 0, rows: 0, bytes: 0, work: 0 };
|
|
125
|
+
const refuse = (state, reason) => ({ ...identity, state, reason, used: { ...used } });
|
|
126
|
+
if (disposed) return Promise.resolve(refuse('error', 'disposed'));
|
|
127
|
+
if (signal?.aborted) return Promise.resolve(refuse('error', 'cancelled'));
|
|
128
|
+
if (!Number.isSafeInteger(request?.generation) || request.generation < 0 || typeof request.requestId !== 'string')
|
|
129
|
+
return Promise.resolve(refuse('error', 'invalid-identity'));
|
|
130
|
+
if (request.query !== query || request.generation < generation) return Promise.resolve(refuse('invalidated', 'query-changed'));
|
|
131
|
+
if (pending.size >= bounds.inFlight) return Promise.resolve(refuse('budget-exhausted', 'in-flight'));
|
|
132
|
+
generation = request.generation;
|
|
133
|
+
const currentTicket = ++ticket;
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const abort = () => controller.abort(signal.reason);
|
|
136
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
137
|
+
const run = async () => {
|
|
138
|
+
await Promise.resolve();
|
|
139
|
+
if (disposed) return refuse('error', 'disposed');
|
|
140
|
+
if (controller.signal.aborted) return refuse('error', 'cancelled');
|
|
141
|
+
const credits = request.credits;
|
|
142
|
+
if (!['pages', 'rows', 'bytes', 'work'].every((key) => Number.isSafeInteger(credits?.[key]) && credits[key] >= 0))
|
|
143
|
+
return refuse('error', 'invalid-credits');
|
|
144
|
+
let start;
|
|
145
|
+
let limit;
|
|
146
|
+
let after;
|
|
147
|
+
if (request.continuation != null) {
|
|
148
|
+
const cursor = continuations.get(request.continuation);
|
|
149
|
+
if (!cursor || cursor.snapshot !== request.snapshot || cursor.query !== request.query)
|
|
150
|
+
return refuse('invalidated', 'continuation-changed');
|
|
151
|
+
start = cursor.end; limit = cursor.limit; after = cursor.after;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const range = request.range;
|
|
155
|
+
if (!range || !Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.end) || range.start < 0 || range.end < range.start)
|
|
156
|
+
return refuse('error', 'invalid-range');
|
|
157
|
+
start = range.start; limit = range.end - range.start;
|
|
158
|
+
if (start > 0 && !capabilities.seekIndex) return refuse('error', 'unsupported-seek');
|
|
159
|
+
}
|
|
160
|
+
if (credits.pages < 1 || credits.rows < limit || credits.work < limit || credits.bytes < 2
|
|
161
|
+
|| limit > bounds.rows || credits.bytes > Number.MAX_SAFE_INTEGER)
|
|
162
|
+
return refuse('budget-exhausted', 'credits');
|
|
163
|
+
try {
|
|
164
|
+
let page;
|
|
165
|
+
const wantedRevision = revision;
|
|
166
|
+
const result = await store.transaction(async (tx) => {
|
|
167
|
+
await checkVersion(tx);
|
|
168
|
+
if (request.snapshot !== snapshot()) return null;
|
|
169
|
+
if (resident && rows === null) {
|
|
170
|
+
// A reset rebuilds the whole bounded source. Admission reserves
|
|
171
|
+
// its maximum root pulls separately from the requested slice.
|
|
172
|
+
if (credits.work - limit < bounds.rows + 1) throw new RangeError('resident refresh exceeds work credits');
|
|
173
|
+
await loadResident(tx, controller.signal, used);
|
|
174
|
+
}
|
|
175
|
+
if (resident) {
|
|
176
|
+
const result = rows.slice(start, start + limit);
|
|
177
|
+
used.work += result.length;
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
if (limit === 0) return [];
|
|
181
|
+
// The store counts row payloads; a range also owes array brackets
|
|
182
|
+
// and commas. Reserve their maximum before any row is pulled.
|
|
183
|
+
const maxBytes = Math.min(credits.bytes, bounds.bytes) - limit - 1;
|
|
184
|
+
if (maxBytes < 1) throw new RangeError('range array exceeds byte credits');
|
|
185
|
+
page = await readPage(tx, { limit, after, maxBytes, signal: controller.signal }, used);
|
|
186
|
+
return page.items;
|
|
187
|
+
}, { signal: controller.signal });
|
|
188
|
+
if (disposed) return refuse('error', 'disposed');
|
|
189
|
+
if (controller.signal.aborted) return refuse('error', 'cancelled');
|
|
190
|
+
if (currentTicket !== ticket || request.generation !== generation) return refuse('invalidated', 'superseded');
|
|
191
|
+
if (result === null || wantedRevision !== revision || request.snapshot !== snapshot()) return refuse('invalidated', 'snapshot-changed');
|
|
192
|
+
used.pages = 1;
|
|
193
|
+
const bytes = bytesOf(result);
|
|
194
|
+
if (bytes > Math.min(credits.bytes, bounds.bytes)) return refuse('budget-exhausted', 'bytes');
|
|
195
|
+
result.forEach(deepFreeze);
|
|
196
|
+
const resultKeys = result.map(keyOf);
|
|
197
|
+
if (new Set(resultKeys).size !== resultKeys.length) return refuse('error', 'duplicate-key');
|
|
198
|
+
used.rows = result.length; used.bytes = bytes;
|
|
199
|
+
let continuation = null;
|
|
200
|
+
const more = resident ? start + result.length < rows.length : page?.hasMore !== false && result.length > 0;
|
|
201
|
+
if (more) {
|
|
202
|
+
continuation = host.uuid();
|
|
203
|
+
if (continuations.size >= bounds.pages) continuations.delete(continuations.keys().next().value);
|
|
204
|
+
continuations.set(continuation, { query, snapshot: snapshot(), end: start + result.length, limit, after: page?.continuation });
|
|
205
|
+
}
|
|
206
|
+
return { ...identity, state: 'ready', rows: result, keys: resultKeys, used,
|
|
207
|
+
continuation, total: capabilities.exactTotal ? { kind: 'known', value: rows.length } : { kind: 'unknown' } };
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
return refuse(disposed ? 'error' : controller.signal.aborted ? 'error'
|
|
211
|
+
: error instanceof RangeError || ['JD2007', 'JD2073', 'JD2074', 'JD2076'].includes(error?.code) ? 'budget-exhausted' : 'error',
|
|
212
|
+
disposed ? 'disposed' : controller.signal.aborted ? 'cancelled' : error?.code ?? error.message);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
const promise = run().finally(() => { pending.delete(controller); signal?.removeEventListener('abort', abort); });
|
|
216
|
+
pending.set(controller, promise);
|
|
217
|
+
return promise;
|
|
218
|
+
},
|
|
219
|
+
export(request, signal) {
|
|
220
|
+
if (disposed) throw new Error('disposed');
|
|
221
|
+
if (exports.size >= bounds.inFlight) throw new RangeError('Export credits');
|
|
222
|
+
const pageRows = request.pageRows ?? 64, pageBytes = request.pageBytes ?? bounds.bytes;
|
|
223
|
+
if (!resident) throw new Error('unsupported-export');
|
|
224
|
+
if (!Number.isSafeInteger(pageRows) || pageRows <= 0 || pageRows > bounds.rows
|
|
225
|
+
|| !Number.isSafeInteger(pageBytes) || pageBytes < 2) throw new RangeError('Invalid export credits');
|
|
226
|
+
const iterator = (async function* () {
|
|
227
|
+
try {
|
|
228
|
+
// Verify external changes before certifying the bounded resident source epoch.
|
|
229
|
+
await store.transaction(checkVersion);
|
|
230
|
+
if (!rows) throw new Error('Snapshot must be reloaded before export');
|
|
231
|
+
const total = rows.length;
|
|
232
|
+
for (let start = 0; start < total; start += pageRows) {
|
|
233
|
+
if (disposed || signal?.aborted || request.query !== query || request.snapshot !== snapshot() || !rows)
|
|
234
|
+
throw new Error('Incomplete snapshot export');
|
|
235
|
+
const items = rows.slice(start, start + pageRows);
|
|
236
|
+
if (bytesOf(items) > Math.min(pageBytes, bounds.bytes)) throw new RangeError('Export byte credits');
|
|
237
|
+
yield { state: 'ready', rows: items, keys: items.map(keyOf), query, snapshot: request.snapshot };
|
|
238
|
+
}
|
|
239
|
+
await store.transaction(checkVersion);
|
|
240
|
+
if (disposed || signal?.aborted || request.query !== query || request.snapshot !== snapshot() || !rows)
|
|
241
|
+
throw new Error('Incomplete snapshot export');
|
|
242
|
+
yield { state: 'complete', total, query, snapshot: request.snapshot };
|
|
243
|
+
}
|
|
244
|
+
finally { exports.delete(iterator); }
|
|
245
|
+
})();
|
|
246
|
+
exports.add(iterator);
|
|
247
|
+
return iterator;
|
|
248
|
+
},
|
|
249
|
+
async dispose() {
|
|
250
|
+
if (!disposed) {
|
|
251
|
+
disposed = true; unsubscribe(); observers.clear();
|
|
252
|
+
for (const controller of pending.keys()) controller.abort();
|
|
253
|
+
}
|
|
254
|
+
await Promise.allSettled([...pending.values()]);
|
|
255
|
+
await Promise.allSettled([...exports].map((iterator) => iterator.return()));
|
|
256
|
+
exports.clear();
|
|
257
|
+
continuations.clear(); rows = null; residentBytes = 0;
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
return Object.freeze(provider);
|
|
261
|
+
}
|
|
@@ -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
|
+
}
|