@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/host.d.ts +35 -4
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +19 -1
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +512 -27
- package/dist/scope-do.js.map +1 -1
- package/package.json +9 -9
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
6095
|
-
|
|
6096
|
-
|
|
6097
|
-
|
|
6098
|
-
|
|
6099
|
-
|
|
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
|
-
|
|
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
|
|
6155
|
-
|
|
6156
|
-
.
|
|
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)
|