@jarenjs/db 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 (110) hide show
  1. package/ARCHITECTURE.md +420 -71
  2. package/README.md +711 -79
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +309 -45
  5. package/docs/LIVE-FORMAT.md +156 -19
  6. package/docs/MIGRATION-FORMAT.md +247 -40
  7. package/docs/MODEL-FORMAT.md +968 -86
  8. package/package.json +21 -8
  9. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  10. package/schemas/jaren-migration.schema.json +73 -0
  11. package/schemas/jaren-model.draft-07.schema.json +224 -162
  12. package/schemas/jaren-model.schema.json +224 -162
  13. package/src/algebra.js +227 -9
  14. package/src/backup.js +161 -0
  15. package/src/cancellation.js +48 -0
  16. package/src/capture.js +255 -44
  17. package/src/cli.js +337 -50
  18. package/src/cursor.js +411 -0
  19. package/src/dag-job.js +154 -21
  20. package/src/ddl.js +125 -11
  21. package/src/dialect.js +267 -112
  22. package/src/dialects/expression-read.js +158 -0
  23. package/src/dialects/postgres.js +618 -0
  24. package/src/dialects/rtree-ddl.js +129 -0
  25. package/src/dialects/sqlite.js +245 -12
  26. package/src/document-files.js +311 -0
  27. package/src/document-steps.js +422 -0
  28. package/src/documents.js +335 -0
  29. package/src/driver.js +503 -69
  30. package/src/drivers/bun.js +37 -1
  31. package/src/drivers/indexeddb-snapshot.js +149 -0
  32. package/src/drivers/node-pool.js +11 -0
  33. package/src/drivers/node-worker-endpoint.js +105 -0
  34. package/src/drivers/node-worker.js +204 -0
  35. package/src/drivers/node.js +41 -7
  36. package/src/drivers/postgres.js +331 -0
  37. package/src/drivers/wasm-oo1.js +97 -0
  38. package/src/drivers/wasm-session.js +67 -0
  39. package/src/drivers/wasm.js +18 -83
  40. package/src/drivers/worker-pool.js +183 -0
  41. package/src/drivers/worker-protocol.js +79 -0
  42. package/src/drivers/worker-queue.js +60 -0
  43. package/src/emit-model.js +14 -0
  44. package/src/emit.js +349 -51
  45. package/src/entity.js +102 -59
  46. package/src/errors.js +430 -2
  47. package/src/expression.js +284 -0
  48. package/src/graph.js +64 -8
  49. package/src/index.js +48 -19
  50. package/src/introspect.js +583 -0
  51. package/src/jobs.js +870 -99
  52. package/src/json-bytes.js +58 -0
  53. package/src/live-time.js +12 -3
  54. package/src/live.js +11 -1
  55. package/src/maintenance.js +175 -0
  56. package/src/migrate.js +606 -333
  57. package/src/model.js +241 -8
  58. package/src/plan.js +1238 -160
  59. package/src/pragmas.js +314 -0
  60. package/src/profile.js +151 -3
  61. package/src/query.js +1748 -312
  62. package/src/residual.js +17 -0
  63. package/src/series.js +12 -4
  64. package/src/store.js +1672 -276
  65. package/src/tracker.js +367 -68
  66. package/src/udf.js +88 -7
  67. package/types/index.d.ts +1246 -32
  68. package/types/node-pool.d.ts +28 -0
  69. package/types/node-worker.d.ts +54 -0
  70. package/types/node.d.ts +72 -3
  71. package/types/postgres.d.ts +46 -0
  72. package/types/typed.d.ts +81 -3
  73. package/types/wasm.d.ts +21 -0
  74. package/dist/types/algebra.d.ts +0 -230
  75. package/dist/types/app.d.ts +0 -49
  76. package/dist/types/capture.d.ts +0 -85
  77. package/dist/types/cli.d.ts +0 -2
  78. package/dist/types/dag-job.d.ts +0 -40
  79. package/dist/types/ddl.d.ts +0 -229
  80. package/dist/types/derive.d.ts +0 -250
  81. package/dist/types/dialect.d.ts +0 -154
  82. package/dist/types/dialects/sqlite.d.ts +0 -9
  83. package/dist/types/driver.d.ts +0 -110
  84. package/dist/types/drivers/bun.d.ts +0 -47
  85. package/dist/types/drivers/node.d.ts +0 -37
  86. package/dist/types/drivers/wasm.d.ts +0 -65
  87. package/dist/types/emit-model.d.ts +0 -44
  88. package/dist/types/emit.d.ts +0 -75
  89. package/dist/types/entity.d.ts +0 -23
  90. package/dist/types/errors.d.ts +0 -170
  91. package/dist/types/graph.d.ts +0 -28
  92. package/dist/types/index.d.ts +0 -37
  93. package/dist/types/jobs.d.ts +0 -140
  94. package/dist/types/knn.d.ts +0 -69
  95. package/dist/types/live-time.d.ts +0 -141
  96. package/dist/types/live.d.ts +0 -64
  97. package/dist/types/migrate.d.ts +0 -170
  98. package/dist/types/model.d.ts +0 -36
  99. package/dist/types/patch-sql.d.ts +0 -37
  100. package/dist/types/plan.d.ts +0 -142
  101. package/dist/types/profile.d.ts +0 -80
  102. package/dist/types/query.d.ts +0 -112
  103. package/dist/types/residual.d.ts +0 -64
  104. package/dist/types/series.d.ts +0 -227
  105. package/dist/types/store.d.ts +0 -60
  106. package/dist/types/tracker.d.ts +0 -43
  107. package/dist/types/typed.d.ts +0 -15
  108. package/dist/types/types.d.ts +0 -26
  109. package/dist/types/udf.d.ts +0 -75
  110. package/dist/types/window.d.ts +0 -52
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 { DbRuntimeError } from './errors.js';
36
- import { chain } from './driver.js';
35
+ import { DbCompileError, DbRuntimeError, wrapDriverError } from './errors.js';
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,16 +326,31 @@ 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 }} options - `now` is the clock a
332
+ * delivery is stamped with (the store's runtime record); the platform's
333
+ * when absent. `bracket` runs the first-open DDL (the store's immediate
334
+ * transaction on a writable store); bare when absent
320
335
  * @returns {any}
321
336
  */
322
337
  export function createCaptureEngine(options) {
323
338
  const { connection, shapes, mode } = options;
339
+ const clock = options.now ?? Date.now;
340
+ const bracket = options.bracket ?? ((/** @type {() => any} */ fn) => fn());
324
341
  const dialect = connection.dialect;
325
342
  const q = dialect.quoteIdentifier;
343
+ if (options.log && !(Number.isInteger(options.retention) && options.retention >= 1)) {
344
+ // a retention of 0 pruned every record the moment it was written,
345
+ // with the log reported as enabled
346
+ throw new TypeError('capture.log.retention must be a positive integer (records kept)');
347
+ }
326
348
 
327
349
  /** @type {Set<Function>} */
328
350
  const observers = new Set();
351
+ // the last sequence THIS process delivered: the per-process sequence
352
+ // when no log is kept, and otherwise the file's allocation read back
353
+ // from the durable row — never an answer to a watermark question
329
354
  let seq = 0;
330
355
  let depth = 0;
331
356
  /** @type {any} */
@@ -345,24 +370,70 @@ export function createCaptureEngine(options) {
345
370
  { name: 'patch', type: dialect.typeFor('string', 'key') },
346
371
  ],
347
372
  }),
373
+ // the durable high watermark: a singleton row the file keeps, so an
374
+ // emptied log still knows the highest sequence it ever allocated
375
+ createState: dialect.ddl.createPlainTable({
376
+ table: CHANGES_STATE_TABLE,
377
+ columns: [
378
+ { name: 'id', type: dialect.typeFor('integer', 'key'), primaryKey: true },
379
+ { name: 'high', type: dialect.typeFor('integer', 'key') },
380
+ ],
381
+ }),
382
+ // idempotent: an existing file is upgraded ONCE from its surviving
383
+ // maximum (a file that never held a row seeds 0); a second open, or
384
+ // a second store on the same file, changes nothing
385
+ hasState: `SELECT 1 AS ${q('present')} FROM ${q(CHANGES_STATE_TABLE)} WHERE ${q('id')} = 1`,
386
+ seedState: `INSERT INTO ${q(CHANGES_STATE_TABLE)} (${q('id')}, ${q('high')}) `
387
+ + `SELECT 1, (SELECT COALESCE(MAX(${q('seq')}), 0) FROM ${q(CHANGES_TABLE)}) `
388
+ + `WHERE NOT EXISTS (SELECT 1 FROM ${q(CHANGES_STATE_TABLE)} WHERE ${q('id')} = 1)`,
389
+ // the sequence is allocated by ONE statement against the durable
390
+ // row, inside the write's own transaction: the next value is one
391
+ // past the higher of the durable watermark and whatever survives in
392
+ // the log (so a state row that went missing can never lower it), and
393
+ // a rolled-back write takes its allocation back with the row. A
394
+ // process counter seeded once at open collided with another store's
395
+ // writes to the same file; the durable row is the file's fact, so two
396
+ // stores allocate distinct, increasing sequences.
397
+ allocate: `INSERT INTO ${q(CHANGES_STATE_TABLE)} (${q('id')}, ${q('high')}) `
398
+ + `VALUES (1, (SELECT COALESCE(MAX(${q('seq')}), 0) + 1 FROM ${q(CHANGES_TABLE)})) `
399
+ + `ON CONFLICT (${q('id')}) DO UPDATE SET ${q('high')} = CASE `
400
+ + `WHEN ${q(CHANGES_STATE_TABLE)}.${q('high')} + 1 > ${dialect.excludedRef(q('high'))} `
401
+ + `THEN ${q(CHANGES_STATE_TABLE)}.${q('high')} + 1 ELSE ${dialect.excludedRef(q('high'))} END `
402
+ + `RETURNING ${q('high')} AS ${q('seq')}`,
348
403
  insert: `INSERT INTO ${q(CHANGES_TABLE)} `
349
404
  + `(${['seq', 'at', 'source', 'patch'].map(q).join(', ')}) `
350
405
  + `VALUES (${[1, 2, 3, 4].map((i) => dialect.parameterRef(i, 'v')).join(', ')})`,
351
406
  prune: `DELETE FROM ${q(CHANGES_TABLE)} WHERE ${q('seq')} <= ${dialect.parameterRef(1, 'v')}`,
352
- highest: `SELECT MAX(${q('seq')}) AS ${q('n')} FROM ${q(CHANGES_TABLE)}`,
353
407
  read: `SELECT ${['seq', 'at', 'source', 'patch'].map(q).join(', ')} `
354
408
  + `FROM ${q(CHANGES_TABLE)} WHERE ${q('seq')} > ${dialect.parameterRef(1, 'v')} `
355
409
  + `ORDER BY ${q('seq')}`,
410
+ // the bounded read: a page of records after a cursor, one more than
411
+ // the page so `hasMore` is a fact and not a guess
412
+ readPage: `SELECT ${['seq', 'at', 'source', 'patch'].map(q).join(', ')} `
413
+ + `FROM ${q(CHANGES_TABLE)} WHERE ${q('seq')} > ${dialect.parameterRef(1, 'v')} `
414
+ + `ORDER BY ${q('seq')} LIMIT ${dialect.parameterRef(2, 'v')}`,
415
+ // the two watermarks are the FILE's facts: the earliest surviving
416
+ // row, and the durable high (falling back to the surviving maximum
417
+ // only for a file whose state row is absent — never to this process)
418
+ bounds: `SELECT (SELECT MIN(${q('seq')}) FROM ${q(CHANGES_TABLE)}) AS ${q('lo')}, `
419
+ + `COALESCE((SELECT ${q('high')} FROM ${q(CHANGES_STATE_TABLE)} WHERE ${q('id')} = 1), `
420
+ + `(SELECT MAX(${q('seq')}) FROM ${q(CHANGES_TABLE)}), 0) AS ${q('hi')}`,
356
421
  } : null;
357
422
 
358
423
  const ready = logStatements === null
359
424
  ? null
360
- : chain(connection.exec(logStatements.create), () =>
361
- chain(connection.prepare(logStatements.highest), (statement) =>
362
- chain(statement.get([]), (row) => {
363
- seq = Number(row?.n ?? 0) || 0;
364
- return null;
365
- })));
425
+ // the seed is written only when the row is absent: a read-only open
426
+ // of an already-upgraded file reads the row and writes nothing
427
+ : chain(attempt(() => bracket(() => chain(connection.exec(logStatements.create), () =>
428
+ chain(connection.exec(logStatements.createState), () =>
429
+ chain(connection.prepare(logStatements.hasState), (probe) => chain(probe.get([]), (row) =>
430
+ (row === undefined || row === null
431
+ ? chain(connection.prepare(logStatements.seedState), (statement) => statement.run([]))
432
+ : null)))))),
433
+ (error) => new DbCompileError('JD0002',
434
+ `the change log table could not be created (${error?.message ?? String(error)}) — `
435
+ + 'a read-only store creates nothing; open it read-write once, or without capture.log',
436
+ '/capture', error)), () => null);
366
437
 
367
438
  /** A patch value must be JSON: a `{ ...doc, m: undefined }` write
368
439
  * stores the member ABSENT, and the journal must record the JSON
@@ -443,40 +514,62 @@ export function createCaptureEngine(options) {
443
514
 
444
515
  const persist = (patch, at) => {
445
516
  if (logStatements === null || patch.length === 0) return null;
446
- seq += 1;
447
- const mySeq = seq;
448
- return chain(connection.prepare(logStatements.insert), (insert) =>
449
- chain(insert.run([mySeq, at, mode, JSON.stringify(patch)]), () =>
450
- chain(connection.prepare(logStatements.prune), (prune) =>
451
- chain(prune.run([mySeq - options.retention]), () => null))));
517
+ return chain(connection.prepare(logStatements.allocate), (allocate) =>
518
+ chain(allocate.get([]), (row) => {
519
+ seq = Number(row.seq);
520
+ return chain(connection.prepare(logStatements.insert), (insert) =>
521
+ chain(insert.run([seq, at, mode, JSON.stringify(patch)]), () =>
522
+ chain(connection.prepare(logStatements.prune), (prune) =>
523
+ chain(prune.run([seq - options.retention]), () => null))));
524
+ }));
452
525
  };
453
526
 
527
+ /** The collections a patch touches, in first-seen order. */
528
+ const collectionsOf = (patch) => [...new Set(patch.map(
529
+ (op) => decodeJSONPointerSegment(op.path.split('/')[1])))];
530
+
531
+ let delivering = false;
454
532
  const deliver = () => {
455
- while (pendingDeliveries.length > 0) {
456
- const delivery = pendingDeliveries.shift();
457
- for (const observer of [...observers]) {
458
- // error isolation: a throwing observer must never affect the
459
- // write (the app.observe discipline)
460
- try {
461
- observer(delivery);
462
- }
463
- catch {
464
- // deliberately swallowed; the write already committed
533
+ // never re-entered: an observer that WRITES commits a further record
534
+ // from inside this loop, and delivering that record here handed it to
535
+ // every sibling before the older one — commit order inverted for
536
+ // them, and a maintained view kept a stale row for good. The nested
537
+ // call queues its record; this loop drains it after the current one.
538
+ if (delivering) return;
539
+ delivering = true;
540
+ try {
541
+ while (pendingDeliveries.length > 0) {
542
+ const delivery = pendingDeliveries.shift();
543
+ for (const observer of [...observers]) {
544
+ // error isolation: a throwing observer must never affect the
545
+ // write (the app.observe discipline)
546
+ try {
547
+ observer(delivery);
548
+ }
549
+ catch {
550
+ // deliberately swallowed; the write already committed
551
+ }
465
552
  }
466
553
  }
467
554
  }
555
+ finally {
556
+ delivering = false;
557
+ }
468
558
  };
469
559
 
470
560
  /**
471
561
  * Run `fn` inside the capture scope: the OUTERMOST scope opens a
472
562
  * session (or journal buffer) plus a transaction, translates and
473
563
  * persists inside it, and delivers to observers after commit.
564
+ *
565
+ * The transaction's scope arguments are FORWARDED to `fn`: the store
566
+ * pins a transaction view to the exact scope its callback runs in,
567
+ * and with capture that is the scope this wrap opens, not the one
568
+ * around it. Callers that need no scope simply ignore the arguments.
474
569
  */
475
570
  const wrap = (fn) => {
476
571
  if (depth > 0) return fn();
477
572
  depth = 1;
478
- if (mode === 'session') session = connection.session();
479
- else journal = [];
480
573
  const cleanupFailure = () => {
481
574
  depth = 0;
482
575
  if (session !== null) {
@@ -487,20 +580,20 @@ export function createCaptureEngine(options) {
487
580
  };
488
581
  let outcome;
489
582
  try {
490
- outcome = connection.transaction(() =>
491
- chain(fn(), (result) =>
583
+ if (mode === 'session') session = connection.session();
584
+ else journal = [];
585
+ outcome = connection.transaction((...scopeArgs) =>
586
+ chain(fn(...scopeArgs), (result) =>
492
587
  chain(collect(), (patch) => {
493
588
  if (patch.length === 0) return { result, delivery: null };
494
- const at = Date.now();
589
+ const at = clock();
495
590
  return chain(persist(patch, at), () => ({
496
591
  result,
497
592
  delivery: {
498
593
  seq: logStatements === null ? (seq += 1) : seq,
499
594
  at,
500
595
  source: mode,
501
- collections: [...new Set(patch.map(
502
- (op) => decodeJSONPointerSegment(op.path.split('/')[1]))),
503
- ],
596
+ collections: collectionsOf(patch),
504
597
  patch,
505
598
  },
506
599
  }));
@@ -553,11 +646,28 @@ export function createCaptureEngine(options) {
553
646
  return outcome;
554
647
  };
555
648
 
649
+ /**
650
+ * The journal's checkpoint pair for NAMED partial rollback
651
+ * (MODEL-FORMAT §5.2). A named `SAVEPOINT` takes a mark; a
652
+ * `ROLLBACK TO` truncates the buffer to it, so records the engine
653
+ * undid vanish from the commit's patch exactly as a session drops
654
+ * rows undone by `ROLLBACK TO`. Session mode needs neither half —
655
+ * SQLite's own changeset already excludes the undone rows — and
656
+ * answers `null` so the caller stores nothing.
657
+ */
658
+ const mark = () => (mode === 'journal' ? journal.length : null);
659
+ /** @param {number | null} at - a value {@link mark} answered */
660
+ const truncate = (at) => {
661
+ if (mode === 'journal' && at !== null && journal.length > at) journal.length = at;
662
+ };
663
+
556
664
  return {
557
665
  mode,
558
666
  ready,
559
667
  wrap,
560
668
  nest,
669
+ mark,
670
+ truncate,
561
671
  record,
562
672
  observe(fn) {
563
673
  if (typeof fn !== 'function')
@@ -565,20 +675,121 @@ export function createCaptureEngine(options) {
565
675
  observers.add(fn);
566
676
  return () => observers.delete(fn);
567
677
  },
678
+ /**
679
+ * Read the persisted log forward from `after` — EVERY surviving
680
+ * record, in one array, with no bound and no watermark: a
681
+ * reconnecting consumer whose cursor fell below the retention floor
682
+ * receives the surviving suffix and cannot tell it from the whole.
683
+ * Kept for its callers; `changes.page()` is the bounded reader that
684
+ * reports the gap instead (LIVE-FORMAT §5).
685
+ * @param {number} after - the last seq seen
686
+ */
568
687
  changesSince(after) {
569
- if (logStatements === null) {
570
- throw new DbRuntimeError('JD2051',
571
- 'the change log is not enabled — open the store with capture.log');
572
- }
688
+ requireLog('changesSince');
689
+ requireCursor(after, 'changesSince');
573
690
  return chain(connection.prepare(logStatements.read), (statement) =>
574
- chain(statement.all([after]), (rows) => rows.map((row) => ({
575
- seq: Number(row.seq),
576
- at: Number(row.at),
577
- source: String(row.source),
578
- patch: JSON.parse(row.patch),
579
- }))));
691
+ chain(attempt(() => statement.all([after]), captureFailure), (rows) => rows.map(recordOf)));
580
692
  },
693
+ /** Whether the persisted log exists — what decides whether the
694
+ * bounded reader is offered at all. */
695
+ logged: logStatements !== null,
696
+ bounds: () => readBounds(),
697
+ page: (options) => readPage(options),
581
698
  };
699
+
700
+ /** @param {string} member */
701
+ function requireLog(member) {
702
+ if (logStatements !== null) return;
703
+ throw new DbRuntimeError('JD2051',
704
+ `${member}: the change log is not enabled — open the store with capture.log`);
705
+ }
706
+ /** @param {any} after @param {string} member */
707
+ function requireCursor(after, member) {
708
+ if (typeof after === 'number' && Number.isFinite(after)) return;
709
+ throw new TypeError(`${member} takes the last seq seen as a number (after), got ${
710
+ after === undefined ? 'undefined' : JSON.stringify(after)}`);
711
+ }
712
+ /** The record shape observers receive, `collections` included. */
713
+ function recordOf(row) {
714
+ const patch = JSON.parse(row.patch);
715
+ return {
716
+ seq: Number(row.seq),
717
+ at: Number(row.at),
718
+ source: String(row.source),
719
+ collections: collectionsOf(patch),
720
+ patch,
721
+ };
722
+ }
723
+ /**
724
+ * The log's two watermarks: the earliest surviving sequence (`null`
725
+ * when nothing survives) and the highest ever allocated, read from the
726
+ * durable state row. Both are the FILE's facts, never this process's:
727
+ * an emptied log, a reopened file and a second store over the same
728
+ * file all answer the same high watermark, so a stale consumer meets a
729
+ * reset rather than a plausible empty history.
730
+ */
731
+ function readBounds() {
732
+ requireLog('changes.bounds');
733
+ return chain(connection.prepare(logStatements.bounds), (statement) =>
734
+ chain(attempt(() => statement.get([]), captureFailure), (row) => ({
735
+ earliestAvailable: row?.lo === null || row?.lo === undefined ? null : Number(row.lo),
736
+ highWatermark: Number(row?.hi ?? 0),
737
+ })));
738
+ }
739
+ /**
740
+ * One bounded page of the log after `after`: never more than `limit`
741
+ * records or `maxBytes` serialised patch bytes (the one page drain,
742
+ * cursor.js — a record larger than `maxBytes` is its `JD2074`, the
743
+ * cursor not advanced), `hasMore` by one peek, `signal` honoured at a
744
+ * record boundary. The watermarks are read AFTER the rows: a floor
745
+ * that rose during the read can only make the reset verdict stricter,
746
+ * never let a pruned gap pass as a continuation. A cursor below the
747
+ * floor — the record after `after` no longer survives — is a TOTAL
748
+ * refusal: `resetRequired: true`, no items, no `next`, because a
749
+ * partial suffix beside a reset flag invites a consumer to use both.
750
+ * @param {{ after: number, limit?: number, maxBytes?: number | null,
751
+ * signal?: AbortSignal }} options
752
+ */
753
+ function readPage(options) {
754
+ requireLog('changes.page');
755
+ const after = options?.after;
756
+ requireCursor(after, 'changes.page');
757
+ const limit = options?.limit ?? PAGE_LIMIT_DEFAULT;
758
+ if (!Number.isSafeInteger(limit) || limit < 1)
759
+ throw new TypeError('changes.page: limit must be a positive integer');
760
+ const declaredBytes = options?.maxBytes;
761
+ const maxBytes = declaredBytes === undefined || declaredBytes === null || declaredBytes === Infinity
762
+ ? null : declaredBytes;
763
+ if (maxBytes !== null && !(Number.isSafeInteger(maxBytes) && maxBytes >= 1))
764
+ throw new TypeError('changes.page: maxBytes must be a positive integer, or Infinity for no byte bound');
765
+ const deadline = options?.deadline;
766
+ if (deadline !== undefined && (typeof deadline !== 'number' || !Number.isFinite(deadline)))
767
+ throw new TypeError('changes.page: deadline is an epoch-millisecond number');
768
+ // the deadline is read against the store's clock at every record
769
+ // boundary, as on every other page (JD2075)
770
+ const cursor = createCursor({ streaming: 'row', barrier: null, signal: options?.signal, deadline, now: clock,
771
+ open: () => chain(connection.prepare(logStatements.readPage),
772
+ (statement) => statement.iterate([after, limit + 1])),
773
+ wrap: captureFailure,
774
+ items: (row) => [{ record: recordOf(row), bytes: utf8Length(String(row.patch)) }] });
775
+ return drainPage(cursor, {
776
+ limit, maxBytes, after,
777
+ sizeOf: (item) => item.bytes,
778
+ continuationOf: (item) => item.record.seq,
779
+ }).then((page) => chain(readBounds(), (bounds) => {
780
+ const first = bounds.earliestAvailable ?? bounds.highWatermark + 1;
781
+ if (after + 1 < first) {
782
+ return { items: [], ...bounds, hasMore: false, resetRequired: true };
783
+ }
784
+ return {
785
+ items: page.items.map((item) => item.record),
786
+ next: page.continuation ?? after,
787
+ ...bounds,
788
+ hasMore: page.hasMore,
789
+ resetRequired: false,
790
+ };
791
+ }));
792
+ }
582
793
  }
583
794
 
584
795
  //#endregion