@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.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +227 -0
  2. package/README.md +650 -17
  3. package/docs/APP-PEN.md +1143 -0
  4. package/docs/CONTRACT-PEN.md +1221 -0
  5. package/docs/DB-CLIENT.md +882 -0
  6. package/docs/FLOW-PEN.md +1033 -0
  7. package/docs/FORMS-PEN.md +940 -0
  8. package/docs/JSLT-PEN.md +955 -0
  9. package/docs/LINQ-FORMAT.md +778 -383
  10. package/docs/MIGRATION-PEN.md +781 -0
  11. package/docs/MODEL-PEN.md +1092 -0
  12. package/docs/QUERY-PEN.md +1724 -0
  13. package/docs/SCHEMA-PEN.md +1218 -0
  14. package/package.json +57 -4
  15. package/src/app/action.js +251 -0
  16. package/src/app/capture.js +63 -0
  17. package/src/app/define.js +255 -0
  18. package/src/app/index.js +20 -0
  19. package/src/app/patch.js +277 -0
  20. package/src/app/sub.js +106 -0
  21. package/src/async.js +377 -75
  22. package/src/capture-root.js +82 -0
  23. package/src/concurrency.js +48 -11
  24. package/src/contract/define.js +282 -0
  25. package/src/contract/http.js +247 -0
  26. package/src/contract/index.js +23 -0
  27. package/src/contract/operation.js +338 -0
  28. package/src/db/handle.js +89 -0
  29. package/src/db/include.js +351 -0
  30. package/src/db/index.js +24 -0
  31. package/src/db/ledger.js +195 -0
  32. package/src/db/live.js +43 -0
  33. package/src/db/membership.js +37 -0
  34. package/src/db/open.js +130 -0
  35. package/src/document.js +143 -13
  36. package/src/effect.js +65 -0
  37. package/src/errors.js +78 -6
  38. package/src/expression.js +463 -36
  39. package/src/federate.js +531 -0
  40. package/src/flow/capture.js +33 -0
  41. package/src/flow/dag.js +316 -0
  42. package/src/flow/fsm.js +323 -0
  43. package/src/flow/index.js +22 -0
  44. package/src/forms/index.js +43 -0
  45. package/src/forms/rules.js +170 -0
  46. package/src/forms/submit.js +177 -0
  47. package/src/index.js +5 -2
  48. package/src/jslt/body.js +226 -0
  49. package/src/jslt/index.js +18 -0
  50. package/src/jslt/rules.js +202 -0
  51. package/src/json-boundary.js +90 -0
  52. package/src/migration/define.js +318 -0
  53. package/src/migration/index.js +15 -0
  54. package/src/migration/steps.js +244 -0
  55. package/src/model/collection.js +273 -0
  56. package/src/model/define.js +125 -0
  57. package/src/model/entity.js +307 -0
  58. package/src/model/index.js +47 -0
  59. package/src/model/relation.js +85 -0
  60. package/src/provider.js +137 -20
  61. package/src/schema/brand.js +31 -0
  62. package/src/schema/builders.js +526 -0
  63. package/src/schema/check.js +29 -0
  64. package/src/schema/emit.js +394 -0
  65. package/src/schema/factories.js +239 -0
  66. package/src/schema/index.js +37 -0
  67. package/src/schema-of.js +24 -0
  68. package/src/sequence.js +233 -103
  69. package/src/sources.js +10 -3
  70. package/types/app.d.ts +293 -0
  71. package/types/contract.d.ts +468 -0
  72. package/types/db.d.ts +359 -0
  73. package/types/flow.d.ts +285 -0
  74. package/types/forms.d.ts +253 -0
  75. package/types/index.d.ts +296 -26
  76. package/types/jslt.d.ts +193 -0
  77. package/types/migration.d.ts +201 -0
  78. package/types/model.d.ts +526 -0
  79. package/types/schema.d.ts +494 -0
@@ -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
+ }
@@ -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';
@@ -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
+ }