@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/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'),
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Formula authoring emits the same JSON document consumed by json/formula. */
|
|
3
|
+
import { formulaDocument } from '@jarenjs/json/formula';
|
|
4
|
+
import { requireJson } from '../json-boundary.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Author a saved Query profile; expression is an existing Query document.
|
|
8
|
+
* @param {string} id
|
|
9
|
+
* @param {any} expression
|
|
10
|
+
* @param {object} [options] - Revision, bindings, schema/helper references and result mode.
|
|
11
|
+
* @returns {any} Frozen JSON, with no executable closures in the document.
|
|
12
|
+
*/
|
|
13
|
+
export function defineFormula(id, expression, options = {}) {
|
|
14
|
+
return formulaDocument(requireJson({ $formula: '1', revision: '1', ...options, id, expression }, 'defineFormula'));
|
|
15
|
+
}
|
package/src/model/define.js
CHANGED
|
@@ -116,6 +116,8 @@ export function defineModel(spec) {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
const entity = { schema: builder.schema };
|
|
119
|
+
if (builder.state.invariants !== undefined) entity.invariants = builder.state.invariants;
|
|
120
|
+
if (builder.state.physical !== undefined) entity.physical = builder.state.physical;
|
|
119
121
|
if (builder.state.renamedFrom !== undefined) entity['x-rename'] = builder.state.renamedFrom;
|
|
120
122
|
setObjectMember(emitted, name, entity);
|
|
121
123
|
}
|
package/src/model/entity.js
CHANGED
|
@@ -218,6 +218,21 @@ export function withEntity(Base) {
|
|
|
218
218
|
*/
|
|
219
219
|
renamedFrom(name) { return this.with({ renamedFrom: requireName(name, 'renamedFrom()') }); }
|
|
220
220
|
|
|
221
|
+
/** Explicit existing columns, lifted onto the entity declaration.
|
|
222
|
+
* @param {any} layout */
|
|
223
|
+
physical(layout) {
|
|
224
|
+
if (this.state.kind !== 'object') throw new LinqBuildError('JL0102', 'physical() belongs on an entity object');
|
|
225
|
+
return this.with({ physical: requireJson(layout, 'physical()') });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
/** Declare persistence predicates, with explicit writer enforcement.
|
|
230
|
+
* @param {any[]} rules */
|
|
231
|
+
invariants(rules) {
|
|
232
|
+
if (this.state.kind !== 'object') throw new LinqBuildError('JL0102', 'invariants() belongs on an entity object');
|
|
233
|
+
return this.with({ invariants: requireJson(rules, 'invariants()') });
|
|
234
|
+
}
|
|
235
|
+
|
|
221
236
|
/** As in the schema pen, but `x-entity` is owned here. @param {Record<string, any>} annotations */
|
|
222
237
|
meta(annotations) {
|
|
223
238
|
if (annotations !== null && typeof annotations === 'object' && KEYWORD in annotations) {
|
package/src/project/index.js
CHANGED
|
@@ -5,24 +5,25 @@ import { LinqBuildError } from '../errors.js';
|
|
|
5
5
|
|
|
6
6
|
/** The public project file vocabulary, held equal to Studio's schema by tests. */
|
|
7
7
|
export const FILE_KINDS = Object.freeze(['app', 'jslt', 'query', 'state', 'data', 'schema', 'fsm', 'dag', 'model', 'contract']);
|
|
8
|
+
const FILE_OPTIONS = ['imports', 'input', 'model', 'collection'];
|
|
8
9
|
const LAYOUT_KEYS = ['mode', 'ratio', 'autorun'];
|
|
9
10
|
|
|
10
11
|
/** One file, preserving the caller's text byte-for-byte. */
|
|
11
|
-
export function file(name, kind, text) {
|
|
12
|
+
export function file(name, kind, text, options = {}) {
|
|
12
13
|
if (typeof name !== 'string' || name.length === 0 || !FILE_KINDS.includes(kind) || typeof text !== 'string')
|
|
13
14
|
throw new LinqBuildError('JL0101', 'file() requires a nonempty name, a declared kind and text');
|
|
14
|
-
return snapshot({ name, kind, text });
|
|
15
|
+
return snapshot({ name, kind, text, ...optionsOf(options, FILE_OPTIONS, 'file()') });
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
/** One file holding a public JSON value; pass another pen's `.schema` explicitly. */
|
|
18
|
-
export function jsonFile(name, kind, document) { return file(name, kind, JSON.stringify(snapshot(document))); }
|
|
19
|
+
export function jsonFile(name, kind, document, options = {}) { return file(name, kind, JSON.stringify(snapshot(document)), options); }
|
|
19
20
|
|
|
20
21
|
/** Validate the authoring shape, preserving duplicate names for Studio to judge. */
|
|
21
22
|
function filesOf(files) {
|
|
22
23
|
if (!Array.isArray(files)) throw new LinqBuildError('JL0101', 'files() takes an array of project files');
|
|
23
24
|
return files.map((value) => {
|
|
24
|
-
const
|
|
25
|
-
return file(
|
|
25
|
+
const { name, kind, text, ...options } = optionsOf(value, ['name', 'kind', 'text', ...FILE_OPTIONS], 'file');
|
|
26
|
+
return file(name, kind, text, options);
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
29
|
|
package/types/db.d.ts
CHANGED
|
@@ -145,6 +145,7 @@ export type Continuation<S, M extends EntityMeta> = LoadContinuation & {
|
|
|
145
145
|
/** A page's options over this graph (the store's `PageOptions`, the
|
|
146
146
|
* continuation typed by the declared ordering). */
|
|
147
147
|
export interface GraphPageOptions<S, M extends EntityMeta> extends EntityCursorOptions {
|
|
148
|
+
lookahead?: boolean;
|
|
148
149
|
limit?: number;
|
|
149
150
|
after?: Continuation<S, M>;
|
|
150
151
|
maxBytes?: number;
|
|
@@ -205,6 +206,7 @@ export interface Graph<E extends MetaMap<E>, M extends EntityMeta, S> {
|
|
|
205
206
|
* exactly the many-to-many members. */
|
|
206
207
|
export type EntityHandle<E extends MetaMap<E>, M extends EntityMeta> =
|
|
207
208
|
TypedEntitySet<E, M> & ChainStart<M['doc']> & {
|
|
209
|
+
range(spec: import('@jarenjs/db').LoadSpec, options: DbRangeOptions): Promise<DbRangeProvider<M['doc']>>;
|
|
208
210
|
/** Open a graph: include one relation member with an optional spec. */
|
|
209
211
|
include<K extends keyof M['relations'] & string, const I extends IncludeSpec<E, TargetMeta<E, M, K>> = true>(
|
|
210
212
|
pick: (u: RelationPicker<M>) => Picked<K>, spec?: I | NoInfer<IncludeSpec<E, TargetMeta<E, M, K>>>,
|
|
@@ -217,6 +219,40 @@ export type EntityHandle<E extends MetaMap<E>, M extends EntityMeta> =
|
|
|
217
219
|
live<T = M['doc']>(source?: AsyncSequence<T, any> | object, options?: LiveOptions): Promise<TypedLiveQuery<T>>;
|
|
218
220
|
};
|
|
219
221
|
|
|
222
|
+
export interface DbRangeOptions {
|
|
223
|
+
keys: readonly string[];
|
|
224
|
+
resident?: boolean; seekIndex?: boolean; exactTotal?: boolean;
|
|
225
|
+
source?: string; query?: string; schemaVersion?: string;
|
|
226
|
+
profile?: 'safe' | import('@jarenjs/db').ProfileSpec;
|
|
227
|
+
maxRows?: number; maxBytes?: number; maxPages?: number; maxInFlight?: number; maxSubscriptions?: number;
|
|
228
|
+
runtime?: Partial<Runtime>;
|
|
229
|
+
}
|
|
230
|
+
export interface DbRangeRequest {
|
|
231
|
+
generation: number; requestId: string; query: string; snapshot: string;
|
|
232
|
+
range?: { start: number; end: number }; continuation?: string;
|
|
233
|
+
credits: { pages: number; rows: number; bytes: number; work: number };
|
|
234
|
+
}
|
|
235
|
+
export interface DbRangeResponse<T = unknown> {
|
|
236
|
+
generation: number; requestId: string; query: string; snapshot: string;
|
|
237
|
+
state: 'ready' | 'loading' | 'error' | 'invalidated' | 'budget-exhausted'; reason?: string;
|
|
238
|
+
rows?: readonly T[]; keys?: readonly string[]; continuation?: string | null;
|
|
239
|
+
total?: { kind: 'known'; value: number } | { kind: 'unknown' };
|
|
240
|
+
used: { pages: number; rows: number; bytes: number; work: number };
|
|
241
|
+
}
|
|
242
|
+
export interface DbRangeProvider<T = unknown> {
|
|
243
|
+
readonly query: string; readonly snapshot: string;
|
|
244
|
+
readonly capabilities: Readonly<{ seekIndex: boolean; seekKey: false; continuation: true;
|
|
245
|
+
live: true; exactTotal: boolean; completeExport: false }>;
|
|
246
|
+
request(request: DbRangeRequest, signal?: AbortSignal): Promise<DbRangeResponse<T>>;
|
|
247
|
+
subscribe(observer: (event: { type: 'reset'; reason: string; query: string; snapshot: string; revision: number; capture: string }) => void): () => boolean;
|
|
248
|
+
stats(): { sourceReads: number; sourceRows: number; sourceBytes: number; pending: number;
|
|
249
|
+
pages: number; rows: number; bytes: number; subscriptions: number; disposed: boolean };
|
|
250
|
+
dispose(): Promise<void>;
|
|
251
|
+
}
|
|
252
|
+
/** Source capture is required; keyset mode is sequential unless resident is explicit. */
|
|
253
|
+
export declare function createDbRangeProvider<T = unknown>(store: import('@jarenjs/db').Store | TypedStore<any>,
|
|
254
|
+
entity: string, spec: import('@jarenjs/db').LoadSpec, options: DbRangeOptions): Promise<DbRangeProvider<T>>;
|
|
255
|
+
|
|
220
256
|
/** Present exactly when the model declares entities, as on the store. */
|
|
221
257
|
export interface EntityClientMembers {
|
|
222
258
|
/** The store's unit of work, flushed. */
|
|
@@ -331,6 +367,9 @@ export type TransactionClientOf<E extends MetaMap<E>, C = Record<string, unknown
|
|
|
331
367
|
/** The scope-bound store — the escape hatch, still inside. It carries
|
|
332
368
|
* no `close`: a transaction never owns the connection's lifetime. */
|
|
333
369
|
readonly store: TransactionStore;
|
|
370
|
+
readonly sql: TransactionStore['sql'];
|
|
371
|
+
readonly jobs: TransactionStore['jobs'];
|
|
372
|
+
readonly sync: TransactionStore['sync'];
|
|
334
373
|
readonly capabilities: StoreCapabilities;
|
|
335
374
|
readonly entities: { readonly [K in keyof E & string]: EntityHandle<E, E[K]> };
|
|
336
375
|
readonly collections: { readonly [K in keyof C & string]: CollectionHandle<C[K]> };
|
|
@@ -364,3 +403,65 @@ export function defineReplication(header: Omit<import('@jarenjs/db').Replication
|
|
|
364
403
|
toDocument(): import('@jarenjs/db').ReplicationEnvelope;
|
|
365
404
|
toJSON(): import('@jarenjs/db').ReplicationEnvelope;
|
|
366
405
|
};
|
|
406
|
+
|
|
407
|
+
/** A complete lexical membership composed with the common structural range provider. */
|
|
408
|
+
export declare function createLexicalRangeProvider(source: import('@jarenjs/db/search').DbSearch,
|
|
409
|
+
text: string, request?: Record<string, unknown>, options?: {maxMatches?:number; maxSourceBytes?:number;
|
|
410
|
+
maxRows?:number; maxBytes?:number; maxPages?:number; maxInFlight?:number; query?:string;
|
|
411
|
+
source?:string; exactTotal?:boolean; seekIndex?:boolean; disposeSource?:boolean}): Promise<any>;
|
|
412
|
+
|
|
413
|
+
/** Atomic staging and publication over application-declared collections. */
|
|
414
|
+
export function createDbIngestionStore(client: Client<any> | TransactionClientOf<any>, options: {
|
|
415
|
+
staging: string; checkpoints: string; publications: string; facts?: string;
|
|
416
|
+
reconcile?: (existing: any, incoming: any, evidence: any) => any;
|
|
417
|
+
}): {
|
|
418
|
+
begin(plan: any): Promise<any>;
|
|
419
|
+
stage(plan: any, partition: string, page: any): Promise<any>;
|
|
420
|
+
invalidate(plan: any, reason: string): Promise<void>;
|
|
421
|
+
publish(plan: any, evidence: any, context?: { signal?: AbortSignal }): Promise<any>;
|
|
422
|
+
current(source: string): Promise<any>;
|
|
423
|
+
inspect(plan: any): Promise<any>;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/** Lossless canonical-record mapping onto an application-declared collection. */
|
|
427
|
+
export type DurableRecordMapping = string | (({ collection: string; entity?: never } | { entity: string; collection?: never }) & { key?: (id: string) => any; read: (stored: any) => any; write: (record: any) => any });
|
|
428
|
+
export interface CommandIdentity {
|
|
429
|
+
tenant: string; environment: string; aggregate: string; op: string; key: string; hashVersion: string; hash: string;
|
|
430
|
+
}
|
|
431
|
+
/** Permanent business outcomes and independently expiring execution authority. */
|
|
432
|
+
export function createDbReceipts(client: Client<any> | TransactionClientOf<any>, options: {
|
|
433
|
+
receipts: DurableRecordMapping; leases?: DurableRecordMapping; runtime?: Partial<import('@jarenjs/core/runtime').Runtime>;
|
|
434
|
+
}): {
|
|
435
|
+
lookup(identity: CommandIdentity): Promise<any>;
|
|
436
|
+
execute(identity: CommandIdentity, work: (tx: TransactionClientOf<any>) => Promise<{ outcome: any; references?: any[] }>, context?: { lease?: any }): Promise<any>;
|
|
437
|
+
claim(identity: CommandIdentity, options: { leaseMs: number }): Promise<any>;
|
|
438
|
+
release(identity: CommandIdentity, lease: any): Promise<any>;
|
|
439
|
+
sweep(identities: CommandIdentity[]): Promise<{ changes: number; writes: number; revisions: number }>;
|
|
440
|
+
migrate(records: any[]): Promise<{ changes: number; writes: number; revisions: number }>;
|
|
441
|
+
compact(identity: CommandIdentity, policy: { retainReplay: true; retainReferences: true; actor: string; reason: string }): Promise<any>;
|
|
442
|
+
};
|
|
443
|
+
/** Durable preparation, job-fenced sending and explicit reconciliation. */
|
|
444
|
+
export function createDbEffectStore(client: Client<any> | TransactionClientOf<any>, options: {
|
|
445
|
+
operations: DurableRecordMapping; maxLegs?: number; maxBytes?: number;
|
|
446
|
+
}): {
|
|
447
|
+
prepare(plan: any, prepare?: (tx: TransactionClientOf<any>) => any): Promise<any>;
|
|
448
|
+
get(id: string): Promise<any>;
|
|
449
|
+
begin(id: string, legId: string, revision: number, lease: import('@jarenjs/db').JobLease): Promise<any>;
|
|
450
|
+
settle(id: string, legId: string, revision: number, lease: import('@jarenjs/db').JobLease, outcome: any): Promise<any>;
|
|
451
|
+
recover(id: string, revision: number, lease: import('@jarenjs/db').JobLease): Promise<any>;
|
|
452
|
+
reconcile(id: string, legId: string, revision: number, lease: import('@jarenjs/db').JobLease, decision: any): Promise<any>;
|
|
453
|
+
};
|
|
454
|
+
/** Application run/checkpoint records and bounded public revision pages. */
|
|
455
|
+
export function createDbRunStore(client: Client<any> | TransactionClientOf<any>, options: {
|
|
456
|
+
runs: DurableRecordMapping; events: DurableRecordMapping; statuses?: Record<string, string>;
|
|
457
|
+
summary?: (snapshot: any) => any; canReset?: (tx: TransactionClientOf<any>, record: any) => any; maxPage?: number; maxBytes?: number;
|
|
458
|
+
}): {
|
|
459
|
+
attach(identity: { id: string; jobId: string; workflow: string; schemaVersion: string }, lease: import('@jarenjs/db').JobLease): Promise<any>;
|
|
460
|
+
load(id: string, identity: any, lease: import('@jarenjs/db').JobLease): Promise<any>;
|
|
461
|
+
save(id: string, snapshot: any, expectedGeneration: number, lease: import('@jarenjs/db').JobLease): Promise<boolean>;
|
|
462
|
+
get(id: string): Promise<any>;
|
|
463
|
+
page(id: string, options?: { after?: number; limit?: number }): Promise<any>;
|
|
464
|
+
requestCancel(id: string, revision: number, evidence: { actor: string; reason: string }): Promise<any>;
|
|
465
|
+
finish(id: string, state: 'cancelled' | 'failed', lease: import('@jarenjs/db').JobLease): Promise<any>;
|
|
466
|
+
reset(id: string, revision: number, evidence: any, lease: import('@jarenjs/db').JobLease): Promise<any>;
|
|
467
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Author a saved JSON Query formula without executing it. */
|
|
2
|
+
export declare function defineFormula(id: string, expression: unknown, options?: {
|
|
3
|
+
revision?: string;
|
|
4
|
+
bindings?: Record<string, unknown>;
|
|
5
|
+
inputSchema?: { id: string; version: string };
|
|
6
|
+
resultSchema?: { id: string; version: string };
|
|
7
|
+
helpers?: { name: string; version: string }[];
|
|
8
|
+
resultMode?: 'value' | 'outcome';
|
|
9
|
+
}): any;
|
package/types/index.d.ts
CHANGED
|
@@ -77,6 +77,8 @@ export interface StringExpr extends ExprBase<string>, EqExpr<string>, SpatialMet
|
|
|
77
77
|
contains(value: string | StringExpr): BoolExpr;
|
|
78
78
|
/** I-Regexp (RFC 9485) full match. */
|
|
79
79
|
matches(pattern: string): BoolExpr;
|
|
80
|
+
/** Explicit lexical provider request; preserves ranked-result completeness. */
|
|
81
|
+
lexical(provider: string, request?: Record<string, unknown>): UnknownExpr;
|
|
80
82
|
upper(): StringExpr;
|
|
81
83
|
lower(): StringExpr;
|
|
82
84
|
length(): NumberExpr;
|
|
@@ -363,6 +365,8 @@ export interface UnknownExpr
|
|
|
363
365
|
endsWith(value: unknown): BoolExpr;
|
|
364
366
|
contains(value: unknown): BoolExpr;
|
|
365
367
|
matches(pattern: string): BoolExpr;
|
|
368
|
+
/** Explicit lexical provider request; preserves ranked-result completeness. */
|
|
369
|
+
lexical(provider: string, request?: Record<string, unknown>): UnknownExpr;
|
|
366
370
|
upper(): UnknownExpr;
|
|
367
371
|
lower(): UnknownExpr;
|
|
368
372
|
length(): NumberExpr;
|
package/types/model.d.ts
CHANGED
|
@@ -61,6 +61,20 @@ export interface EntityBlock {
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** Explicit column-only layout for an existing SQLite object. */
|
|
65
|
+
export interface PhysicalLayout {
|
|
66
|
+
readonly table: string;
|
|
67
|
+
readonly kind?: 'table' | 'view';
|
|
68
|
+
readonly keys?: readonly string[];
|
|
69
|
+
readonly columns: Readonly<Record<string, {
|
|
70
|
+
readonly name: string;
|
|
71
|
+
readonly codec: 'text' | 'integer' | 'number' | 'boolean' | 'json' | 'date' | 'datetime' | 'epoch-ms' | 'bigint' | 'decimal' | 'blob-hex';
|
|
72
|
+
readonly null: 'null' | 'absent' | 'reject';
|
|
73
|
+
readonly default?: 'database';
|
|
74
|
+
readonly generated?: boolean;
|
|
75
|
+
}>>;
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
// ————— the entity-aware builders —————
|
|
65
79
|
|
|
66
80
|
/** The base every untyped kind is built from, plus the vocabulary. */
|
|
@@ -203,6 +217,10 @@ export class EntityObjectBuilder<
|
|
|
203
217
|
P extends Props, Open extends boolean = false, PV = never, PVIn = PV,
|
|
204
218
|
N extends boolean = false, F extends Flag = never,
|
|
205
219
|
> extends ObjectBuilder<P, Open, PV, PVIn, N, F> {
|
|
220
|
+
physical(layout: PhysicalLayout): this;
|
|
221
|
+
invariants(rules: readonly { name: string; on: readonly ('insert' | 'update' | 'delete')[];
|
|
222
|
+
enforcement: 'database' | 'store'; assert: unknown;
|
|
223
|
+
audit?: { entity: string; values: Readonly<Record<string, unknown>> } }[]): this;
|
|
206
224
|
optional(): EntityObjectBuilder<P, Open, PV, PVIn, N, F | 'optional'>;
|
|
207
225
|
nullable(): EntityObjectBuilder<P, Open, PV, PVIn, true, F>;
|
|
208
226
|
open(): EntityObjectBuilder<P, true, PV, PVIn, N, F>;
|
package/types/project.d.ts
CHANGED
|
@@ -7,7 +7,13 @@ export type JsonInput<T> = unknown extends T ? unknown
|
|
|
7
7
|
: T extends object ? { readonly [K in keyof T]: JsonInput<T[K]> } : never;
|
|
8
8
|
|
|
9
9
|
export type FileKind = 'app' | 'jslt' | 'query' | 'state' | 'data' | 'schema' | 'fsm' | 'dag' | 'model' | 'contract';
|
|
10
|
-
export interface
|
|
10
|
+
export interface ProjectFileOptions {
|
|
11
|
+
readonly imports?: Readonly<Record<string, string>>;
|
|
12
|
+
readonly input?: string;
|
|
13
|
+
readonly model?: string;
|
|
14
|
+
readonly collection?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ProjectFile<Name extends string = string, Kind extends FileKind = FileKind> extends ProjectFileOptions {
|
|
11
17
|
readonly name: Name;
|
|
12
18
|
readonly kind: Kind;
|
|
13
19
|
readonly text: string;
|
|
@@ -24,8 +30,8 @@ export interface ProjectDocument {
|
|
|
24
30
|
readonly layout?: ProjectLayout;
|
|
25
31
|
}
|
|
26
32
|
export const FILE_KINDS: readonly FileKind[];
|
|
27
|
-
export function file<const N extends string, K extends FileKind>(name: N, kind: K, text: string): ProjectFile<N, K>;
|
|
28
|
-
export function jsonFile<const N extends string, K extends FileKind, const D>(name: N, kind: K, document: D & JsonInput<D
|
|
33
|
+
export function file<const N extends string, K extends FileKind>(name: N, kind: K, text: string, options?: ProjectFileOptions): ProjectFile<N, K>;
|
|
34
|
+
export function jsonFile<const N extends string, K extends FileKind, const D>(name: N, kind: K, document: D & JsonInput<D>, options?: ProjectFileOptions): ProjectFile<N, K>;
|
|
29
35
|
/** Names are a phantom; the public document has no extra registry. */
|
|
30
36
|
export class ProjectBuilder<Names extends string = never> {
|
|
31
37
|
protected constructor();
|