@substrat-run/adapter-cloudflare 0.170.0 → 0.171.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/dist/scope-do.js CHANGED
@@ -457,6 +457,82 @@ const stageRowsTable = (ord) => `_substrat_stage_rows_${ord}`;
457
457
  const STAGE_READ_BATCH = 500;
458
458
  /** How many rows a dump page reads from a table at a time; each batch is read to its end. */
459
459
  const DUMP_READ_BATCH = 500;
460
+ /**
461
+ * A capture's own tables and triggers (#2450): what lets a copy page a scope that never stops
462
+ * taking writes. `_substrat_capture` holds one row per copy reading this store (its id, when it was
463
+ * last asked for, the schema its pages read), `_substrat_capture_changes` the rows written since
464
+ * (table, rowid, and the batch that served it, null until one did), and three triggers per dumped
465
+ * table log every insert, update and delete there, once for every open capture. They exist only
466
+ * while a capture is open: the last one to end drops them all. Never in a dump.
467
+ */
468
+ const CAPTURE_GLOB = '_substrat_capture*';
469
+ const CAPTURE_TABLES_DDL = [
470
+ `CREATE TABLE IF NOT EXISTS _substrat_capture (copy_id TEXT PRIMARY KEY, touched_at INTEGER NOT NULL, schema TEXT NOT NULL, triggers INTEGER NOT NULL)`,
471
+ `CREATE TABLE IF NOT EXISTS _substrat_capture_changes (
472
+ copy_id TEXT NOT NULL, tbl TEXT NOT NULL, rid INTEGER NOT NULL, batch INTEGER,
473
+ PRIMARY KEY (copy_id, tbl, rid)) WITHOUT ROWID`,
474
+ ];
475
+ /**
476
+ * How long a capture nobody asks for stays open: one copy lease. A copy's job renews its lease at
477
+ * least every quarter of it, and asks the source at every step, so a capture this idle belongs to
478
+ * a copy that has stopped, and its triggers would only cost every write a log row.
479
+ */
480
+ const CAPTURE_IDLE_MS = SCOPE_COPY_LEASE_MS;
481
+ /** How many logged rows one batch of changes serves at most, before its byte cap. */
482
+ const CAPTURE_BATCH_ROWS = 2_000;
483
+ /** Rowids per `IN (…)`: a Durable Object's SQLite binds at most 100 parameters to a statement. */
484
+ const CAPTURE_IN_CHUNK = 90;
485
+ const CAPTURE_GONE = 'the copy\'s capture of this scope is gone (it ended, expired or a load replaced the store); start the copy again';
486
+ const CAPTURE_SCHEMA_MOVED = 'the scope\'s schema changed while it was copied (a migration ran, or a table was rebuilt); start the copy again';
487
+ const CAPTURE_EXPRESSION_INDEX = 'a table with a unique index on an expression cannot be copied while it takes writes; copy it at a quiet moment';
488
+ /** A write the capture path takes: one whose target is a capture table or a capture trigger. */
489
+ const CAPTURE_STATEMENT = /^\s*(?:CREATE TABLE IF NOT EXISTS|INSERT INTO|UPDATE|DELETE FROM|DROP TABLE IF EXISTS|CREATE TRIGGER|DROP TRIGGER IF EXISTS)\s+"?_substrat_capture(?:\b|_)/;
490
+ const quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
491
+ const quoteText = (text) => `'${text.replaceAll("'", "''")}'`;
492
+ /**
493
+ * The triggers that log `name`'s writes for every open capture (#2450). A write logs the rowid it
494
+ * touched (an update both the old and the new, should it move the row), once per capture however
495
+ * often the row is written, and clears the batch that served it, so it is served again. A table
496
+ * read by offset has no rowid to log, and logs `0`: the whole table is sent again.
497
+ *
498
+ * A row a `REPLACE` resolution deletes (`INSERT OR REPLACE`, `REPLACE INTO`, `UPDATE OR REPLACE`
499
+ * onto another key) fires no delete trigger while recursive triggers are off, so for each of
500
+ * `uniqueKeys` a BEFORE trigger also logs every row equal to the incoming one on that key: the row
501
+ * the resolution is about to delete. Logging a row that then survives is harmless (a batch reads
502
+ * every row as it is), so a partial index is matched without its WHERE.
503
+ *
504
+ * A delete and a plain insert rather than `INSERT OR REPLACE`: inside a trigger, the outer
505
+ * statement's conflict handling wins, so an upsert's update (the write revision's own bump is one)
506
+ * would turn the replace into an abort, and fail the write it logs.
507
+ */
508
+ function captureTriggers(name, byRowid, uniqueKeys) {
509
+ const tbl = quoteText(name);
510
+ const table = quoteIdent(name);
511
+ // Keyed on the log's primary key, copy first, so neither statement scans the log.
512
+ const clear = (rids) => `DELETE FROM _substrat_capture_changes WHERE copy_id IN (SELECT copy_id FROM _substrat_capture) AND tbl = ${tbl} AND rid IN (${rids}); `;
513
+ const log = (rid) => clear(rid) + `INSERT INTO _substrat_capture_changes (copy_id, tbl, rid, batch) SELECT copy_id, ${tbl}, ${rid}, NULL FROM _substrat_capture;`;
514
+ const logHeld = (key) => {
515
+ const match = key.map(({ column, collation }) => `t.${quoteIdent(column)} = NEW.${quoteIdent(column)} COLLATE ${quoteIdent(collation)}`).join(' AND ');
516
+ return (clear(`SELECT t.rowid FROM ${table} t WHERE ${match}`) +
517
+ `INSERT INTO _substrat_capture_changes (copy_id, tbl, rid, batch) SELECT c.copy_id, ${tbl}, t.rowid, NULL FROM _substrat_capture c, ${table} t WHERE ${match};`);
518
+ };
519
+ // Spelled as `sqlite_master` keeps it (no IF NOT EXISTS, one space after CREATE TRIGGER), so a
520
+ // trigger in place can be compared with the one this schema needs, text for text.
521
+ const on = (kind, when, event, body) => {
522
+ const trigger = `_substrat_capture_${kind}_${name}`;
523
+ return { name: trigger, sql: `CREATE TRIGGER ${quoteIdent(trigger)} ${when} ${event} ON ${table} BEGIN ${body} END` };
524
+ };
525
+ const triggers = [
526
+ on('i', 'AFTER', 'INSERT', log(byRowid ? 'NEW.rowid' : '0')),
527
+ on('u', 'AFTER', 'UPDATE', byRowid ? log('OLD.rowid') + ' ' + log('NEW.rowid') : log('0')),
528
+ on('d', 'AFTER', 'DELETE', log(byRowid ? 'OLD.rowid' : '0')),
529
+ ];
530
+ if (byRowid && uniqueKeys.length > 0) {
531
+ const held = uniqueKeys.map(logHeld).join(' ');
532
+ triggers.push(on('bi', 'BEFORE', 'INSERT', held), on('bu', 'BEFORE', 'UPDATE', held));
533
+ }
534
+ return triggers;
535
+ }
460
536
  /** The names under which SQLite answers a table's rowid, unless a column takes one. */
461
537
  const ROWID_NAMES = new Set(['rowid', '_rowid_', 'oid']);
462
538
  /**
@@ -527,6 +603,7 @@ class WriteRevision {
527
603
  storage;
528
604
  suspended;
529
605
  refusal;
606
+ beforeWrite;
530
607
  /** Whether the current run, in the current transaction scope, already holds a bump. */
531
608
  covered = false;
532
609
  /** Inside `bookkeeping`: the one path whose writes advance no revision. */
@@ -544,18 +621,33 @@ class WriteRevision {
544
621
  * reads) only costs a pass a re-check.
545
622
  */
546
623
  written = 0;
624
+ /** Inside `untracked`: a capture's own bookkeeping, which nothing here counts. */
625
+ bypass = false;
547
626
  sql;
548
627
  constructor(raw, storage, suspended,
549
628
  /** Why this store takes no write at all right now, or null (a `carried_away` copy, #1722). */
550
- refusal) {
629
+ refusal,
630
+ /** Run before every write statement (#2450: an idle capture's triggers go before the write). */
631
+ beforeWrite = () => undefined) {
551
632
  this.raw = raw;
552
633
  this.storage = storage;
553
634
  this.suspended = suspended;
554
635
  this.refusal = refusal;
636
+ this.beforeWrite = beforeWrite;
555
637
  const exec = (query, ...bindings) => {
638
+ if (this.bypass) {
639
+ // Enforced, not trusted, as `bookkeeping` is: a write here that is not the capture's own
640
+ // would be one the revision never saw.
641
+ if (this.isWrite(query) && !CAPTURE_STATEMENT.test(query)) {
642
+ throw substratError('internal', `the capture path takes only the capture's own statements (#2450), not: ${query.slice(0, 80)}`);
643
+ }
644
+ return raw.exec(query, ...bindings);
645
+ }
556
646
  const write = this.isWrite(query);
557
- if (write)
647
+ if (write) {
558
648
  this.written++;
649
+ this.beforeWrite();
650
+ }
559
651
  if (this.keeping && write) {
560
652
  // Bookkeeping takes the copy-marker insert, the lifecycle delivery (#1713) and the wire
561
653
  // nonce claim's own statements (RFC wire-signing §4) and nothing else: any other write here would be one the revision never saw, which is the hole this
@@ -603,6 +695,23 @@ class WriteRevision {
603
695
  this.keeping = was;
604
696
  }
605
697
  }
698
+ /**
699
+ * A copy's capture of this store (#2450): its log, its triggers and its clock. That is what this
700
+ * store records about a copy reading it, never its data, so it advances no write revision and
701
+ * meets no refusal: a capture must open, renew and end on a store whatever state it is in. Only
702
+ * the capture's own statements run here; the triggers it creates log the scope's writes, which
703
+ * still go through the counted path above.
704
+ */
705
+ untracked(run) {
706
+ const was = this.bypass;
707
+ this.bypass = true;
708
+ try {
709
+ return run();
710
+ }
711
+ finally {
712
+ this.bypass = was;
713
+ }
714
+ }
606
715
  /** `storage.transactionSync`, at a transaction boundary. */
607
716
  transactionSync(run) {
608
717
  this.covered = false;
@@ -938,7 +1047,7 @@ options = {}) {
938
1047
  // so every write advances the write revision a carry's restore is fenced on, inside the
939
1048
  // transaction that commits it. Nothing else here may take the raw handle or open a
940
1049
  // transaction on `ctx.storage` itself (`carried-copy.test.ts` in the kernel holds that).
941
- this.revision = new WriteRevision(ctx.storage.sql, ctx.storage, () => this.revisionSuspended, () => (this.carriedAwayCopy ? CARRIED_AWAY_WRITE_REFUSAL : null));
1050
+ this.revision = new WriteRevision(ctx.storage.sql, ctx.storage, () => this.revisionSuspended, () => (this.carriedAwayCopy ? CARRIED_AWAY_WRITE_REFUSAL : null), () => this.expireIdleCaptures());
942
1051
  this.sql = this.revision.sql;
943
1052
  for (const stmt of executableSqlStatements(KERNEL_DDL)) {
944
1053
  this.sql.exec(stmt);
@@ -946,6 +1055,12 @@ options = {}) {
946
1055
  this.applySpineColumnAdditions();
947
1056
  this.revisionSuspended = false;
948
1057
  this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
1058
+ // #2450: the triggers and tables an expiry emptied but could not drop go here, if no capture
1059
+ // is open, before any request runs.
1060
+ this.revision.untracked(() => {
1061
+ if (this.hasTable('_substrat_capture'))
1062
+ this.dropCaptureIfIdle();
1063
+ });
949
1064
  for (const registration of modules)
950
1065
  this.registerModule(registration);
951
1066
  for (const [name, handler] of Object.entries(bareOps))
@@ -5370,13 +5485,13 @@ options = {}) {
5370
5485
  * tables cannot be replayed — this host answers "object name reserved for internal use" — and
5371
5486
  * it is recomputable, so `importDump` rebuilds it from the loaded rows. A dump carries the rows,
5372
5487
  * never the index over them. A staged load's own tables (#2450) are this store's, not its data,
5373
- * and so is the wire nonce store (RFC wire-signing §4): a load builds it empty.
5488
+ * and so are a capture's (#2450) and the wire nonce store (RFC wire-signing §4): a load builds it empty.
5374
5489
  */
5375
5490
  dumpTableDefs() {
5376
5491
  return this.sql
5377
5492
  .exec(`SELECT name, sql FROM sqlite_master
5378
5493
  WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*'
5379
- AND name NOT GLOB '${STAGE_GLOB}' AND sql IS NOT NULL
5494
+ AND name NOT GLOB '${STAGE_GLOB}' AND name NOT GLOB '${CAPTURE_GLOB}' AND sql IS NOT NULL
5380
5495
  ORDER BY name`)
5381
5496
  .toArray().filter(({ name }) => !isSearchIndexTable(name) && !isWireNonceTable(name));
5382
5497
  }
@@ -5387,34 +5502,41 @@ options = {}) {
5387
5502
  * own; the pages are one consistent read only while `revision` stays the same, which the reader
5388
5503
  * compares. With `stamp`, the first page (`cursor` null) mints the load stamp, as
5389
5504
  * `exportDumpStamped` does; without it the export writes nothing, as `exportDump`.
5505
+ *
5506
+ * With `capture` (a copy's id), the pages need no quiet moment: the first page opens a capture
5507
+ * in the same turn it reads (`openCapture`), so every row written after it is logged for
5508
+ * `exportDumpChanges`, and every page names each row by its rowid (`rowids`), so a change can
5509
+ * replace or delete it at the destination. A later page of a capture that is gone is refused.
5390
5510
  */
5391
5511
  exportDumpPage(cursor, opts = {}) {
5392
5512
  // Answered as data, so a refusal's code survives the hop (#113).
5393
5513
  return replyOf(() => this.dumpPage(cursor, opts));
5394
5514
  }
5395
- dumpPage(cursor, { stamp = false, maxBytes = SCOPE_DUMP_PAGE_MAX_BYTES }) {
5515
+ dumpPage(cursor, { stamp = false, maxBytes = SCOPE_DUMP_PAGE_MAX_BYTES, capture }) {
5396
5516
  let loadStamp = this.loadStamp();
5397
5517
  if (stamp && cursor === null && !loadStamp && !this.carriedAwayCopy) {
5398
5518
  loadStamp = ulid();
5399
5519
  this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, LOAD_STAMP_KEY, loadStamp);
5400
5520
  }
5521
+ const defs = this.dumpTableDefs();
5522
+ if (capture !== undefined) {
5523
+ if (cursor === null)
5524
+ this.openCapture(capture, defs);
5525
+ else
5526
+ this.heldCapture(capture, defs);
5527
+ }
5401
5528
  const at = cursor === null ? null : decodeDumpCursor(cursor);
5402
5529
  const tables = [];
5403
5530
  let bytes = 0;
5404
5531
  let next = null;
5405
- for (const { name, sql } of this.dumpTableDefs()) {
5532
+ for (const { name, sql } of defs) {
5406
5533
  // Tables are read in name order: those before the cursor's are done. A cursor naming a
5407
5534
  // table that is gone resumes at the next name.
5408
5535
  if (at && name < at.table)
5409
5536
  continue;
5410
5537
  const after = at && name === at.table ? at.after : null;
5411
5538
  const { where, bindings } = dumpRowFilter(name);
5412
- // Read to its end (it holds no row), as every cursor here is: see the batches below.
5413
- const probe = this.sql.exec(`SELECT * FROM "${name}" WHERE 0`);
5414
- probe.toArray();
5415
- const columns = probe.columnNames;
5416
- // A table without a rowid, or with a column that takes the name, is read by offset.
5417
- const byRowid = !/\bWITHOUT\s+ROWID\b/i.test(sql) && !columns.some((c) => ROWID_NAMES.has(c.toLowerCase()));
5539
+ const { columns, byRowid } = this.tableShape(name, sql);
5418
5540
  // In batches, each read to its end: a cursor left part-read keeps its table locked, and
5419
5541
  // the next statement that drops or alters it fails with SQLITE_LOCKED.
5420
5542
  const batch = (from) => Array.from(!byRowid
@@ -5425,12 +5547,15 @@ options = {}) {
5425
5547
  .exec(`SELECT rowid, * FROM "${name}" WHERE (${where}) AND rowid > ? ORDER BY rowid LIMIT ?`, ...bindings, from, DUMP_READ_BATCH)
5426
5548
  .raw(), (raw) => raw);
5427
5549
  const rows = [];
5550
+ const rowids = [];
5428
5551
  let last = after;
5429
5552
  let full = false;
5430
5553
  for (;;) {
5431
5554
  const read = batch(last);
5432
5555
  for (const cells of read) {
5433
5556
  rows.push(byRowid ? cells.slice(1) : cells);
5557
+ if (byRowid)
5558
+ rowids.push(Number(cells[0]));
5434
5559
  last = byRowid ? Number(cells[0]) : (last ?? 0) + 1;
5435
5560
  bytes += rowBytes(rows[rows.length - 1]);
5436
5561
  if (bytes >= maxBytes) {
@@ -5441,7 +5566,7 @@ options = {}) {
5441
5566
  if (full || read.length < DUMP_READ_BATCH)
5442
5567
  break;
5443
5568
  }
5444
- tables.push({ name, ddl: sql, columns, rows });
5569
+ tables.push({ name, ddl: sql, columns, rows, ...(capture !== undefined && byRowid ? { rowids } : {}) });
5445
5570
  if (full) {
5446
5571
  next = encodeDumpCursor({ table: name, after: last ?? 0 });
5447
5572
  break;
@@ -5449,6 +5574,296 @@ options = {}) {
5449
5574
  }
5450
5575
  return { tables, next, loadStamp, revision: this.metaValue(WRITE_REVISION_KEY) };
5451
5576
  }
5577
+ /** A dumped table's columns, and whether its rows are read, and named, by rowid. */
5578
+ tableShape(name, sql) {
5579
+ // Read to its end (it holds no row), as every cursor here is: see `dumpPage`'s batches.
5580
+ const probe = this.sql.exec(`SELECT * FROM ${quoteIdent(name)} WHERE 0`);
5581
+ probe.toArray();
5582
+ const columns = probe.columnNames;
5583
+ // A table without a rowid, or with a column that takes the name, is read by offset.
5584
+ return { columns, byRowid: !/\bWITHOUT\s+ROWID\b/i.test(sql) && !columns.some((c) => ROWID_NAMES.has(c.toLowerCase())) };
5585
+ }
5586
+ /**
5587
+ * #2450: when the oldest open capture goes idle (epoch ms); null when none is open; undefined
5588
+ * when not read since the object woke or since the last expiry, so the next write reads it.
5589
+ */
5590
+ captureIdleAt = undefined;
5591
+ /**
5592
+ * Open (or, for page 0 asked again, renew) the capture `copyId` (#2450): its row, the log, and
5593
+ * the triggers that fill it on every dumped table. Through the untracked path: a capture is this
5594
+ * store's bookkeeping about a copy, not its data, so it moves no write revision, and no dump
5595
+ * carries it. Every trigger logs for every open capture, so one set serves overlapping copies.
5596
+ *
5597
+ * One set serving several captures means a capture opening must not vouch for one already open.
5598
+ * So, before its triggers are made:
5599
+ * - every other open capture is held to what `heldCapture` holds it to (the schema its pages read,
5600
+ * and every trigger it opened with still there) and ended here if it fails, because the triggers
5601
+ * about to be made again would hide a table rebuilt under it;
5602
+ * - the triggers in place must be exactly the ones this schema needs, text for text. If any
5603
+ * differ, are missing or are left over (an index added under an open capture, a drop that did
5604
+ * not run), every other open capture ends, as the triggers it relied on were not these, and the
5605
+ * whole set is dropped and made again.
5606
+ */
5607
+ openCapture(copyId, defs) {
5608
+ // Every trigger is worked out before anything is written, so a table a capture cannot follow
5609
+ // refuses it with nothing left behind.
5610
+ const triggers = defs.flatMap(({ name, sql }) => {
5611
+ const { byRowid } = this.tableShape(name, sql);
5612
+ return captureTriggers(name, byRowid, byRowid ? this.uniqueKeysOf(name) : []);
5613
+ });
5614
+ const schema = this.captureSchema(defs);
5615
+ this.revision.untracked(() => {
5616
+ for (const ddl of CAPTURE_TABLES_DDL)
5617
+ this.sql.exec(ddl);
5618
+ const inPlace = new Map(this.sql.exec(`SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND name GLOB '${CAPTURE_GLOB}'`).toArray().map((t) => [t.name, t.sql]));
5619
+ const others = this.sql.exec(`SELECT copy_id, schema, triggers FROM _substrat_capture WHERE copy_id <> ?`, copyId).toArray();
5620
+ for (const other of others) {
5621
+ if (other.schema !== schema || Number(other.triggers) !== inPlace.size)
5622
+ this.forgetCapture(other.copy_id);
5623
+ }
5624
+ const current = inPlace.size === triggers.length && triggers.every((t) => inPlace.get(t.name) === t.sql);
5625
+ if (!current) {
5626
+ this.sql.exec(`DELETE FROM _substrat_capture_changes WHERE copy_id <> ?`, copyId);
5627
+ this.sql.exec(`DELETE FROM _substrat_capture WHERE copy_id <> ?`, copyId);
5628
+ for (const name of inPlace.keys())
5629
+ this.sql.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(name)}`);
5630
+ for (const t of triggers)
5631
+ this.sql.exec(t.sql);
5632
+ }
5633
+ // Page 0 asked again starts the pages over: what was logged since its first read is still
5634
+ // to be sent, and is sent again at worst.
5635
+ this.sql.exec(`INSERT INTO _substrat_capture (copy_id, touched_at, schema, triggers) VALUES (?, ?, ?, ?)
5636
+ ON CONFLICT (copy_id) DO UPDATE SET touched_at = excluded.touched_at, schema = excluded.schema, triggers = excluded.triggers`, copyId, Date.now(), schema, triggers.length);
5637
+ });
5638
+ this.readCaptureClock();
5639
+ }
5640
+ /** Forget the capture `copyId` and its log (#2450), leaving the tables and triggers. Untracked, in the caller's run. */
5641
+ forgetCapture(copyId) {
5642
+ this.sql.exec(`DELETE FROM _substrat_capture_changes WHERE copy_id = ?`, copyId);
5643
+ this.sql.exec(`DELETE FROM _substrat_capture WHERE copy_id = ?`, copyId);
5644
+ }
5645
+ /**
5646
+ * The unique keys of dumped rowid table `name` (#2450), for the triggers that log a row a
5647
+ * `REPLACE` deletes. A unique index on an expression has no columns to match the incoming row
5648
+ * on, so a capture of its table is refused: that copy is taken at a quiet moment instead.
5649
+ */
5650
+ uniqueKeysOf(name) {
5651
+ const indexes = this.sql.exec(`SELECT name FROM pragma_index_list(?) WHERE "unique" = 1`, name).toArray();
5652
+ return indexes.map(({ name: index }) => {
5653
+ const key = this.sql
5654
+ .exec(`SELECT cid, name, coll FROM pragma_index_xinfo(?) WHERE key = 1 ORDER BY seqno`, index)
5655
+ .toArray();
5656
+ if (key.some((c) => Number(c.cid) < 0 || c.name === null)) {
5657
+ throw substratError('precondition_failed', `${CAPTURE_EXPRESSION_INDEX}: ${name} (${index})`);
5658
+ }
5659
+ return key.map((c) => ({ column: c.name, collation: c.coll }));
5660
+ });
5661
+ }
5662
+ /** What a capture's pages read the schema as (#2450): the dumped tables and their explicit indexes. */
5663
+ captureSchema(defs) {
5664
+ const names = new Set(defs.map((d) => d.name));
5665
+ const indexes = this.sql.exec(`SELECT tbl_name, name, sql FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL ORDER BY name`).toArray().filter((i) => names.has(i.tbl_name));
5666
+ return JSON.stringify({ tables: defs, indexes: indexes.map((i) => [i.name, i.sql]) });
5667
+ }
5668
+ /**
5669
+ * The capture `copyId`, renewed, as its pages read the schema (#2450). Refused when it is gone.
5670
+ * Ended and refused when a table or an index was added, dropped or altered since its first
5671
+ * page (no trigger logs a table it did not see, and a table altered under it no longer reads as
5672
+ * its pages did), or when a capture trigger is missing: a table dropped and made again with the
5673
+ * same schema took its triggers with it, and nothing logged its writes since.
5674
+ */
5675
+ heldCapture(copyId, defs) {
5676
+ const row = this.revision.untracked(() => this.hasTable('_substrat_capture')
5677
+ ? this.sql.exec(`SELECT schema, triggers FROM _substrat_capture WHERE copy_id = ?`, copyId).toArray()[0]
5678
+ : undefined);
5679
+ if (!row)
5680
+ throw substratError('precondition_failed', CAPTURE_GONE);
5681
+ const triggers = Number(this.sql.exec(`SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'trigger' AND name GLOB '${CAPTURE_GLOB}'`).toArray()[0].n);
5682
+ if (row.schema !== this.captureSchema(defs) || triggers !== Number(row.triggers)) {
5683
+ this.endCapture(copyId);
5684
+ throw substratError('precondition_failed', CAPTURE_SCHEMA_MOVED);
5685
+ }
5686
+ this.revision.untracked(() => this.sql.exec(`UPDATE _substrat_capture SET touched_at = ? WHERE copy_id = ?`, Date.now(), copyId));
5687
+ this.readCaptureClock();
5688
+ }
5689
+ /**
5690
+ * One batch of what was written since the capture `copyId` opened (#2450), read in one turn.
5691
+ *
5692
+ * Batch `seq` acknowledges every batch before it: their rows leave the log. A batch asked for
5693
+ * again under the same `seq` (its answer was lost) is served afresh, from the rows as they are
5694
+ * now. A row written after a batch served it is logged again, so a later batch serves it again.
5695
+ * Each row is read as it is now: present, it is put in place by its rowid; gone, it is deleted.
5696
+ * A table read by offset is logged as a whole and sent whole.
5697
+ *
5698
+ * The batch that leaves nothing in the log is `final`: read in the same turn as the log ran
5699
+ * empty, so nothing was written between it and the moment its `revision` names, and the capture
5700
+ * ends with it (its triggers too, when it was the last).
5701
+ */
5702
+ exportDumpChanges(copyId, seq, opts = {}) {
5703
+ return replyOf(() => this.captureChanges(copyId, seq, opts.maxBytes ?? SCOPE_DUMP_PAGE_MAX_BYTES));
5704
+ }
5705
+ captureChanges(copyId, seq, maxBytes) {
5706
+ if (!Number.isSafeInteger(seq) || seq < 0)
5707
+ throw substratError('validation_failed', 'a change batch is numbered from 0');
5708
+ const defs = this.dumpTableDefs();
5709
+ this.heldCapture(copyId, defs);
5710
+ const sqlOf = new Map(defs.map((d) => [d.name, d.sql]));
5711
+ return this.revision.untracked(() => {
5712
+ this.sql.exec(`DELETE FROM _substrat_capture_changes WHERE copy_id = ? AND batch < ?`, copyId, seq);
5713
+ this.sql.exec(`UPDATE _substrat_capture_changes SET batch = NULL WHERE copy_id = ? AND batch >= ?`, copyId, seq);
5714
+ const logged = this.sql
5715
+ .exec(`SELECT tbl, rid FROM _substrat_capture_changes WHERE copy_id = ? ORDER BY tbl, rid LIMIT ?`, copyId, CAPTURE_BATCH_ROWS)
5716
+ .toArray();
5717
+ const byTable = new Map();
5718
+ for (const { tbl, rid } of logged) {
5719
+ const rids = byTable.get(tbl) ?? [];
5720
+ rids.push(Number(rid));
5721
+ byTable.set(tbl, rids);
5722
+ }
5723
+ const tables = [];
5724
+ let bytes = 0;
5725
+ for (const [name, rids] of byTable) {
5726
+ if (bytes >= maxBytes)
5727
+ break;
5728
+ const ddl = sqlOf.get(name);
5729
+ const { columns, byRowid } = this.tableShape(name, ddl);
5730
+ const { where, bindings } = dumpRowFilter(name);
5731
+ const served = [];
5732
+ if (!byRowid) {
5733
+ const rows = Array.from(this.sql.exec(`SELECT * FROM ${quoteIdent(name)} WHERE ${where}`, ...bindings).raw(), (r) => r);
5734
+ for (const row of rows)
5735
+ bytes += rowBytes(row);
5736
+ tables.push({ name, ddl, columns, rows, deleted: [] });
5737
+ served.push(...rids);
5738
+ }
5739
+ else {
5740
+ const change = { name, ddl, columns, rows: [], rowids: [], deleted: [] };
5741
+ for (let i = 0; i < rids.length && bytes < maxBytes; i += CAPTURE_IN_CHUNK) {
5742
+ const chunk = rids.slice(i, i + CAPTURE_IN_CHUNK);
5743
+ const found = new Map();
5744
+ for (const cells of this.sql
5745
+ .exec(`SELECT rowid, * FROM ${quoteIdent(name)} WHERE (${where}) AND rowid IN (${chunk.map(() => '?').join(', ')})`, ...bindings, ...chunk)
5746
+ .raw()) {
5747
+ found.set(Number(cells[0]), cells.slice(1));
5748
+ }
5749
+ for (const rid of chunk) {
5750
+ if (bytes >= maxBytes)
5751
+ break;
5752
+ const row = found.get(rid);
5753
+ if (row) {
5754
+ change.rows.push(row);
5755
+ change.rowids.push(rid);
5756
+ bytes += rowBytes(row);
5757
+ }
5758
+ else {
5759
+ change.deleted.push(rid);
5760
+ bytes += 24;
5761
+ }
5762
+ served.push(rid);
5763
+ }
5764
+ }
5765
+ tables.push(change);
5766
+ }
5767
+ for (let i = 0; i < served.length; i += CAPTURE_IN_CHUNK) {
5768
+ const chunk = served.slice(i, i + CAPTURE_IN_CHUNK);
5769
+ this.sql.exec(`UPDATE _substrat_capture_changes SET batch = ? WHERE copy_id = ? AND tbl = ? AND rid IN (${chunk.map(() => '?').join(', ')})`, seq, copyId, name, ...chunk);
5770
+ }
5771
+ }
5772
+ const left = this.sql.exec(`SELECT 1 FROM _substrat_capture_changes WHERE copy_id = ? AND batch IS NULL LIMIT 1`, copyId).toArray();
5773
+ const final = left.length === 0;
5774
+ if (final)
5775
+ this.endCapture(copyId);
5776
+ return { tables, final, revision: this.metaValue(WRITE_REVISION_KEY) };
5777
+ });
5778
+ }
5779
+ /**
5780
+ * End the capture `copyId` (#2450), if it is open: a copy that finished, failed or was cancelled
5781
+ * calls it. The last capture to end takes the log and every trigger with it.
5782
+ */
5783
+ endDumpCapture(copyId) {
5784
+ return { ended: this.endCapture(copyId) };
5785
+ }
5786
+ endCapture(copyId) {
5787
+ const ended = this.revision.untracked(() => {
5788
+ if (!this.hasTable('_substrat_capture'))
5789
+ return false;
5790
+ this.sql.exec(`DELETE FROM _substrat_capture_changes WHERE copy_id = ?`, copyId);
5791
+ const gone = this.sql.exec(`DELETE FROM _substrat_capture WHERE copy_id = ? RETURNING copy_id`, copyId).toArray().length > 0;
5792
+ this.dropCaptureIfIdle();
5793
+ return gone;
5794
+ });
5795
+ this.readCaptureClock();
5796
+ return ended;
5797
+ }
5798
+ /** With no capture left open, drop the log, the capture table and every capture trigger. */
5799
+ dropCaptureIfIdle() {
5800
+ if (this.sql.exec(`SELECT 1 FROM _substrat_capture LIMIT 1`).toArray().length > 0)
5801
+ return;
5802
+ const triggers = this.sql
5803
+ .exec(`SELECT name FROM sqlite_master WHERE type = 'trigger' AND name GLOB '${CAPTURE_GLOB}'`)
5804
+ .toArray();
5805
+ for (const { name } of triggers)
5806
+ this.sql.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(name)}`);
5807
+ this.sql.exec(`DROP TABLE IF EXISTS _substrat_capture_changes`);
5808
+ this.sql.exec(`DROP TABLE IF EXISTS _substrat_capture`);
5809
+ }
5810
+ /** Read when the oldest open capture goes idle (`captureIdleAt`). */
5811
+ readCaptureClock() {
5812
+ const oldest = this.revision.untracked(() => this.hasTable('_substrat_capture')
5813
+ ? this.sql.exec(`SELECT MIN(touched_at) AS t FROM _substrat_capture`).toArray()[0]?.t ?? null
5814
+ : null);
5815
+ this.captureIdleAt = oldest === null ? null : Number(oldest) + CAPTURE_IDLE_MS;
5816
+ }
5817
+ /**
5818
+ * Run before every write (#2450, the write revision's `beforeWrite`): a capture nobody has asked
5819
+ * for in `CAPTURE_IDLE_MS` belongs to a copy that stopped, so it ends here, before the write its
5820
+ * triggers would log. Once the clock is read this is one comparison per write; the clock is read
5821
+ * on the first write after a wake, a load, or an expiry.
5822
+ *
5823
+ * No DDL here: the write this runs before may hold a read cursor open (a module iterating a
5824
+ * table and writing as it goes), and a DROP under one fails that write (SQLITE_LOCKED). Only the
5825
+ * idle captures' rows go; a trigger with no capture to log for logs nothing, and the triggers and
5826
+ * tables are dropped in a turn of their own (`dropIdleCaptureLater`).
5827
+ */
5828
+ expireIdleCaptures() {
5829
+ if (this.captureIdleAt === undefined)
5830
+ this.readCaptureClock();
5831
+ if (this.captureIdleAt === null || this.captureIdleAt === undefined || Date.now() < this.captureIdleAt)
5832
+ return;
5833
+ this.revision.untracked(() => {
5834
+ // A load took the capture's tables with it: there is nothing left to end.
5835
+ if (!this.hasTable('_substrat_capture'))
5836
+ return;
5837
+ const idle = this.sql
5838
+ .exec(`SELECT copy_id FROM _substrat_capture WHERE touched_at <= ?`, Date.now() - CAPTURE_IDLE_MS)
5839
+ .toArray();
5840
+ for (const { copy_id } of idle)
5841
+ this.forgetCapture(copy_id);
5842
+ });
5843
+ // Read again at the next write, not now: should the write's own transaction roll back, the
5844
+ // capture is back, and the next write ends it again.
5845
+ this.captureIdleAt = undefined;
5846
+ this.dropIdleCaptureLater();
5847
+ }
5848
+ /**
5849
+ * Drop the capture's triggers and tables once no capture is open (#2450), after the current
5850
+ * turn: never under the write that ended the last capture. Best effort: one that fails (a cursor
5851
+ * still open across an await) leaves them to the next capture call or the next wake, and a
5852
+ * trigger with no capture to log for costs a write only an empty lookup meanwhile.
5853
+ */
5854
+ dropIdleCaptureLater() {
5855
+ setTimeout(() => {
5856
+ try {
5857
+ this.revision.untracked(() => {
5858
+ if (this.hasTable('_substrat_capture'))
5859
+ this.dropCaptureIfIdle();
5860
+ });
5861
+ }
5862
+ catch {
5863
+ // Left to the next capture call or wake, as above.
5864
+ }
5865
+ }, 0);
5866
+ }
5452
5867
  /**
5453
5868
  * `exportDump`, with the store's load stamp read in the same call (#1722): the stamp a
5454
5869
  * carry's fenced wipe of this copy later expects. A store no load has stamped gets one now,
@@ -5918,6 +6333,8 @@ options = {}) {
5918
6333
  }
5919
6334
  });
5920
6335
  this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
6336
+ // #2450: the drops took any capture with them; its clock is read again at the next write.
6337
+ this.captureIdleAt = undefined;
5921
6338
  // #1335 / #1686: the outbox arrived with the dump, so this DO's event ids resume above it,
5922
6339
  // as on a wake. A copy's own events then sort above every copied one, which is what
5923
6340
  // `emittedHere()` relies on. A dump whose top id is no ULID leaves the floor where it was.
@@ -6073,9 +6490,6 @@ options = {}) {
6073
6490
  if (seq > head.nextSeq) {
6074
6491
  throw substratError('precondition_failed', `stage page ${seq} arrived before page ${head.nextSeq}`);
6075
6492
  }
6076
- // #2314: a page carrying an attachment row this store condemned is refused here, at the
6077
- // page, as the commit refuses it again before its first drop (`loadTables`).
6078
- refuseCondemnedInDump(doSpineSql(this.sql), tables);
6079
6493
  for (const t of tables) {
6080
6494
  const columns = JSON.stringify(t.columns);
6081
6495
  let held = this.sql
@@ -6091,13 +6505,81 @@ options = {}) {
6091
6505
  else if (held.ddl !== t.ddl || held.columns !== columns) {
6092
6506
  throw substratError('precondition_failed', `table ${t.name} changed between dump pages; nothing more was staged`);
6093
6507
  }
6094
- const insert = `INSERT INTO "${stageRowsTable(held.ord)}" VALUES (${t.columns.map(() => '?').join(', ')})`;
6095
- for (const row of t.rows) {
6096
- if (row.length !== t.columns.length) {
6097
- throw substratError('validation_failed', `a row of ${t.name} has ${row.length} cells for ${t.columns.length} columns`);
6098
- }
6099
- this.sql.exec(insert, ...row);
6508
+ this.stageRows(held.ord, t);
6509
+ }
6510
+ this.sql.exec(`UPDATE _substrat_stage SET next_seq = ?`, seq + 1);
6511
+ return { staged: true };
6512
+ });
6513
+ }
6514
+ finally {
6515
+ this.revisionSuspended = false;
6516
+ }
6517
+ }
6518
+ /**
6519
+ * Put a fragment's rows into staged table `ord` (#2450). With `rowids` each row is staged under
6520
+ * its source rowid, replacing the one staged there: a capture's page and its changes name rows
6521
+ * that way, and the commit reads the stage in rowid order, so the rows load in the source's order.
6522
+ */
6523
+ stageRows(ord, t) {
6524
+ // #2314: a fragment carrying an attachment row this store condemned is refused here, as it
6525
+ // arrives, and the commit refuses it again before its first drop (`loadTables`).
6526
+ refuseCondemnedInDump(doSpineSql(this.sql), [t]);
6527
+ if (t.rowids && t.rowids.length !== t.rows.length) {
6528
+ throw substratError('validation_failed', `${t.name} has ${t.rowids.length} rowids for ${t.rows.length} rows`);
6529
+ }
6530
+ const marks = t.columns.map(() => '?').join(', ');
6531
+ const insert = t.rowids
6532
+ ? `INSERT OR REPLACE INTO "${stageRowsTable(ord)}" (rowid, ${t.columns.map((_, i) => `c${i}`).join(', ')}) VALUES (?, ${marks})`
6533
+ : `INSERT INTO "${stageRowsTable(ord)}" VALUES (${marks})`;
6534
+ for (const [i, row] of t.rows.entries()) {
6535
+ if (row.length !== t.columns.length) {
6536
+ throw substratError('validation_failed', `a row of ${t.name} has ${row.length} cells for ${t.columns.length} columns`);
6537
+ }
6538
+ if (t.rowids)
6539
+ this.sql.exec(insert, t.rowids[i], ...row);
6540
+ else
6541
+ this.sql.exec(insert, ...row);
6542
+ }
6543
+ }
6544
+ /**
6545
+ * Apply one batch of a capture's changes to the stage (#2450): numbered in the same sequence as
6546
+ * the stage's pages, after the last of them, and as idempotent: a batch already applied answers
6547
+ * `staged: false`. A change with `rowids` puts its rows in place and deletes `deleted` by rowid;
6548
+ * one without replaces its table whole. A table no page staged, or staged with another schema,
6549
+ * is refused: the source's schema moved, and the copy starts again.
6550
+ */
6551
+ stageDumpChanges(stageId, seq, tables) {
6552
+ return replyOf(() => this.stageChanges(stageId, seq, tables));
6553
+ }
6554
+ stageChanges(stageId, seq, tables) {
6555
+ if (!Number.isSafeInteger(seq) || seq < 1)
6556
+ throw substratError('validation_failed', 'a change batch follows a stage\'s first page');
6557
+ assertReplayableDump(tables, { maxColumns: DO_SQL_LIMITS.columns });
6558
+ this.revisionSuspended = true;
6559
+ try {
6560
+ return this.revision.transactionSync(() => {
6561
+ const head = this.stageHead();
6562
+ if (head?.stageId !== stageId)
6563
+ throw substratError('precondition_failed', STAGE_GONE);
6564
+ if (seq < head.nextSeq)
6565
+ return { staged: false };
6566
+ if (seq > head.nextSeq)
6567
+ throw substratError('precondition_failed', `stage page ${seq} arrived before page ${head.nextSeq}`);
6568
+ for (const t of tables) {
6569
+ const held = this.sql
6570
+ .exec(`SELECT ord, ddl, columns FROM _substrat_stage_tables WHERE name = ?`, t.name)
6571
+ .toArray()[0];
6572
+ if (!held || held.ddl !== t.ddl || held.columns !== JSON.stringify(t.columns)) {
6573
+ throw substratError('precondition_failed', `table ${t.name} changed since its pages were staged; nothing more was staged`);
6574
+ }
6575
+ const rows = `"${stageRowsTable(held.ord)}"`;
6576
+ if (!t.rowids)
6577
+ this.sql.exec(`DELETE FROM ${rows}`);
6578
+ for (let i = 0; i < t.deleted.length; i += CAPTURE_IN_CHUNK) {
6579
+ const chunk = t.deleted.slice(i, i + CAPTURE_IN_CHUNK);
6580
+ this.sql.exec(`DELETE FROM ${rows} WHERE rowid IN (${chunk.map(() => '?').join(', ')})`, ...chunk);
6100
6581
  }
6582
+ this.stageRows(held.ord, t);
6101
6583
  }
6102
6584
  this.sql.exec(`UPDATE _substrat_stage SET next_seq = ?`, seq + 1);
6103
6585
  return { staged: true };
@@ -6149,11 +6631,14 @@ options = {}) {
6149
6631
  }
6150
6632
  /** One staged table's rows, in the order they were staged, a batch at a time. */
6151
6633
  *stagedRows(ord) {
6152
- let after = 0;
6634
+ // No lower bound on the first batch: a row staged under its source rowid (#2450) may sit at 0
6635
+ // or below.
6636
+ let after = null;
6153
6637
  for (;;) {
6154
- const batch = Array.from(this.sql
6155
- .exec(`SELECT rowid, * FROM "${stageRowsTable(ord)}" WHERE rowid > ? ORDER BY rowid LIMIT ?`, after, STAGE_READ_BATCH)
6156
- .raw(), (row) => row);
6638
+ const table = `"${stageRowsTable(ord)}"`;
6639
+ const batch = Array.from((after === null
6640
+ ? this.sql.exec(`SELECT rowid, * FROM ${table} ORDER BY rowid LIMIT ?`, STAGE_READ_BATCH)
6641
+ : this.sql.exec(`SELECT rowid, * FROM ${table} WHERE rowid > ? ORDER BY rowid LIMIT ?`, after, STAGE_READ_BATCH)).raw(), (row) => row);
6157
6642
  for (const row of batch)
6158
6643
  yield row.slice(1);
6159
6644
  if (batch.length < STAGE_READ_BATCH)