@jarenjs/db 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.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +412 -56
  2. package/README.md +600 -57
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +293 -45
  5. package/docs/LIVE-FORMAT.md +169 -20
  6. package/docs/MIGRATION-FORMAT.md +142 -17
  7. package/docs/MODEL-FORMAT.md +752 -64
  8. package/docs/REPLICATION-FORMAT.md +208 -0
  9. package/package.json +21 -7
  10. package/schemas/jaren-model.draft-07.schema.json +224 -162
  11. package/schemas/jaren-model.schema.json +224 -162
  12. package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
  13. package/schemas/jaren-replication-snapshot.schema.json +83 -0
  14. package/schemas/jaren-replication.draft-07.schema.json +82 -0
  15. package/schemas/jaren-replication.schema.json +82 -0
  16. package/src/algebra.js +227 -9
  17. package/src/backup.js +161 -0
  18. package/src/cancellation.js +48 -0
  19. package/src/capture.js +230 -47
  20. package/src/cli.js +165 -59
  21. package/src/cursor.js +417 -0
  22. package/src/dag-job.js +154 -21
  23. package/src/ddl.js +102 -8
  24. package/src/dialect.js +268 -113
  25. package/src/dialects/expression-read.js +158 -0
  26. package/src/dialects/postgres.js +618 -0
  27. package/src/dialects/rtree-ddl.js +129 -0
  28. package/src/dialects/sqlite.js +244 -11
  29. package/src/document-files.js +311 -0
  30. package/src/document-steps.js +422 -0
  31. package/src/documents.js +335 -0
  32. package/src/driver.js +448 -61
  33. package/src/drivers/bun.js +37 -1
  34. package/src/drivers/indexeddb-snapshot.js +149 -0
  35. package/src/drivers/node-pool.js +11 -0
  36. package/src/drivers/node-worker-endpoint.js +105 -0
  37. package/src/drivers/node-worker.js +204 -0
  38. package/src/drivers/node.js +41 -7
  39. package/src/drivers/postgres.js +331 -0
  40. package/src/drivers/wasm-oo1.js +97 -0
  41. package/src/drivers/wasm-session.js +67 -0
  42. package/src/drivers/wasm.js +17 -83
  43. package/src/drivers/worker-pool.js +183 -0
  44. package/src/drivers/worker-protocol.js +79 -0
  45. package/src/drivers/worker-queue.js +60 -0
  46. package/src/emit.js +339 -48
  47. package/src/entity.js +20 -22
  48. package/src/errors.js +430 -19
  49. package/src/expression.js +284 -0
  50. package/src/graph.js +64 -8
  51. package/src/index.js +48 -17
  52. package/src/introspect.js +583 -0
  53. package/src/jobs.js +843 -107
  54. package/src/json-bytes.js +58 -0
  55. package/src/live-join.js +250 -0
  56. package/src/live-nested.js +120 -0
  57. package/src/live.js +18 -4
  58. package/src/logical-rows.js +90 -0
  59. package/src/maintenance.js +175 -0
  60. package/src/migrate.js +248 -181
  61. package/src/model.js +68 -0
  62. package/src/plan.js +1119 -138
  63. package/src/pragmas.js +314 -0
  64. package/src/profile.js +151 -3
  65. package/src/query.js +1634 -323
  66. package/src/replication-format.js +115 -0
  67. package/src/replication.js +332 -0
  68. package/src/residual.js +17 -0
  69. package/src/series.js +12 -4
  70. package/src/store.js +1567 -273
  71. package/src/tracker.js +203 -29
  72. package/src/udf.js +88 -7
  73. package/types/index.d.ts +1158 -27
  74. package/types/node-pool.d.ts +28 -0
  75. package/types/node-worker.d.ts +54 -0
  76. package/types/node.d.ts +69 -2
  77. package/types/postgres.d.ts +46 -0
  78. package/types/typed.d.ts +27 -4
  79. package/types/wasm.d.ts +14 -0
package/src/capture.js CHANGED
@@ -32,13 +32,23 @@
32
32
  import { createJSONPatch } from '@jarenjs/json/patch';
33
33
  import { encodeJSONPointerSegment, decodeJSONPointerSegment } from '@jarenjs/json/pointer';
34
34
 
35
- import { DbCompileError, DbRuntimeError } from './errors.js';
35
+ import { DbCompileError, DbRuntimeError, wrapDriverError } from './errors.js';
36
36
  import { chain, attempt } from './driver.js';
37
+ import { createCursor, drainPage, utf8Length, PAGE_LIMIT_DEFAULT } from './cursor.js';
37
38
 
38
39
  /** The persisted change log (LIVE-FORMAT §5). */
39
40
  export const CHANGES_TABLE = '_jaren_changes';
41
+ /**
42
+ * The log's durable state: one row holding the highest sequence ever
43
+ * allocated for this file, so the high watermark survives a log that
44
+ * retention or maintenance has emptied (LIVE-FORMAT §5).
45
+ */
46
+ export const CHANGES_STATE_TABLE = '_jaren_changes_state';
40
47
  export const DEFAULT_RETENTION = 1000;
41
48
 
49
+ /** A driver failure met by the change reader, classified — never raw. */
50
+ const captureFailure = (error) => wrapDriverError(error, { docPath: '/capture' });
51
+
42
52
  //#region the binary changeset parser
43
53
 
44
54
  const OP_INSERT = 18;
@@ -316,11 +326,19 @@ export function translateOperations(connection, shapes, operations) {
316
326
  /**
317
327
  * @param {{ connection: any, shapes: Map<string, TableShape>,
318
328
  * mode: 'session' | 'journal',
319
- * log: boolean, retention: number }} options
329
+ * log: boolean, retention: number,
330
+ * now?: () => number,
331
+ * bracket?: (fn: () => any) => any,
332
+ * beforeCommit?: (patch: any[], context: any) => any }} options - `now` is the clock a
333
+ * delivery is stamped with (the store's runtime record); the platform's
334
+ * when absent. `bracket` runs the first-open DDL (the store's immediate
335
+ * transaction on a writable store); bare when absent
320
336
  * @returns {any}
321
337
  */
322
338
  export function createCaptureEngine(options) {
323
339
  const { connection, shapes, mode } = options;
340
+ const clock = options.now ?? Date.now;
341
+ const bracket = options.bracket ?? ((/** @type {() => any} */ fn) => fn());
324
342
  const dialect = connection.dialect;
325
343
  const q = dialect.quoteIdentifier;
326
344
  if (options.log && !(Number.isInteger(options.retention) && options.retention >= 1)) {
@@ -331,8 +349,12 @@ export function createCaptureEngine(options) {
331
349
 
332
350
  /** @type {Set<Function>} */
333
351
  const observers = new Set();
352
+ // the last sequence THIS process delivered: the per-process sequence
353
+ // when no log is kept, and otherwise the file's allocation read back
354
+ // from the durable row — never an answer to a watermark question
334
355
  let seq = 0;
335
356
  let depth = 0;
357
+ let context = null;
336
358
  /** @type {any} */
337
359
  let session = null;
338
360
  /** @type {any[]} */
@@ -350,33 +372,70 @@ export function createCaptureEngine(options) {
350
372
  { name: 'patch', type: dialect.typeFor('string', 'key') },
351
373
  ],
352
374
  }),
353
- // the sequence is allocated by the STATEMENT, inside the write's
354
- // own transaction: a counter seeded once at open collided with
355
- // another store's writes to the same file and rolled the user's
356
- // write back with a raw UNIQUE failure
375
+ // the durable high watermark: a singleton row the file keeps, so an
376
+ // emptied log still knows the highest sequence it ever allocated
377
+ createState: dialect.ddl.createPlainTable({
378
+ table: CHANGES_STATE_TABLE,
379
+ columns: [
380
+ { name: 'id', type: dialect.typeFor('integer', 'key'), primaryKey: true },
381
+ { name: 'high', type: dialect.typeFor('integer', 'key') },
382
+ ],
383
+ }),
384
+ // idempotent: an existing file is upgraded ONCE from its surviving
385
+ // maximum (a file that never held a row seeds 0); a second open, or
386
+ // a second store on the same file, changes nothing
387
+ hasState: `SELECT 1 AS ${q('present')} FROM ${q(CHANGES_STATE_TABLE)} WHERE ${q('id')} = 1`,
388
+ seedState: `INSERT INTO ${q(CHANGES_STATE_TABLE)} (${q('id')}, ${q('high')}) `
389
+ + `SELECT 1, (SELECT COALESCE(MAX(${q('seq')}), 0) FROM ${q(CHANGES_TABLE)}) `
390
+ + `WHERE NOT EXISTS (SELECT 1 FROM ${q(CHANGES_STATE_TABLE)} WHERE ${q('id')} = 1)`,
391
+ // the sequence is allocated by ONE statement against the durable
392
+ // row, inside the write's own transaction: the next value is one
393
+ // past the higher of the durable watermark and whatever survives in
394
+ // the log (so a state row that went missing can never lower it), and
395
+ // a rolled-back write takes its allocation back with the row. A
396
+ // process counter seeded once at open collided with another store's
397
+ // writes to the same file; the durable row is the file's fact, so two
398
+ // stores allocate distinct, increasing sequences.
399
+ allocate: `INSERT INTO ${q(CHANGES_STATE_TABLE)} (${q('id')}, ${q('high')}) `
400
+ + `VALUES (1, (SELECT COALESCE(MAX(${q('seq')}), 0) + 1 FROM ${q(CHANGES_TABLE)})) `
401
+ + `ON CONFLICT (${q('id')}) DO UPDATE SET ${q('high')} = CASE `
402
+ + `WHEN ${q(CHANGES_STATE_TABLE)}.${q('high')} + 1 > ${dialect.excludedRef(q('high'))} `
403
+ + `THEN ${q(CHANGES_STATE_TABLE)}.${q('high')} + 1 ELSE ${dialect.excludedRef(q('high'))} END `
404
+ + `RETURNING ${q('high')} AS ${q('seq')}`,
357
405
  insert: `INSERT INTO ${q(CHANGES_TABLE)} `
358
406
  + `(${['seq', 'at', 'source', 'patch'].map(q).join(', ')}) `
359
- + `VALUES ((SELECT COALESCE(MAX(${q('seq')}), 0) + 1 FROM ${q(CHANGES_TABLE)}), `
360
- + `${[1, 2, 3].map((i) => dialect.parameterRef(i, 'v')).join(', ')}) `
361
- + `RETURNING ${q('seq')} AS ${q('seq')}`,
407
+ + `VALUES (${[1, 2, 3, 4].map((i) => dialect.parameterRef(i, 'v')).join(', ')})`,
362
408
  prune: `DELETE FROM ${q(CHANGES_TABLE)} WHERE ${q('seq')} <= ${dialect.parameterRef(1, 'v')}`,
363
- highest: `SELECT MAX(${q('seq')}) AS ${q('n')} FROM ${q(CHANGES_TABLE)}`,
364
409
  read: `SELECT ${['seq', 'at', 'source', 'patch'].map(q).join(', ')} `
365
410
  + `FROM ${q(CHANGES_TABLE)} WHERE ${q('seq')} > ${dialect.parameterRef(1, 'v')} `
366
411
  + `ORDER BY ${q('seq')}`,
412
+ // the bounded read: a page of records after a cursor, one more than
413
+ // the page so `hasMore` is a fact and not a guess
414
+ readPage: `SELECT ${['seq', 'at', 'source', 'patch'].map(q).join(', ')} `
415
+ + `FROM ${q(CHANGES_TABLE)} WHERE ${q('seq')} > ${dialect.parameterRef(1, 'v')} `
416
+ + `ORDER BY ${q('seq')} LIMIT ${dialect.parameterRef(2, 'v')}`,
417
+ // the two watermarks are the FILE's facts: the earliest surviving
418
+ // row, and the durable high (falling back to the surviving maximum
419
+ // only for a file whose state row is absent — never to this process)
420
+ bounds: `SELECT (SELECT MIN(${q('seq')}) FROM ${q(CHANGES_TABLE)}) AS ${q('lo')}, `
421
+ + `COALESCE((SELECT ${q('high')} FROM ${q(CHANGES_STATE_TABLE)} WHERE ${q('id')} = 1), `
422
+ + `(SELECT MAX(${q('seq')}) FROM ${q(CHANGES_TABLE)}), 0) AS ${q('hi')}`,
367
423
  } : null;
368
424
 
369
425
  const ready = logStatements === null
370
426
  ? null
371
- : chain(attempt(() => connection.exec(logStatements.create), (error) => new DbCompileError('JD0002',
427
+ // the seed is written only when the row is absent: a read-only open
428
+ // of an already-upgraded file reads the row and writes nothing
429
+ : chain(attempt(() => bracket(() => chain(connection.exec(logStatements.create), () =>
430
+ chain(connection.exec(logStatements.createState), () =>
431
+ chain(connection.prepare(logStatements.hasState), (probe) => chain(probe.get([]), (row) =>
432
+ (row === undefined || row === null
433
+ ? chain(connection.prepare(logStatements.seedState), (statement) => statement.run([]))
434
+ : null)))))),
435
+ (error) => new DbCompileError('JD0002',
372
436
  `the change log table could not be created (${error?.message ?? String(error)}) — `
373
437
  + 'a read-only store creates nothing; open it read-write once, or without capture.log',
374
- '/capture', error)), () =>
375
- chain(connection.prepare(logStatements.highest), (statement) =>
376
- chain(statement.get([]), (row) => {
377
- seq = Number(row?.n ?? 0) || 0;
378
- return null;
379
- })));
438
+ '/capture', error)), () => null);
380
439
 
381
440
  /** A patch value must be JSON: a `{ ...doc, m: undefined }` write
382
441
  * stores the member ABSENT, and the journal must record the JSON
@@ -457,11 +516,13 @@ export function createCaptureEngine(options) {
457
516
 
458
517
  const persist = (patch, at) => {
459
518
  if (logStatements === null || patch.length === 0) return null;
460
- return chain(connection.prepare(logStatements.insert), (insert) =>
461
- chain(insert.get([at, mode, JSON.stringify(patch)]), (row) => {
519
+ return chain(connection.prepare(logStatements.allocate), (allocate) =>
520
+ chain(allocate.get([]), (row) => {
462
521
  seq = Number(row.seq);
463
- return chain(connection.prepare(logStatements.prune), (prune) =>
464
- chain(prune.run([seq - options.retention]), () => null));
522
+ return chain(connection.prepare(logStatements.insert), (insert) =>
523
+ chain(insert.run([seq, at, mode, JSON.stringify(patch)]), () =>
524
+ chain(connection.prepare(logStatements.prune), (prune) =>
525
+ chain(prune.run([seq - options.retention]), () => null))));
465
526
  }));
466
527
  };
467
528
 
@@ -502,14 +563,18 @@ export function createCaptureEngine(options) {
502
563
  * Run `fn` inside the capture scope: the OUTERMOST scope opens a
503
564
  * session (or journal buffer) plus a transaction, translates and
504
565
  * persists inside it, and delivers to observers after commit.
566
+ *
567
+ * The transaction's scope arguments are FORWARDED to `fn`: the store
568
+ * pins a transaction view to the exact scope its callback runs in,
569
+ * and with capture that is the scope this wrap opens, not the one
570
+ * around it. Callers that need no scope simply ignore the arguments.
505
571
  */
506
572
  const wrap = (fn) => {
507
573
  if (depth > 0) return fn();
508
574
  depth = 1;
509
- if (mode === 'session') session = connection.session();
510
- else journal = [];
511
575
  const cleanupFailure = () => {
512
576
  depth = 0;
577
+ context = null;
513
578
  if (session !== null) {
514
579
  session.close();
515
580
  session = null;
@@ -518,11 +583,13 @@ export function createCaptureEngine(options) {
518
583
  };
519
584
  let outcome;
520
585
  try {
521
- outcome = connection.transaction(() =>
522
- chain(fn(), (result) =>
523
- chain(collect(), (patch) => {
586
+ if (mode === 'session') session = connection.session();
587
+ else journal = [];
588
+ outcome = connection.transaction((...scopeArgs) =>
589
+ chain(fn(...scopeArgs), (result) =>
590
+ chain(collect(), (patch) => chain(options.beforeCommit?.(patch, context), () => {
524
591
  if (patch.length === 0) return { result, delivery: null };
525
- const at = Date.now();
592
+ const at = clock();
526
593
  return chain(persist(patch, at), () => ({
527
594
  result,
528
595
  delivery: {
@@ -533,7 +600,7 @@ export function createCaptureEngine(options) {
533
600
  patch,
534
601
  },
535
602
  }));
536
- })));
603
+ }))));
537
604
  }
538
605
  catch (error) {
539
606
  cleanupFailure();
@@ -541,6 +608,7 @@ export function createCaptureEngine(options) {
541
608
  }
542
609
  const finish = (bundle) => {
543
610
  depth = 0;
611
+ context = null;
544
612
  if (bundle.delivery !== null) pendingDeliveries.push(bundle.delivery);
545
613
  deliver();
546
614
  return bundle.result;
@@ -582,41 +650,156 @@ export function createCaptureEngine(options) {
582
650
  return outcome;
583
651
  };
584
652
 
653
+ /**
654
+ * The journal's checkpoint pair for NAMED partial rollback
655
+ * (MODEL-FORMAT §5.2). A named `SAVEPOINT` takes a mark; a
656
+ * `ROLLBACK TO` truncates the buffer to it, so records the engine
657
+ * undid vanish from the commit's patch exactly as a session drops
658
+ * rows undone by `ROLLBACK TO`. Session mode needs neither half —
659
+ * SQLite's own changeset already excludes the undone rows — and
660
+ * answers `null` so the caller stores nothing.
661
+ */
662
+ const mark = () => (mode === 'journal' ? journal.length : null);
663
+ /** @param {number | null} at - a value {@link mark} answered */
664
+ const truncate = (at) => {
665
+ if (mode === 'journal' && at !== null && journal.length > at) journal.length = at;
666
+ };
667
+
585
668
  return {
586
669
  mode,
587
670
  ready,
588
671
  wrap,
589
672
  nest,
673
+ mark,
674
+ truncate,
590
675
  record,
676
+ // Metadata belongs to this capture transaction and is cleared on every
677
+ // settlement, including a failure while collecting or persisting changes.
678
+ setContext(value) {
679
+ if (depth === 0) throw new TypeError('capture context needs an active transaction');
680
+ context = value;
681
+ },
591
682
  observe(fn) {
592
683
  if (typeof fn !== 'function')
593
684
  throw new TypeError('observe needs a function');
594
685
  observers.add(fn);
595
686
  return () => observers.delete(fn);
596
687
  },
688
+ /**
689
+ * Read the persisted log forward from `after` — EVERY surviving
690
+ * record, in one array, with no bound and no watermark: a
691
+ * reconnecting consumer whose cursor fell below the retention floor
692
+ * receives the surviving suffix and cannot tell it from the whole.
693
+ * Kept for its callers; `changes.page()` is the bounded reader that
694
+ * reports the gap instead (LIVE-FORMAT §5).
695
+ * @param {number} after - the last seq seen
696
+ */
597
697
  changesSince(after) {
598
- if (logStatements === null) {
599
- throw new DbRuntimeError('JD2051',
600
- 'the change log is not enabled — open the store with capture.log');
601
- }
602
- if (typeof after !== 'number' || !Number.isFinite(after)) {
603
- throw new TypeError(`changesSince(after) takes the last seq seen as a number, got ${
604
- after === undefined ? 'undefined' : JSON.stringify(after)}`);
605
- }
698
+ requireLog('changesSince');
699
+ requireCursor(after, 'changesSince');
606
700
  return chain(connection.prepare(logStatements.read), (statement) =>
607
- chain(statement.all([after]), (rows) => rows.map((row) => {
608
- const patch = JSON.parse(row.patch);
609
- // the same record shape observers receive: `collections` too
610
- return {
611
- seq: Number(row.seq),
612
- at: Number(row.at),
613
- source: String(row.source),
614
- collections: collectionsOf(patch),
615
- patch,
616
- };
617
- })));
701
+ chain(attempt(() => statement.all([after]), captureFailure), (rows) => rows.map(recordOf)));
618
702
  },
703
+ /** Whether the persisted log exists — what decides whether the
704
+ * bounded reader is offered at all. */
705
+ logged: logStatements !== null,
706
+ bounds: () => readBounds(),
707
+ page: (options) => readPage(options),
619
708
  };
709
+
710
+ /** @param {string} member */
711
+ function requireLog(member) {
712
+ if (logStatements !== null) return;
713
+ throw new DbRuntimeError('JD2051',
714
+ `${member}: the change log is not enabled — open the store with capture.log`);
715
+ }
716
+ /** @param {any} after @param {string} member */
717
+ function requireCursor(after, member) {
718
+ if (typeof after === 'number' && Number.isFinite(after)) return;
719
+ throw new TypeError(`${member} takes the last seq seen as a number (after), got ${
720
+ after === undefined ? 'undefined' : JSON.stringify(after)}`);
721
+ }
722
+ /** The record shape observers receive, `collections` included. */
723
+ function recordOf(row) {
724
+ const patch = JSON.parse(row.patch);
725
+ return {
726
+ seq: Number(row.seq),
727
+ at: Number(row.at),
728
+ source: String(row.source),
729
+ collections: collectionsOf(patch),
730
+ patch,
731
+ };
732
+ }
733
+ /**
734
+ * The log's two watermarks: the earliest surviving sequence (`null`
735
+ * when nothing survives) and the highest ever allocated, read from the
736
+ * durable state row. Both are the FILE's facts, never this process's:
737
+ * an emptied log, a reopened file and a second store over the same
738
+ * file all answer the same high watermark, so a stale consumer meets a
739
+ * reset rather than a plausible empty history.
740
+ */
741
+ function readBounds() {
742
+ requireLog('changes.bounds');
743
+ return chain(connection.prepare(logStatements.bounds), (statement) =>
744
+ chain(attempt(() => statement.get([]), captureFailure), (row) => ({
745
+ earliestAvailable: row?.lo === null || row?.lo === undefined ? null : Number(row.lo),
746
+ highWatermark: Number(row?.hi ?? 0),
747
+ })));
748
+ }
749
+ /**
750
+ * One bounded page of the log after `after`: never more than `limit`
751
+ * records or `maxBytes` serialised patch bytes (the one page drain,
752
+ * cursor.js — a record larger than `maxBytes` is its `JD2074`, the
753
+ * cursor not advanced), `hasMore` by one peek, `signal` honoured at a
754
+ * record boundary. The watermarks are read AFTER the rows: a floor
755
+ * that rose during the read can only make the reset verdict stricter,
756
+ * never let a pruned gap pass as a continuation. A cursor below the
757
+ * floor — the record after `after` no longer survives — is a TOTAL
758
+ * refusal: `resetRequired: true`, no items, no `next`, because a
759
+ * partial suffix beside a reset flag invites a consumer to use both.
760
+ * @param {{ after: number, limit?: number, maxBytes?: number | null,
761
+ * signal?: AbortSignal }} options
762
+ */
763
+ function readPage(options) {
764
+ requireLog('changes.page');
765
+ const after = options?.after;
766
+ requireCursor(after, 'changes.page');
767
+ const limit = options?.limit ?? PAGE_LIMIT_DEFAULT;
768
+ if (!Number.isSafeInteger(limit) || limit < 1)
769
+ throw new TypeError('changes.page: limit must be a positive integer');
770
+ const declaredBytes = options?.maxBytes;
771
+ const maxBytes = declaredBytes === undefined || declaredBytes === null || declaredBytes === Infinity
772
+ ? null : declaredBytes;
773
+ if (maxBytes !== null && !(Number.isSafeInteger(maxBytes) && maxBytes >= 1))
774
+ throw new TypeError('changes.page: maxBytes must be a positive integer, or Infinity for no byte bound');
775
+ const deadline = options?.deadline;
776
+ if (deadline !== undefined && (typeof deadline !== 'number' || !Number.isFinite(deadline)))
777
+ throw new TypeError('changes.page: deadline is an epoch-millisecond number');
778
+ // the deadline is read against the store's clock at every record
779
+ // boundary, as on every other page (JD2075)
780
+ const cursor = createCursor({ streaming: 'row', barrier: null, signal: options?.signal, deadline, now: clock,
781
+ open: () => chain(connection.prepare(logStatements.readPage),
782
+ (statement) => statement.iterate([after, limit + 1])),
783
+ wrap: captureFailure,
784
+ items: (row) => [{ record: recordOf(row), bytes: utf8Length(String(row.patch)) }] });
785
+ return drainPage(cursor, {
786
+ limit, maxBytes, after,
787
+ sizeOf: (item) => item.bytes,
788
+ continuationOf: (item) => item.record.seq,
789
+ }).then((page) => chain(readBounds(), (bounds) => {
790
+ const first = bounds.earliestAvailable ?? bounds.highWatermark + 1;
791
+ if (after + 1 < first) {
792
+ return { items: [], ...bounds, hasMore: false, resetRequired: true };
793
+ }
794
+ return {
795
+ items: page.items.map((item) => item.record),
796
+ next: page.continuation ?? after,
797
+ ...bounds,
798
+ hasMore: page.hasMore,
799
+ resetRequired: false,
800
+ };
801
+ }));
802
+ }
620
803
  }
621
804
 
622
805
  //#endregion