@jarenjs/linq 0.56.0 → 0.67.0

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/src/async.js CHANGED
@@ -26,10 +26,13 @@
26
26
  * refuses (`JL0005`) and `explain()` reports the split.
27
27
  * - a PROVIDER origin (`fromAsync(store.entity('Post'))`) runs nothing
28
28
  * here: everything up to the first `mapAsync` is ONE document the
29
- * provider executes whole the terminal's wrapper included, exactly
30
- * as the synchronous surface pushes it — and `execute` may answer a
31
- * promise (D8); the residual after the split streams locally, and a
32
- * `join` exists on this surface only inside that pushed document.
29
+ * provider executes — a terminal's wrapper included, exactly as the
30
+ * synchronous surface pushes it, `execute` answering a promise if it
31
+ * must (D8). Iteration hands that document to the provider's CURSOR
32
+ * when it offers one, so a `for await` pulls one row at a time from
33
+ * an open statement and a `break` releases it; the residual after
34
+ * the split streams locally, and a `join` exists on this surface
35
+ * only inside that pushed document.
33
36
  *
34
37
  * Early termination CLOSES the source: every consumer is a
35
38
  * `for await … break` chain, and async generators propagate `return()`
@@ -437,10 +440,11 @@ export class AsyncSequence {
437
440
  }
438
441
 
439
442
  /** Barriers, the split, the relation hops the callbacks navigated,
440
- * and — when representable — the document. Stages are named by the
441
- * OPERATOR the caller wrote (`selectMany`, `orderByDescending`), and a
442
- * `thenBy` is part of the `$orderby` barrier it extends, not a barrier
443
- * of its own. */
443
+ * and — when representable — the document; plus `streaming` and
444
+ * `barrier`, what this surface's own execution does. Stages are named
445
+ * by the OPERATOR the caller wrote (`selectMany`, `orderByDescending`),
446
+ * and a `thenBy` is part of the `$orderby` barrier it extends, not a
447
+ * barrier of its own. */
444
448
  explain() {
445
449
  const firstMap = this.#stages.findIndex((s) => s.kind === 'mapAsync');
446
450
  // over a provider nothing before the split materialises HERE — the
@@ -455,7 +459,19 @@ export class AsyncSequence {
455
459
  barriers.push({ operator: stage.name ?? stage.kind, reason: BARRIERS[stage.kind] });
456
460
  }
457
461
  }
458
- const out = { barriers, hops: this.#hops(), bindings: this.#externals().values };
462
+ // what THIS surface does with the item stream: one item at a time,
463
+ // or a buffer at the first local barrier. Over a provider the pushed
464
+ // document's own class — a set residual, an unbindable external — is
465
+ // the provider's to report: `explain(document, { externals: bindings })`
466
+ // on the source answers it, and its cursor carries the same answer
467
+ const first = barriers[0];
468
+ const out = {
469
+ barriers,
470
+ streaming: first === undefined ? 'row' : 'buffered',
471
+ barrier: first === undefined ? null : { construct: first.operator, reason: first.reason },
472
+ hops: this.#hops(),
473
+ bindings: this.#externals().values,
474
+ };
459
475
  if (firstMap < 0) {
460
476
  out.document = this.toDocument();
461
477
  }
@@ -487,7 +503,7 @@ export class AsyncSequence {
487
503
  // runs whole; the residual continues locally over its rows
488
504
  const firstMap = stages.findIndex((s) => s.kind === 'mapAsync');
489
505
  i = firstMap < 0 ? stages.length : firstMap;
490
- stream = arrayStream(await this.#pushWindow('toArray', undefined, stages.slice(0, i)));
506
+ stream = this.#providerStream(stages.slice(0, i), values);
491
507
  }
492
508
  else {
493
509
  stream = this.#origin.iterate();
@@ -522,6 +538,33 @@ export class AsyncSequence {
522
538
  yield* iterateAndClose(stream);
523
539
  }
524
540
 
541
+ /**
542
+ * The pushed window as a STREAM. A provider that offers the cursor
543
+ * protocol (`cursor(document, options)` — the store's entity sets and
544
+ * collections do) is handed the unwrapped document and answers one
545
+ * item per pull from an open statement; the generator's `for await`
546
+ * releases that statement on break, throw and exhaustion alike,
547
+ * exactly once. A provider without one keeps the element window it
548
+ * always had: the `toArray`-wrapped document run whole, its array
549
+ * iterated — a materialisation, which is what such a provider can do.
550
+ * @param {readonly any[]} stages - the stages up to the split
551
+ * @param {Record<string, any>} values - the bound externals
552
+ * @returns {AsyncIterator<any>}
553
+ */
554
+ #providerStream(stages, values) {
555
+ const source = this.#origin.source;
556
+ if (typeof source.cursor === 'function') {
557
+ return source.cursor(this.#documentOf(stages), { externals: values });
558
+ }
559
+ return this.#materializedWindow(stages);
560
+ }
561
+
562
+ /** The legacy element window, iterated: one `toArray` push, run whole.
563
+ * @param {readonly any[]} stages */
564
+ async* #materializedWindow(stages) {
565
+ yield* await this.#pushWindow('toArray', undefined, stages);
566
+ }
567
+
525
568
  /** @param {AsyncIterator<any>} stream @param {any} stage @param {any} values */
526
569
  #applyStreamStage(stream, stage, values) {
527
570
  const self = this;
@@ -614,7 +657,12 @@ export class AsyncSequence {
614
657
  }
615
658
  }
616
659
 
660
+ /** The whole result as one array. Over a provider with no `mapAsync`
661
+ * this is ONE pushed window — the terminal asked for the array, so the
662
+ * store answers it in one statement rather than one row at a time;
663
+ * everywhere else it drains the item stream. */
617
664
  async toArray() {
665
+ if (this.#pushable()) return this.#pushWindow('toArray');
618
666
  return collect(this[Symbol.asyncIterator]());
619
667
  }
620
668
 
@@ -54,6 +54,18 @@ export async function* applyMapAsync(items, fn, opts) {
54
54
  /** The pipeline's own failure, so source cleanup cannot displace it.
55
55
  * @type {{ reason: any } | null} */
56
56
  let failure = null;
57
+ /** A callback failure may close while a pull is pending; finally awaits
58
+ * that same close, so cancellation reaches the producer exactly once.
59
+ * @type {Promise<any> | null} */
60
+ let closing = null;
61
+ const closeSource = () => {
62
+ if (closing === null) {
63
+ closing = Promise.resolve().then(() => typeof items.return === 'function'
64
+ ? items.return(undefined) : undefined);
65
+ closing.catch(() => {}); // the finally block reports cleanup failures
66
+ }
67
+ return closing;
68
+ };
57
69
 
58
70
  try {
59
71
  if (opts.mode === 'concat' || (opts.mode === 'parallel' && opts.concurrency === 1)) {
@@ -122,14 +134,29 @@ export async function* applyMapAsync(items, fn, opts) {
122
134
  // and stops the pull, at once — not when it reaches the head of the
123
135
  // ordered window: the rejection itself still surfaces in order
124
136
  let rejected = false;
137
+ let stopPull;
138
+ /** @type {Promise<IteratorResult<any>>} */
139
+ const stoppedPull = new Promise((resolve) => {
140
+ stopPull = () => resolve({ done: true, value: undefined });
141
+ });
142
+ const taskFailed = () => {
143
+ if (rejected) return;
144
+ rejected = true;
145
+ stopPull();
146
+ controller.abort();
147
+ closeSource();
148
+ };
149
+ // An idle producer must not hide a callback's failure. The race also
150
+ // observes a pending next() that rejects after cancellation won.
151
+ const next = () => Promise.race([items.next(), stoppedPull]);
125
152
  const pull = async () => {
126
- const step = await items.next();
153
+ const step = await next();
127
154
  if (step.done) { sourceDone = true; return null; }
128
155
  if (rejected) return null; // a sibling failed while this pull awaited
129
156
  const promise = Promise.resolve(fn(step.value, signal));
130
157
  // a rejection must wait its turn in the ordered window without
131
158
  // firing unhandledRejection while an earlier task is in flight
132
- promise.catch(() => { rejected = true; controller.abort(); });
159
+ promise.catch(taskFailed);
133
160
  return { promise };
134
161
  };
135
162
  if (opts.ordered) {
@@ -153,19 +180,24 @@ export async function* applyMapAsync(items, fn, opts) {
153
180
  let nextId = 0;
154
181
  const inflight = new Map();
155
182
  const start = async () => {
156
- const step = await items.next();
183
+ const step = await next();
157
184
  if (step.done) { sourceDone = true; return; }
185
+ if (rejected) return; // a task failed while this source pull awaited
158
186
  const id = nextId++;
159
- inflight.set(id, Promise.resolve(fn(step.value, signal)).then(
187
+ const promise = Promise.resolve(fn(step.value, signal)).then(
160
188
  (value) => ({ id, value }),
161
189
  // the task's identity rides in an internal ENVELOPE, never on the
162
190
  // rejection value. Stamping the value mutated whatever the handler
163
191
  // threw: a frozen error became a different TypeError, a thrown
164
192
  // string came back boxed, an ordinary error grew a private
165
193
  // property, and a hostile proxy could break normalization outright.
166
- (error) => { throw new TaskFailure(id, error); }));
194
+ (error) => { throw new TaskFailure(id, error); });
195
+ // Observe failures immediately, including while the next source
196
+ // pull is pending or downstream has stopped consuming the window.
197
+ promise.catch(taskFailed);
198
+ inflight.set(id, promise);
167
199
  };
168
- while (!sourceDone && inflight.size < opts.concurrency) await start();
200
+ while (!sourceDone && !rejected && inflight.size < opts.concurrency) await start();
169
201
  while (inflight.size > 0) {
170
202
  let settled;
171
203
  try {
@@ -179,7 +211,7 @@ export async function* applyMapAsync(items, fn, opts) {
179
211
  throw err;
180
212
  }
181
213
  inflight.delete(settled.id);
182
- if (!sourceDone) await start();
214
+ if (!sourceDone && !rejected) await start();
183
215
  yield settled.value;
184
216
  }
185
217
  }
@@ -196,7 +228,7 @@ export async function* applyMapAsync(items, fn, opts) {
196
228
  // itself succeeded. Composed as a rejection the `await` adopts,
197
229
  // because a `throw` here would be the very substitution this
198
230
  // avoids: it discards whatever completion the block was carrying.
199
- await Promise.resolve(items.return(undefined)).then(undefined,
231
+ await closeSource().then(undefined,
200
232
  (cleanupError) => Promise.reject(failure === null
201
233
  ? cleanupError
202
234
  : new AggregateError([failure.reason, cleanupError],
@@ -21,7 +21,7 @@
21
21
  * document means.
22
22
  */
23
23
 
24
- import { cloneJson, deepFreeze, setObjectMember } from '@jarenjs/core/object';
24
+ import { cloneJson, deepFreeze, setObjectMember, isJsonObject } from '@jarenjs/core/object';
25
25
 
26
26
  import { LinqBuildError } from '../errors.js';
27
27
  import { describeValue, requireJson, requireNameMap } from '../json-boundary.js';
@@ -36,11 +36,6 @@ const HEAD_MEMBERS = ['id', 'version', 'compat'];
36
36
  /** `[A-Za-z_][A-Za-z0-9_-]*` — a contract id (§2.1). */
37
37
  const ID = /^[A-Za-z_][A-Za-z0-9_-]*$/;
38
38
 
39
- /** @param {any} value */
40
- function isPlainObject(value) {
41
- return value !== null && typeof value === 'object' && !Array.isArray(value);
42
- }
43
-
44
39
  /**
45
40
  * One schema position — `input`, `output`, an error's `schema` — as the
46
41
  * document carries it: a builder emitted into the shared `$defs`
@@ -53,7 +48,7 @@ function isPlainObject(value) {
53
48
  function schemaAt(value, ctx, at) {
54
49
  if (isSchemaBuilder(value)) return emitInto(value, ctx, at);
55
50
  const json = requireJson(value, `the schema at ${at}`);
56
- if (typeof json !== 'boolean' && !isPlainObject(json)) {
51
+ if (typeof json !== 'boolean' && !isJsonObject(json)) {
57
52
  throw new LinqBuildError('JL0101',
58
53
  `a schema is an object, true or false — got ${describeValue(value)}`, at);
59
54
  }
@@ -78,7 +73,7 @@ function schemaAt(value, ctx, at) {
78
73
  */
79
74
  function declarationOf(declared, at) {
80
75
  if (isOperation(declared)) return declared;
81
- if (!isPlainObject(declared)) {
76
+ if (!isJsonObject(declared)) {
82
77
  throw new LinqBuildError('JL0101',
83
78
  `an operation is read(), command() or subscribe() — got ${describeValue(declared)}`, at);
84
79
  }
@@ -166,7 +161,7 @@ export class Contract {
166
161
  * compileContract(shop.document).ids; // ['catalog.load']
167
162
  */
168
163
  export function defineContract(meta, operations) {
169
- if (!isPlainObject(meta)) {
164
+ if (!isJsonObject(meta)) {
170
165
  throw new LinqBuildError('JL0101',
171
166
  `defineContract() takes ({ id?, version?, compat? }, operations), got `
172
167
  + `${describeValue(meta)} as its first argument`);
@@ -191,7 +186,7 @@ export function defineContract(meta, operations) {
191
186
  throw new LinqBuildError('JL0101',
192
187
  'defineContract() compat is an array of peer version strings', '/compat');
193
188
  }
194
- if (!isPlainObject(operations)) {
189
+ if (!isJsonObject(operations)) {
195
190
  throw new LinqBuildError('JL0101',
196
191
  `defineContract() operations is a plain object of id → operation, got `
197
192
  + `${describeValue(operations)}`, '/operations');
@@ -235,6 +230,24 @@ export function typedClient(client, contract) {
235
230
  return client;
236
231
  }
237
232
 
233
+ /**
234
+ * Bind an HTTP client to the contract that types it: `typedClient` plus
235
+ * `bytes` over the opaque operations. Identity at runtime — a local or
236
+ * port client carries no `bytes` and takes `typedClient`.
237
+ * @template C
238
+ * @template T
239
+ * @param {T} client - an `openHttpClient` client
240
+ * @param {C} contract - the pen's contract; the type argument only
241
+ * @returns {T}
242
+ * @example
243
+ * const api = typedHttpClient(openHttpClient(compileContract(shop.document), { baseUrl }), shop);
244
+ * const outcome = await api.bytes('image.bytes', { id: 3 }); // ok: { status, headers, media, body: ReadableStream }
245
+ */
246
+ export function typedHttpClient(client, contract) {
247
+ void contract;
248
+ return client;
249
+ }
250
+
238
251
  /**
239
252
  * Bind a handler table to the contract it serves. Identity at runtime:
240
253
  * the table is checked against the operation ids and each handler's
@@ -11,13 +11,13 @@
11
11
  * earlier than the compiler's `JC0008`), `error()` one entry of an
12
12
  * operation's `errors`.
13
13
  *
14
- * The three identity wrappers — `typedClient`, `typedHandlers`,
15
- * `typedTools` — carry the inferred `Operations` type onto a client, a
16
- * handler table and an AI toolbox without running the TypeScript
17
- * projection. The document is the deliverable; nothing here imports
14
+ * The identity wrappers — `typedClient`, `typedHttpClient`,
15
+ * `typedHandlers`, `typedTools` — carry the inferred `Operations` type
16
+ * onto a client (an HTTP one with its byte method), a handler table and
17
+ * an AI toolbox without running the TypeScript projection. The document is the deliverable; nothing here imports
18
18
  * `@jarenjs/contract` or an engine.
19
19
  */
20
20
 
21
- export { defineContract, typedClient, typedHandlers, typedTools, Contract } from './define.js';
21
+ export { defineContract, typedClient, typedHttpClient, typedHandlers, typedTools, Contract } from './define.js';
22
22
  export { read, command, subscribe, error } from './operation.js';
23
23
  export { http } from './http.js';
@@ -20,6 +20,7 @@
20
20
  * nothing.
21
21
  */
22
22
 
23
+ import { isJsonObject } from '@jarenjs/core/object';
23
24
  import { LinqBuildError } from '../errors.js';
24
25
  import { describeValue } from '../json-boundary.js';
25
26
 
@@ -57,11 +58,6 @@ export const POLICY_VALUES = Object.freeze({
57
58
  /** An error code: `^[a-z][a-z0-9-]*$` (§3's table). */
58
59
  const CODE = /^[a-z][a-z0-9-]*$/;
59
60
 
60
- /** @param {any} value */
61
- function isPlainObject(value) {
62
- return value !== null && typeof value === 'object' && !Array.isArray(value);
63
- }
64
-
65
61
  /**
66
62
  * A member set the pen knows, or `JL0101` naming the one it does not.
67
63
  * @param {any} spec
@@ -104,7 +100,7 @@ function policyMember(member, value, at) {
104
100
  return value;
105
101
  }
106
102
  if (member === 'limits') {
107
- if (!isPlainObject(value)) {
103
+ if (!isJsonObject(value)) {
108
104
  throw new LinqBuildError('JL0101', 'policy.limits is { maxBodyBytes }', at);
109
105
  }
110
106
  closedTo(value, ['maxBodyBytes'], 'policy.limits', at);
@@ -116,7 +112,7 @@ function policyMember(member, value, at) {
116
112
  return { maxBodyBytes: value.maxBodyBytes };
117
113
  }
118
114
  if (member === 'errors') {
119
- if (!isPlainObject(value)) {
115
+ if (!isJsonObject(value)) {
120
116
  throw new LinqBuildError('JL0101', 'policy.errors is { details }', at);
121
117
  }
122
118
  closedTo(value, ['details'], 'policy.errors', at);
@@ -128,7 +124,7 @@ function policyMember(member, value, at) {
128
124
  return { details: value.details };
129
125
  }
130
126
  if (member === 'retry') {
131
- if (!isPlainObject(value)) {
127
+ if (!isJsonObject(value)) {
132
128
  throw new LinqBuildError('JL0101', 'policy.retry is { max, on }', at);
133
129
  }
134
130
  closedTo(value, ['max', 'on'], 'policy.retry', at);
@@ -144,7 +140,7 @@ function policyMember(member, value, at) {
144
140
  return { max: value.max, on: value.on.slice() };
145
141
  }
146
142
  // stream
147
- if (!isPlainObject(value)) {
143
+ if (!isJsonObject(value)) {
148
144
  throw new LinqBuildError('JL0101', 'policy.stream is { resume?, heartbeatMs?, maxPatchBytes? }', at);
149
145
  }
150
146
  closedTo(value, ['resume', 'heartbeatMs', 'maxPatchBytes'], 'policy.stream', at);
@@ -184,7 +180,7 @@ function policyMember(member, value, at) {
184
180
  * @returns {any}
185
181
  */
186
182
  export function emitPolicy(policy, at) {
187
- if (!isPlainObject(policy)) {
183
+ if (!isJsonObject(policy)) {
188
184
  throw new LinqBuildError('JL0101',
189
185
  `policy is a plain object of the members CONTRACT-FORMAT §3.1 declares, got `
190
186
  + `${describeValue(policy)}`, at);
@@ -210,7 +206,7 @@ export function emitPolicy(policy, at) {
210
206
  * error({ status: 409, schema: Conflict });
211
207
  */
212
208
  export function error(spec = {}) {
213
- if (!isPlainObject(spec)) {
209
+ if (!isJsonObject(spec)) {
214
210
  throw new LinqBuildError('JL0101',
215
211
  `error() takes { status?, schema? }, got ${describeValue(spec)}`);
216
212
  }
@@ -235,7 +231,7 @@ export function error(spec = {}) {
235
231
  * @returns {[string, any][]} code → `{ status?, schema? }`, in declaration order
236
232
  */
237
233
  export function readErrors(errors, at) {
238
- if (!isPlainObject(errors)) {
234
+ if (!isJsonObject(errors)) {
239
235
  throw new LinqBuildError('JL0101',
240
236
  `errors is a plain object of code → error(), got ${describeValue(errors)}`, at);
241
237
  }
@@ -245,7 +241,7 @@ export function readErrors(errors, at) {
245
241
  `an error code matches ^[a-z][a-z0-9-]*$, got '${code}'`, `${at}/${code}`);
246
242
  }
247
243
  const declared = errors[code];
248
- if (!isPlainObject(declared)) {
244
+ if (!isJsonObject(declared)) {
249
245
  throw new LinqBuildError('JL0101',
250
246
  `errors.${code} is error({ status?, schema? }), got ${describeValue(declared)}`,
251
247
  `${at}/${code}`);
@@ -270,7 +266,7 @@ export function readErrors(errors, at) {
270
266
  */
271
267
  export function checkOperation(kind, spec, at) {
272
268
  const base = at ?? '';
273
- if (!isPlainObject(spec)) {
269
+ if (!isJsonObject(spec)) {
274
270
  throw new LinqBuildError('JL0101',
275
271
  `${kind}() takes { input?, output, errors?, policy?, http?, doc? }, got `
276
272
  + `${describeValue(spec)}`, at);
package/src/db/handle.js CHANGED
@@ -56,6 +56,9 @@ export function createEntityHandle(store, name) {
56
56
  const members = { ...set };
57
57
  chainStart(set, members);
58
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);
59
62
  members.link = (own, member, target) => {
60
63
  requireMembership(set.relations, name, member, 'link');
61
64
  set.link(own, member, target);
package/src/db/include.js CHANGED
@@ -10,9 +10,10 @@
10
10
  * `explainLoad(spec)`: the one-statement guarantee is the store's. A
11
11
  * spec is plain JSON in a fixed member order (`where, orderBy, take,
12
12
  * skip, after, maxDepth, include` at the root; `where, orderBy, take,
13
- * skip, count, include` in an include — a keyset cursor paginates the
14
- * root alone), so one graph is one document; `toJSON()` is that
15
- * document, as a pen's is.
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.
16
17
  */
17
18
 
18
19
  import { deepFreeze, setObjectMember } from '@jarenjs/core/object';
@@ -23,7 +24,7 @@ import { requireJson, describeValue } from '../json-boundary.js';
23
24
 
24
25
  const NO_PARAMS = new Set();
25
26
  const ROOT_KEYS = ['where', 'orderBy', 'take', 'skip', 'after', 'maxDepth', 'include'];
26
- const INCLUDE_KEYS = ['where', 'orderBy', 'take', 'skip', 'count', 'include'];
27
+ const INCLUDE_KEYS = ['where', 'orderBy', 'take', 'skip', 'count', 'maxRows', 'maxBytes', 'include'];
27
28
  /** A member path over the row: the shorthand or the bracketed spelling. */
28
29
  const MEMBER_PATH = /^\$it(?:\.([A-Za-z_$][\w$]*)|\['((?:[^'\\]|\\.)*)'\])$/;
29
30
 
@@ -158,6 +159,12 @@ function lowerInclude(spec, entityName, relationsOf, path) {
158
159
  if (spec.count !== true) throw new LinqBuildError('JL0101', `the include spec at ${at}: count takes true`);
159
160
  out.count = true;
160
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
+ }
161
168
  if (spec.include !== undefined) out.include = lowerIncludes(spec.include, entityName, relationsOf, path);
162
169
  return out;
163
170
  }
@@ -276,7 +283,9 @@ export class Graph {
276
283
  /** @param {number} count */
277
284
  skip(count) { return this.#with({ skip: requireJson(count, 'skip()') }); }
278
285
 
279
- /** The keyset cursor (§10.5). @param {string | number} cursor */
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 */
280
289
  after(cursor) { return this.#with({ after: requireJson(cursor, 'after()') }); }
281
290
 
282
291
  /** The include depth bound (§10.4). @param {number} depth */
@@ -309,6 +318,32 @@ export class Graph {
309
318
  return this.#tracking ? this.#set.load(spec) : this.#set.asNoTracking().load(spec);
310
319
  }
311
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
+
312
347
  /** `explainLoad(spec)` — the SQL, the includes, the pagination strategy. */
313
348
  explain() {
314
349
  return this.#set.explainLoad(this.toSpec());
package/src/db/index.js CHANGED
@@ -14,6 +14,12 @@
14
14
  * duplicates no algorithm — `include` emits the spec the store runs,
15
15
  * membership is the store's own `link`/`unlink`, `live` is the store's
16
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).
17
21
  */
18
22
 
19
23
  export { open, defaultValidator } from './open.js';
24
+ export { createDbLedger } from './ledger.js';
25
+ export { defineReplication } from './replication.js';