@jarenjs/linq 0.49.2 → 0.66.1
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 +227 -0
- package/README.md +650 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1221 -0
- package/docs/DB-CLIENT.md +882 -0
- package/docs/FLOW-PEN.md +1033 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +778 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1092 -0
- package/docs/QUERY-PEN.md +1724 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +251 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +255 -0
- package/src/app/index.js +20 -0
- package/src/app/patch.js +277 -0
- package/src/app/sub.js +106 -0
- package/src/async.js +377 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +48 -11
- package/src/contract/define.js +282 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +338 -0
- package/src/db/handle.js +89 -0
- package/src/db/include.js +351 -0
- package/src/db/index.js +24 -0
- package/src/db/ledger.js +195 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +130 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +78 -6
- package/src/expression.js +463 -36
- package/src/federate.js +531 -0
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +316 -0
- package/src/flow/fsm.js +323 -0
- package/src/flow/index.js +22 -0
- package/src/forms/index.js +43 -0
- package/src/forms/rules.js +170 -0
- package/src/forms/submit.js +177 -0
- package/src/index.js +5 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +202 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +318 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +244 -0
- package/src/model/collection.js +273 -0
- package/src/model/define.js +125 -0
- package/src/model/entity.js +307 -0
- package/src/model/index.js +47 -0
- package/src/model/relation.js +85 -0
- package/src/provider.js +137 -20
- package/src/schema/brand.js +31 -0
- package/src/schema/builders.js +526 -0
- package/src/schema/check.js +29 -0
- package/src/schema/emit.js +394 -0
- package/src/schema/factories.js +239 -0
- package/src/schema/index.js +37 -0
- package/src/schema-of.js +24 -0
- package/src/sequence.js +233 -103
- package/src/sources.js +10 -3
- package/types/app.d.ts +293 -0
- package/types/contract.d.ts +468 -0
- package/types/db.d.ts +359 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +296 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +526 -0
- package/types/schema.d.ts +494 -0
package/src/db/handle.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The handles the client fronts the store with. An entity handle
|
|
4
|
+
* is the store's entity set — every member of it, the provider members
|
|
5
|
+
* included, so `fromAsync(handle)` still binds through its root and two
|
|
6
|
+
* handles of one client still share a scope — plus the chain start
|
|
7
|
+
* (every `AsyncSequence` operator and terminal, delegated to
|
|
8
|
+
* `fromAsync(set)`: nothing is duplicated, every read is the chain and
|
|
9
|
+
* pushes down), `include` (the graph builder that EMITS the store's
|
|
10
|
+
* `load` spec), `link`/`unlink` (checked against the relation table
|
|
11
|
+
* before the store records them) and `live`. A collection handle is the
|
|
12
|
+
* collection with the same chain start and `live`. `explain()` without
|
|
13
|
+
* a document explains the empty chain; with one it is the store's
|
|
14
|
+
* explanation of that document.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { fromAsync, AsyncSequence } from '../async.js';
|
|
18
|
+
import { Graph } from './include.js';
|
|
19
|
+
import { requireMembership } from './membership.js';
|
|
20
|
+
import { registerLive } from './live.js';
|
|
21
|
+
|
|
22
|
+
/** The chain surface, read once from the class: every public operator
|
|
23
|
+
* and terminal, `explain` set aside for its overload. */
|
|
24
|
+
const CHAIN_MEMBERS = Object.getOwnPropertyNames(AsyncSequence.prototype)
|
|
25
|
+
.filter((name) => name !== 'constructor' && name !== 'explain');
|
|
26
|
+
const CHAIN_SYMBOLS = Object.getOwnPropertySymbols(AsyncSequence.prototype);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Give a handle the chain start over a provider: each member is
|
|
30
|
+
* `fromAsync(provider)`'s, so the handle and the chain are one surface.
|
|
31
|
+
* @param {any} provider - the store's handle (a collection or an entity set)
|
|
32
|
+
* @param {Record<string | symbol, any>} members - the handle being built
|
|
33
|
+
*/
|
|
34
|
+
function chainStart(provider, members) {
|
|
35
|
+
for (const name of CHAIN_MEMBERS) {
|
|
36
|
+
members[name] = (...args) => fromAsync(provider)[name](...args);
|
|
37
|
+
}
|
|
38
|
+
for (const symbol of CHAIN_SYMBOLS) {
|
|
39
|
+
members[symbol] = () => fromAsync(provider)[symbol]();
|
|
40
|
+
}
|
|
41
|
+
const explainDocument = provider.explain;
|
|
42
|
+
members.explain = (document, options) => (document === undefined
|
|
43
|
+
? fromAsync(provider).explain()
|
|
44
|
+
: explainDocument(document, options));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The handle over one entity set.
|
|
49
|
+
* @param {any} store - the opened store
|
|
50
|
+
* @param {string} name - the entity
|
|
51
|
+
* @returns {any}
|
|
52
|
+
*/
|
|
53
|
+
export function createEntityHandle(store, name) {
|
|
54
|
+
const set = store.entity(name);
|
|
55
|
+
/** @type {Record<string | symbol, any>} */
|
|
56
|
+
const members = { ...set };
|
|
57
|
+
chainStart(set, members);
|
|
58
|
+
members.include = (pick, spec) => new Graph(set, name).include(pick, spec);
|
|
59
|
+
// the graph with nothing included: the root clauses, the keyset and
|
|
60
|
+
// the page over the rows alone
|
|
61
|
+
members.graph = () => new Graph(set, name);
|
|
62
|
+
members.link = (own, member, target) => {
|
|
63
|
+
requireMembership(set.relations, name, member, 'link');
|
|
64
|
+
set.link(own, member, target);
|
|
65
|
+
};
|
|
66
|
+
members.unlink = (own, member, target) => {
|
|
67
|
+
requireMembership(set.relations, name, member, 'unlink');
|
|
68
|
+
set.unlink(own, member, target);
|
|
69
|
+
};
|
|
70
|
+
members.live = (source = fromAsync(set), options = undefined) =>
|
|
71
|
+
registerLive(store.live, source, options);
|
|
72
|
+
return Object.freeze(members);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The handle over one collection.
|
|
77
|
+
* @param {any} store - the opened store
|
|
78
|
+
* @param {string} name - the collection
|
|
79
|
+
* @returns {any}
|
|
80
|
+
*/
|
|
81
|
+
export function createCollectionHandle(store, name) {
|
|
82
|
+
const collection = store.collection(name);
|
|
83
|
+
/** @type {Record<string | symbol, any>} */
|
|
84
|
+
const members = { ...collection };
|
|
85
|
+
chainStart(collection, members);
|
|
86
|
+
members.live = (source = fromAsync(collection), options = undefined) =>
|
|
87
|
+
registerLive(collection.live, source, options);
|
|
88
|
+
return Object.freeze(members);
|
|
89
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The graph builder: `include(pick, spec)` and the root clauses
|
|
4
|
+
* accumulate a `load` specification (MODEL-FORMAT §10.4, §10.5) — the
|
|
5
|
+
* client EMITS the spec the store already runs and runs nothing itself.
|
|
6
|
+
* The relation member is captured to its NAME; every callback is
|
|
7
|
+
* captured to a query expression over `$it` through the chain's own
|
|
8
|
+
* recording proxy — with no parameters, since a load clause binds no
|
|
9
|
+
* externals — and `toArray()` is `load(spec)`, `explain()` is
|
|
10
|
+
* `explainLoad(spec)`: the one-statement guarantee is the store's. A
|
|
11
|
+
* spec is plain JSON in a fixed member order (`where, orderBy, take,
|
|
12
|
+
* skip, after, maxDepth, include` at the root; `where, orderBy, take,
|
|
13
|
+
* skip, count, maxRows, maxBytes, include` in an include — a keyset
|
|
14
|
+
* cursor paginates the root alone), so one graph is one document;
|
|
15
|
+
* `toJSON()` is that document, as a pen's is. `cursor()` is
|
|
16
|
+
* `loadCursor(spec)`: one root graph per pull.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { deepFreeze, setObjectMember } from '@jarenjs/core/object';
|
|
20
|
+
|
|
21
|
+
import { captureExpression } from '../expression.js';
|
|
22
|
+
import { LinqBuildError } from '../errors.js';
|
|
23
|
+
import { requireJson, describeValue } from '../json-boundary.js';
|
|
24
|
+
|
|
25
|
+
const NO_PARAMS = new Set();
|
|
26
|
+
const ROOT_KEYS = ['where', 'orderBy', 'take', 'skip', 'after', 'maxDepth', 'include'];
|
|
27
|
+
const INCLUDE_KEYS = ['where', 'orderBy', 'take', 'skip', 'count', 'maxRows', 'maxBytes', 'include'];
|
|
28
|
+
/** A member path over the row: the shorthand or the bracketed spelling. */
|
|
29
|
+
const MEMBER_PATH = /^\$it(?:\.([A-Za-z_$][\w$]*)|\['((?:[^'\\]|\\.)*)'\])$/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Capture one callback over the row bound as `it`.
|
|
33
|
+
* @param {any} fn
|
|
34
|
+
* @param {string} what - for the message
|
|
35
|
+
* @returns {any} the query expression
|
|
36
|
+
*/
|
|
37
|
+
function captureOver(fn, what) {
|
|
38
|
+
if (typeof fn !== 'function') {
|
|
39
|
+
throw new LinqBuildError('JL0101', `${what} takes a callback over the row, got ${describeValue(fn)}`);
|
|
40
|
+
}
|
|
41
|
+
return captureExpression(fn, ['it'], NO_PARAMS);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The relation entry a member names, or the refusal naming the table.
|
|
46
|
+
* @param {Record<string, any>} relations
|
|
47
|
+
* @param {string} entityName
|
|
48
|
+
* @param {string} member
|
|
49
|
+
* @param {string} what
|
|
50
|
+
* @returns {{ member: string, entry: any }}
|
|
51
|
+
*/
|
|
52
|
+
function requireRelation(relations, entityName, member, what) {
|
|
53
|
+
const entry = relations[member];
|
|
54
|
+
if (entry === undefined) {
|
|
55
|
+
const declared = Object.keys(relations);
|
|
56
|
+
throw new LinqBuildError('JL0107',
|
|
57
|
+
`'${member}' is not a relation member of '${entityName}' — ${what} loads a declared relation`
|
|
58
|
+
+ (declared.length === 0 ? `, and '${entityName}' declares none`
|
|
59
|
+
: ` (${declared.map((name) => `'${name}'`).join(', ')})`));
|
|
60
|
+
}
|
|
61
|
+
return { member, entry };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The relation member a pick names: `(u) => u.posts` — or `u.get('posts')`
|
|
66
|
+
* for a name that collides with a method — captured to `$it.posts` and
|
|
67
|
+
* looked up in the relation table.
|
|
68
|
+
* @param {any} pick
|
|
69
|
+
* @param {Record<string, any>} relations
|
|
70
|
+
* @param {string} entityName
|
|
71
|
+
* @returns {{ member: string, entry: any }}
|
|
72
|
+
*/
|
|
73
|
+
function pickMember(pick, relations, entityName) {
|
|
74
|
+
const captured = captureOver(pick, 'include()');
|
|
75
|
+
const match = typeof captured === 'string' ? MEMBER_PATH.exec(captured) : null;
|
|
76
|
+
if (match === null) {
|
|
77
|
+
throw new LinqBuildError('JL0107',
|
|
78
|
+
`include() picks one relation member of '${entityName}' by name ((u) => u.posts); got `
|
|
79
|
+
+ (typeof captured === 'string' ? captured : 'an operator result'));
|
|
80
|
+
}
|
|
81
|
+
const member = match[1] ?? match[2].replace(/\\(.)/g, '$1');
|
|
82
|
+
return requireRelation(relations, entityName, member, 'include()');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One `$orderby` term: a bare key for a plain ascending order, else the
|
|
87
|
+
* `{ $key, $dir, $empty, $collation }` spec — as the chain spells it.
|
|
88
|
+
* @param {any} key - the key callback
|
|
89
|
+
* @param {boolean} desc
|
|
90
|
+
* @param {{ empty?: string, collation?: string } | undefined} options
|
|
91
|
+
* @param {string} what
|
|
92
|
+
* @returns {any}
|
|
93
|
+
*/
|
|
94
|
+
function orderTerm(key, desc, options, what) {
|
|
95
|
+
const expression = captureOver(key, what);
|
|
96
|
+
const spec = { $key: expression };
|
|
97
|
+
if (desc) spec.$dir = 'desc';
|
|
98
|
+
if (options !== undefined && options !== null) {
|
|
99
|
+
if (options.empty !== undefined) spec.$empty = requireJson(options.empty, `${what} empty`);
|
|
100
|
+
if (options.collation !== undefined) spec.$collation = requireJson(options.collation, `${what} collation`);
|
|
101
|
+
}
|
|
102
|
+
return Object.keys(spec).length === 1 ? expression : spec;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* An include spec's `orderBy`: a key callback, `{ key, desc?, empty?,
|
|
107
|
+
* collation? }`, or an array of either.
|
|
108
|
+
* @param {any} orderBy
|
|
109
|
+
* @param {string} what
|
|
110
|
+
* @returns {any}
|
|
111
|
+
*/
|
|
112
|
+
function lowerOrder(orderBy, what) {
|
|
113
|
+
const one = (term, at) => {
|
|
114
|
+
if (typeof term === 'function') return orderTerm(term, false, undefined, at);
|
|
115
|
+
if (term !== null && typeof term === 'object' && !Array.isArray(term) && typeof term.key === 'function') {
|
|
116
|
+
return orderTerm(term.key, term.desc === true, term, at);
|
|
117
|
+
}
|
|
118
|
+
throw new LinqBuildError('JL0101',
|
|
119
|
+
`${at} takes a key callback ((p) => p.stars) or { key, desc?, empty?, collation? }, got ${describeValue(term)}`);
|
|
120
|
+
};
|
|
121
|
+
return Array.isArray(orderBy) ? orderBy.map((term, i) => one(term, `${what}[${i}]`)) : one(orderBy, what);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Lower one include: `true` (or nothing) includes the rows; an object
|
|
126
|
+
* carries the clauses the store's `load` reads, each callback captured,
|
|
127
|
+
* nested includes resolved against the TARGET's relation table.
|
|
128
|
+
* @param {any} spec
|
|
129
|
+
* @param {string} entityName - the target entity
|
|
130
|
+
* @param {(name: string) => Record<string, any> | undefined} relationsOf
|
|
131
|
+
* @param {string[]} path
|
|
132
|
+
* @returns {any}
|
|
133
|
+
*/
|
|
134
|
+
function lowerInclude(spec, entityName, relationsOf, path) {
|
|
135
|
+
if (spec === undefined || spec === true) return true;
|
|
136
|
+
const at = path.join('.');
|
|
137
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
138
|
+
// the member list is DERIVED from the same constant the check below
|
|
139
|
+
// reads: typed out, it named `after` — a member that check refuses —
|
|
140
|
+
// and a reader who believed the first message met the second
|
|
141
|
+
throw new LinqBuildError('JL0101',
|
|
142
|
+
`the include spec at ${at} is true or `
|
|
143
|
+
+ `{ ${INCLUDE_KEYS.map((key) => `${key}?`).join(', ')} }, got ${describeValue(spec)}`);
|
|
144
|
+
}
|
|
145
|
+
for (const key of Object.keys(spec)) {
|
|
146
|
+
if (!INCLUDE_KEYS.includes(key)) {
|
|
147
|
+
throw new LinqBuildError('JL0101',
|
|
148
|
+
`the include spec at ${at} does not take '${key}' — the members are ${INCLUDE_KEYS.join(', ')}`
|
|
149
|
+
+ (key === 'after' ? '; a keyset cursor paginates the root: after() on the graph' : ''));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const out = {};
|
|
153
|
+
if (spec.where !== undefined) out.where = captureOver(spec.where, `${at} where`);
|
|
154
|
+
if (spec.orderBy !== undefined) out.orderBy = lowerOrder(spec.orderBy, `${at} orderBy`);
|
|
155
|
+
for (const key of ['take', 'skip']) {
|
|
156
|
+
if (spec[key] !== undefined) out[key] = requireJson(spec[key], `${at} ${key}`);
|
|
157
|
+
}
|
|
158
|
+
if (spec.count !== undefined) {
|
|
159
|
+
if (spec.count !== true) throw new LinqBuildError('JL0101', `the include spec at ${at}: count takes true`);
|
|
160
|
+
out.count = true;
|
|
161
|
+
}
|
|
162
|
+
for (const key of ['maxRows', 'maxBytes']) {
|
|
163
|
+
// the per-root bound (MODEL-FORMAT §10.4); `Infinity` is the spelled
|
|
164
|
+
// unbounded case, and its JSON form is `null`
|
|
165
|
+
if (spec[key] === undefined) continue;
|
|
166
|
+
out[key] = spec[key] === Infinity ? null : requireJson(spec[key], `${at} ${key}`);
|
|
167
|
+
}
|
|
168
|
+
if (spec.include !== undefined) out.include = lowerIncludes(spec.include, entityName, relationsOf, path);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A nested include record — member → `true` | spec — over the target's
|
|
174
|
+
* relation table.
|
|
175
|
+
* @param {any} includes
|
|
176
|
+
* @param {string} entityName
|
|
177
|
+
* @param {(name: string) => Record<string, any> | undefined} relationsOf
|
|
178
|
+
* @param {string[]} path
|
|
179
|
+
* @returns {Record<string, any>}
|
|
180
|
+
*/
|
|
181
|
+
function lowerIncludes(includes, entityName, relationsOf, path) {
|
|
182
|
+
if (includes === null || typeof includes !== 'object' || Array.isArray(includes)) {
|
|
183
|
+
throw new LinqBuildError('JL0101',
|
|
184
|
+
`include at ${path.join('.')} is a record of relation members, got ${describeValue(includes)}`);
|
|
185
|
+
}
|
|
186
|
+
const relations = relationsOf(entityName) ?? {};
|
|
187
|
+
const out = {};
|
|
188
|
+
for (const member of Object.keys(includes)) {
|
|
189
|
+
const { entry } = requireRelation(relations, entityName, member, 'a nested include');
|
|
190
|
+
setObjectMember(out, member, lowerInclude(includes[member], entry.to, relationsOf, [...path, member]));
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The graph over one entity set: an immutable builder of a `load` spec. */
|
|
196
|
+
export class Graph {
|
|
197
|
+
#set;
|
|
198
|
+
#entity;
|
|
199
|
+
#spec;
|
|
200
|
+
#tracking;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {any} set - the store's entity set
|
|
204
|
+
* @param {string} entity - its name
|
|
205
|
+
* @param {Record<string, any>} [spec] - the spec so far (`orderBy` as a list)
|
|
206
|
+
* @param {boolean} [tracking] - whether `toArray()` registers snapshots
|
|
207
|
+
*/
|
|
208
|
+
constructor(set, entity, spec = {}, tracking = true) {
|
|
209
|
+
this.#set = set;
|
|
210
|
+
this.#entity = entity;
|
|
211
|
+
this.#spec = spec;
|
|
212
|
+
this.#tracking = tracking;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** @param {Record<string, any>} patch */
|
|
216
|
+
#with(patch) {
|
|
217
|
+
return new Graph(this.#set, this.#entity, { ...this.#spec, ...patch }, this.#tracking);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The other roots' tables, where a nested include finds its target's. */
|
|
221
|
+
#relationsOf() {
|
|
222
|
+
const scoped = this.#set.scope?.relations;
|
|
223
|
+
return (name) => (scoped !== null && typeof scoped === 'object' ? scoped[name] : undefined);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Include one relation member, with an optional spec over its rows.
|
|
228
|
+
* @param {(u: any) => any} pick - `(u) => u.posts`
|
|
229
|
+
* @param {any} [spec] - `{ where?, orderBy?, take?, skip?, after?, count?, include? }`
|
|
230
|
+
* @returns {Graph}
|
|
231
|
+
*/
|
|
232
|
+
include(pick, spec) {
|
|
233
|
+
const { member, entry } = pickMember(pick, this.#set.relations, this.#entity);
|
|
234
|
+
const lowered = lowerInclude(spec, entry.to, this.#relationsOf(), [member]);
|
|
235
|
+
const include = { ...(this.#spec.include ?? {}) };
|
|
236
|
+
setObjectMember(include, member, lowered);
|
|
237
|
+
return this.#with({ include });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Filter the root rows; consecutive calls conjoin. @param {(it: any) => any} predicate */
|
|
241
|
+
where(predicate) {
|
|
242
|
+
const expression = captureOver(predicate, 'where()');
|
|
243
|
+
const previous = this.#spec.where;
|
|
244
|
+
if (previous === undefined) return this.#with({ where: expression });
|
|
245
|
+
const conjoined = previous !== null && typeof previous === 'object'
|
|
246
|
+
&& Object.keys(previous).length === 1 && Array.isArray(previous.$and)
|
|
247
|
+
? [...previous.$and, expression] : [previous, expression];
|
|
248
|
+
return this.#with({ where: { $and: conjoined } });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** @param {(it: any) => any} key @param {{ empty?: string, collation?: string }} [options] */
|
|
252
|
+
orderBy(key, options) {
|
|
253
|
+
return this.#with({ orderBy: [orderTerm(key, false, options, 'orderBy()')] });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** @param {(it: any) => any} key @param {{ empty?: string, collation?: string }} [options] */
|
|
257
|
+
orderByDescending(key, options) {
|
|
258
|
+
return this.#with({ orderBy: [orderTerm(key, true, options, 'orderByDescending()')] });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** @param {(it: any) => any} key @param {{ empty?: string, collation?: string }} [options] */
|
|
262
|
+
thenBy(key, options) {
|
|
263
|
+
return this.#then(orderTerm(key, false, options, 'thenBy()'), 'thenBy');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** @param {(it: any) => any} key @param {{ empty?: string, collation?: string }} [options] */
|
|
267
|
+
thenByDescending(key, options) {
|
|
268
|
+
return this.#then(orderTerm(key, true, options, 'thenByDescending()'), 'thenByDescending');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** @param {any} term @param {string} what */
|
|
272
|
+
#then(term, what) {
|
|
273
|
+
if (this.#spec.orderBy === undefined) {
|
|
274
|
+
throw new LinqBuildError('JL0005',
|
|
275
|
+
`${what}() extends an orderBy()/orderByDescending() — none precedes it`);
|
|
276
|
+
}
|
|
277
|
+
return this.#with({ orderBy: [...this.#spec.orderBy, term] });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** @param {number} count */
|
|
281
|
+
take(count) { return this.#with({ take: requireJson(count, 'take()') }); }
|
|
282
|
+
|
|
283
|
+
/** @param {number} count */
|
|
284
|
+
skip(count) { return this.#with({ skip: requireJson(count, 'skip()') }); }
|
|
285
|
+
|
|
286
|
+
/** The keyset continuation (§10.5): the `{ order, keys, key }` value a
|
|
287
|
+
* page emitted — a bare unique-column value is the single-column form.
|
|
288
|
+
* @param {any} cursor */
|
|
289
|
+
after(cursor) { return this.#with({ after: requireJson(cursor, 'after()') }); }
|
|
290
|
+
|
|
291
|
+
/** The include depth bound (§10.4). @param {number} depth */
|
|
292
|
+
maxDepth(depth) { return this.#with({ maxDepth: requireJson(depth, 'maxDepth()') }); }
|
|
293
|
+
|
|
294
|
+
/** The same graph, loaded without registering snapshots. */
|
|
295
|
+
asNoTracking() {
|
|
296
|
+
return new Graph(this.#set, this.#entity, this.#spec, false);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The document, as every pen answers it: the emitted spec. */
|
|
300
|
+
toJSON() {
|
|
301
|
+
return this.toSpec();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The emitted `load` spec: plain frozen JSON, a snapshot. */
|
|
305
|
+
toSpec() {
|
|
306
|
+
const out = {};
|
|
307
|
+
for (const key of ROOT_KEYS) {
|
|
308
|
+
const value = this.#spec[key];
|
|
309
|
+
if (value === undefined) continue;
|
|
310
|
+
out[key] = key === 'orderBy' && value.length === 1 ? value[0] : value;
|
|
311
|
+
}
|
|
312
|
+
return deepFreeze(structuredClone(out));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** `load(spec)` — the store's one statement. */
|
|
316
|
+
toArray() {
|
|
317
|
+
const spec = this.toSpec();
|
|
318
|
+
return this.#tracking ? this.#set.load(spec) : this.#set.asNoTracking().load(spec);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* `loadCursor(spec, options)` — the store's graph cursor: one root
|
|
323
|
+
* graph per pull, its includes attached and bounded, from the same one
|
|
324
|
+
* statement; `return()` releases it. Untracked unless `tracking: true`
|
|
325
|
+
* is spelled per call — a snapshot per yielded root is a unit of work
|
|
326
|
+
* that grows with the result.
|
|
327
|
+
* @param {{ signal?: AbortSignal, tracking?: boolean }} [options]
|
|
328
|
+
*/
|
|
329
|
+
cursor(options) {
|
|
330
|
+
return this.#set.loadCursor(this.toSpec(), options);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* `page(spec, options)` — the store's bounded page over the composite
|
|
335
|
+
* keyset (§10.5): `{ items, continuation, hasMore, snapshot }`, never
|
|
336
|
+
* more than `limit` roots or `maxBytes` serialised bytes, the
|
|
337
|
+
* continuation unsigned and structural. Untracked unless `tracking:
|
|
338
|
+
* true`.
|
|
339
|
+
* @param {{ limit?: number, after?: any, maxBytes?: number,
|
|
340
|
+
* consistency?: 'live' | 'snapshot', signal?: AbortSignal,
|
|
341
|
+
* tracking?: boolean }} [options]
|
|
342
|
+
*/
|
|
343
|
+
page(options) {
|
|
344
|
+
return this.#set.page(this.toSpec(), options);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** `explainLoad(spec)` — the SQL, the includes, the pagination strategy. */
|
|
348
|
+
explain() {
|
|
349
|
+
return this.#set.explainLoad(this.toSpec());
|
|
350
|
+
}
|
|
351
|
+
}
|
package/src/db/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `@jarenjs/linq/db` — the store's front door. `open(model,
|
|
4
|
+
* options)` returns a client typed from the model pen whose entity
|
|
5
|
+
* handles chain (`client.entities.Post.where(…)` is `fromAsync(handle)
|
|
6
|
+
* .where(…)`, so every read is the chain's document and pushes down),
|
|
7
|
+
* load graphs from an EMITTED `load` spec (`include`), track and save
|
|
8
|
+
* changes, link and unlink many-to-many members and maintain live
|
|
9
|
+
* results — over exactly one runtime edge: this subpath imports
|
|
10
|
+
* `@jarenjs/db`, `@jarenjs/validate` and `@jarenjs/formats`, declared as
|
|
11
|
+
* optional peers; no other subpath of the package does, and the store
|
|
12
|
+
* never imports this package (the edge suite in the tests holds both).
|
|
13
|
+
* The store stays the engine: the client adds no storage semantics and
|
|
14
|
+
* duplicates no algorithm — `include` emits the spec the store runs,
|
|
15
|
+
* membership is the store's own `link`/`unlink`, `live` is the store's
|
|
16
|
+
* registration — and every read is one an `explain()` can name.
|
|
17
|
+
* `createDbLedger` is the contract ledger over a declared collection of
|
|
18
|
+
* the client's store, through that same surface: the durable idempotency
|
|
19
|
+
* ledger a host was left to write, with no edge from `@jarenjs/contract`
|
|
20
|
+
* to a store (DB-CLIENT.md §2.6).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export { open, defaultValidator } from './open.js';
|
|
24
|
+
export { createDbLedger } from './ledger.js';
|
package/src/db/ledger.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `createDbLedger(client, options)`: the contract ledger
|
|
4
|
+
* (`@jarenjs/contract` docs/CONTRACT-FORMAT.md §8) over a declared
|
|
5
|
+
* collection of the client's store — every claim state, the persisted
|
|
6
|
+
* generation fence, expiry, `sweep` — through the client's collection
|
|
7
|
+
* and transaction surface alone. Nothing here imports the contract
|
|
8
|
+
* package or a driver: the ledger SHAPE is structural (the
|
|
9
|
+
* `claim`/`commit`/`fail`/`lookup` the http binding calls), the record
|
|
10
|
+
* is exactly `idempotencyLedgerModel`'s, and the id is the same
|
|
11
|
+
* versioned JSON tuple the memory ledger spells. A root client claims
|
|
12
|
+
* inside `transaction(…, { mode: 'immediate' })` — one writer, so two
|
|
13
|
+
* processes never both hold `new`; a transaction client claims and
|
|
14
|
+
* settles inside the transaction it was handed, which is the ledger a
|
|
15
|
+
* lifecycle settlement lease carries: a domain write and the settlement
|
|
16
|
+
* then commit together or not at all.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { resolveRuntime } from '@jarenjs/core/runtime';
|
|
20
|
+
|
|
21
|
+
import { LinqRuntimeError } from '../errors.js';
|
|
22
|
+
|
|
23
|
+
/** One day, the default retention of a key — the memory ledger's. */
|
|
24
|
+
const DEFAULT_TTL_MS = 86_400_000;
|
|
25
|
+
|
|
26
|
+
/** The collection `idempotencyLedgerModel` declares. */
|
|
27
|
+
const DEFAULT_COLLECTION = 'ledger';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The id of one `(op, scope, key)` tuple — the version `1`, a colon, the
|
|
31
|
+
* JSON array of the three: the spelling `@jarenjs/contract/ledger`'s
|
|
32
|
+
* `ledgerId` writes, so a store's records and a memory ledger's agree.
|
|
33
|
+
* A record written under the legacy `"<op>|<scope>|<key>"` spelling is
|
|
34
|
+
* matched by no claim again; it expires by its own `expiresAt` (`sweep`)
|
|
35
|
+
* or a host rewrites it once (DB-CLIENT.md §2.6).
|
|
36
|
+
* @param {string} op
|
|
37
|
+
* @param {string} scope
|
|
38
|
+
* @param {string} key
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function ledgerId(op, scope, key) {
|
|
42
|
+
return `1:${JSON.stringify([op, scope, key])}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {Object} DbLedgerOptions
|
|
47
|
+
* @property {string} [collection] - the declared collection, `'ledger'` by default
|
|
48
|
+
* @property {number} [ttlMs] - the retention of a key, 86,400,000 ms by default
|
|
49
|
+
* @property {Partial<import('@jarenjs/core/runtime').Runtime>} [runtime] -
|
|
50
|
+
* the host's runtime record: its `now` is the clock, its `uuid` mints
|
|
51
|
+
* every generation
|
|
52
|
+
* @property {() => number} [now] - the clock; wins over the runtime's
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {unknown} ref
|
|
57
|
+
* @param {string} reason
|
|
58
|
+
* @returns {LinqRuntimeError}
|
|
59
|
+
*/
|
|
60
|
+
function stale(ref, reason) {
|
|
61
|
+
const r = /** @type {any} */ (ref);
|
|
62
|
+
const named = r !== null && typeof r === 'object' && typeof r.id === 'string' ? r.id : 'a ref this ledger did not issue';
|
|
63
|
+
return new LinqRuntimeError('JL2007', `${named} settles no started record: ${reason}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The durable ledger over a client's declared collection.
|
|
68
|
+
* @param {any} client - a `@jarenjs/linq/db` client: the root one, or
|
|
69
|
+
* the one a transaction callback received
|
|
70
|
+
* @param {DbLedgerOptions} [options]
|
|
71
|
+
* @returns {{ claim: (claim: { op: string, scope: string, key: string, hash: string, now?: number }) => Promise<any>,
|
|
72
|
+
* commit: (ref: unknown, response: unknown, now?: number) => Promise<void>,
|
|
73
|
+
* fail: (ref: unknown, retryable: boolean, response?: unknown, now?: number) => Promise<void>,
|
|
74
|
+
* lookup: (key: { op: string, scope: string, key: string, now?: number }) => Promise<any>,
|
|
75
|
+
* sweep: (now?: number) => Promise<number> }}
|
|
76
|
+
*/
|
|
77
|
+
export function createDbLedger(client, options = {}) {
|
|
78
|
+
if (client === null || typeof client !== 'object' || client.collections === null
|
|
79
|
+
|| typeof client.collections !== 'object' || typeof client.transaction !== 'function') {
|
|
80
|
+
throw new TypeError('createDbLedger: client must be a @jarenjs/linq/db client — the root client, or the one a transaction callback received');
|
|
81
|
+
}
|
|
82
|
+
const name = options.collection === undefined ? DEFAULT_COLLECTION : options.collection;
|
|
83
|
+
if (typeof name !== 'string' || name === '') throw new TypeError('createDbLedger: collection must be a non-empty collection name');
|
|
84
|
+
if (!Object.hasOwn(client.collections, name)) {
|
|
85
|
+
throw new TypeError(`createDbLedger: the client declares no collection '${name}' — open the store with idempotencyLedgerModel, or a model that declares it`);
|
|
86
|
+
}
|
|
87
|
+
const ttlMs = options.ttlMs === undefined ? DEFAULT_TTL_MS : options.ttlMs;
|
|
88
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new TypeError('createDbLedger: ttlMs must be a positive number');
|
|
89
|
+
let runtime;
|
|
90
|
+
try {
|
|
91
|
+
runtime = resolveRuntime(options.runtime);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw new TypeError(`createDbLedger: runtime: ${error instanceof Error ? error.message : String(error)}`);
|
|
95
|
+
}
|
|
96
|
+
const clock = options.now === undefined ? runtime.now : options.now;
|
|
97
|
+
if (typeof clock !== 'function') throw new TypeError('createDbLedger: now must be a function');
|
|
98
|
+
// the memory ledger's clock discipline: one clock judges a record from
|
|
99
|
+
// claim to expiry — its own when given, else the binding's instants
|
|
100
|
+
const ownClock = options.now !== undefined || options.runtime !== undefined;
|
|
101
|
+
/** @type {number | null} */
|
|
102
|
+
let latest = null;
|
|
103
|
+
/** @param {number | undefined} given */
|
|
104
|
+
const instant = (given) => {
|
|
105
|
+
if (typeof given === 'number') {
|
|
106
|
+
if (!ownClock) latest = latest === null ? given : Math.max(latest, given);
|
|
107
|
+
return given;
|
|
108
|
+
}
|
|
109
|
+
return ownClock || latest === null ? clock() : latest;
|
|
110
|
+
};
|
|
111
|
+
// a root client owns a connection and takes the write lock up front; a
|
|
112
|
+
// transaction client is inside one already and nests a savepoint, so
|
|
113
|
+
// the settlement commits with the host's own writes
|
|
114
|
+
const root = typeof client.close === 'function';
|
|
115
|
+
/** @param {(tx: any) => Promise<any>} fn */
|
|
116
|
+
const inside = (fn) => (root ? client.transaction(fn, { mode: 'immediate' }) : client.transaction(fn));
|
|
117
|
+
/** @param {any} tx */
|
|
118
|
+
const rows = (tx) => tx.collections[name];
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Settle the started record a ref names, or refuse (`JL2007`).
|
|
122
|
+
* @param {unknown} ref
|
|
123
|
+
* @param {{ status: 'committed' | 'failed', response: unknown, retryable: boolean | null }} changes
|
|
124
|
+
* @param {number | undefined} now
|
|
125
|
+
*/
|
|
126
|
+
function settle(ref, changes, now) {
|
|
127
|
+
const r = /** @type {any} */ (ref);
|
|
128
|
+
if (r === null || typeof r !== 'object' || typeof r.id !== 'string' || typeof r.generation !== 'string') {
|
|
129
|
+
return Promise.reject(stale(ref, 'a ref is { id, generation } as this ledger issued it'));
|
|
130
|
+
}
|
|
131
|
+
const at = instant(now);
|
|
132
|
+
return inside(async (tx) => {
|
|
133
|
+
const c = rows(tx);
|
|
134
|
+
const record = await c.get(r.id);
|
|
135
|
+
if (record === undefined || record.generation !== r.generation || record.status !== 'started') {
|
|
136
|
+
throw stale(ref, 'the key expired, was reclaimed under a newer generation, or was settled already');
|
|
137
|
+
}
|
|
138
|
+
await c.put({ ...record, ...changes, updatedAt: at }, r.id);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return Object.freeze({
|
|
143
|
+
claim({ op, scope, key, hash, now }) {
|
|
144
|
+
const at = instant(now);
|
|
145
|
+
const id = ledgerId(op, scope, key);
|
|
146
|
+
return inside(async (tx) => {
|
|
147
|
+
const c = rows(tx);
|
|
148
|
+
const existing = await c.get(id);
|
|
149
|
+
if (existing !== undefined) {
|
|
150
|
+
if (existing.expiresAt <= at) await c.delete(id);
|
|
151
|
+
else if (existing.hash !== hash) return { state: 'mismatch' };
|
|
152
|
+
else if (existing.status === 'started') return { state: 'in-progress' };
|
|
153
|
+
else if (existing.status === 'committed') return { state: 'replay', response: existing.response };
|
|
154
|
+
else if (existing.retryable !== true && existing.response !== null) return { state: 'replay', response: existing.response };
|
|
155
|
+
else await c.delete(id); // a retryable failure: the key runs again
|
|
156
|
+
}
|
|
157
|
+
const generation = runtime.uuid();
|
|
158
|
+
await c.insert({
|
|
159
|
+
id, generation, op, scope, key, hash, status: 'started', response: null, retryable: null,
|
|
160
|
+
createdAt: at, updatedAt: at, expiresAt: at + ttlMs,
|
|
161
|
+
});
|
|
162
|
+
return { state: 'new', ref: Object.freeze({ id, generation }) };
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
commit(ref, response, now = undefined) {
|
|
166
|
+
return settle(ref, { status: 'committed', response, retryable: null }, now);
|
|
167
|
+
},
|
|
168
|
+
fail(ref, retryable, response = undefined, now = undefined) {
|
|
169
|
+
return settle(ref, { status: 'failed', response: response === undefined ? null : response, retryable: retryable === true }, now);
|
|
170
|
+
},
|
|
171
|
+
lookup({ op, scope, key, now = undefined }) {
|
|
172
|
+
const at = instant(now);
|
|
173
|
+
const id = ledgerId(op, scope, key);
|
|
174
|
+
return inside(async (tx) => {
|
|
175
|
+
const c = rows(tx);
|
|
176
|
+
const record = await c.get(id);
|
|
177
|
+
if (record === undefined) return null;
|
|
178
|
+
if (record.expiresAt <= at) {
|
|
179
|
+
await c.delete(id);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return record; // the store's fresh document: nothing of the ledger's own
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
sweep(now = undefined) {
|
|
186
|
+
const at = instant(now);
|
|
187
|
+
return inside(async (tx) => {
|
|
188
|
+
const c = rows(tx);
|
|
189
|
+
const expired = await c.where((/** @type {any} */ r) => r.expiresAt.le(at)).select((/** @type {any} */ r) => r.id).toArray();
|
|
190
|
+
for (const id of expired) await c.delete(id);
|
|
191
|
+
return expired.length;
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|