@g9tm/syncdata 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/PARITY.md +26 -2
  2. package/dist/abstractions/errors.d.ts +4 -0
  3. package/dist/abstractions/errors.js +4 -0
  4. package/dist/abstractions/errors.js.map +1 -1
  5. package/dist/client/engine.js +93 -51
  6. package/dist/client/engine.js.map +1 -1
  7. package/dist/client/runs.d.ts +5 -0
  8. package/dist/client/runs.js +8 -7
  9. package/dist/client/runs.js.map +1 -1
  10. package/dist/client/storeTypes.d.ts +22 -1
  11. package/dist/client/storeTypes.js +27 -0
  12. package/dist/client/storeTypes.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/node/index.d.ts +1 -0
  17. package/dist/node/index.js +1 -0
  18. package/dist/node/index.js.map +1 -1
  19. package/dist/node/snapshotFileSink.d.ts +3 -0
  20. package/dist/node/snapshotFileSink.js +79 -0
  21. package/dist/node/snapshotFileSink.js.map +1 -0
  22. package/dist/protocol/dtos.d.ts +76 -0
  23. package/dist/protocol/dtos.js +2 -0
  24. package/dist/protocol/dtos.js.map +1 -1
  25. package/dist/protocol/wire.d.ts +3 -1
  26. package/dist/protocol/wire.js +49 -0
  27. package/dist/protocol/wire.js.map +1 -1
  28. package/dist/sqlite/apply.d.ts +21 -1
  29. package/dist/sqlite/apply.js +218 -114
  30. package/dist/sqlite/apply.js.map +1 -1
  31. package/dist/sqlite/connection.d.ts +5 -0
  32. package/dist/sqlite/connection.js +17 -0
  33. package/dist/sqlite/connection.js.map +1 -1
  34. package/dist/sqlite/index.d.ts +2 -1
  35. package/dist/sqlite/index.js +1 -0
  36. package/dist/sqlite/index.js.map +1 -1
  37. package/dist/sqlite/options.d.ts +39 -1
  38. package/dist/sqlite/options.js +5 -1
  39. package/dist/sqlite/options.js.map +1 -1
  40. package/dist/sqlite/push.js +4 -1
  41. package/dist/sqlite/push.js.map +1 -1
  42. package/dist/sqlite/query.js +44 -15
  43. package/dist/sqlite/query.js.map +1 -1
  44. package/dist/sqlite/revocation.d.ts +1 -1
  45. package/dist/sqlite/revocation.js +4 -2
  46. package/dist/sqlite/revocation.js.map +1 -1
  47. package/dist/sqlite/snapshotFiles.d.ts +51 -0
  48. package/dist/sqlite/snapshotFiles.js +516 -0
  49. package/dist/sqlite/snapshotFiles.js.map +1 -0
  50. package/dist/sqlite/sql.d.ts +1 -1
  51. package/dist/sqlite/sql.js +4 -0
  52. package/dist/sqlite/sql.js.map +1 -1
  53. package/dist/sqlite/store.d.ts +20 -2
  54. package/dist/sqlite/store.js +147 -78
  55. package/dist/sqlite/store.js.map +1 -1
  56. package/package.json +1 -1
@@ -14,11 +14,12 @@ import { G9ApplyReport, G9ERevocationReason, REASON_TABLE_NOT_EMPTY } from '../c
14
14
  import { G9EPullPartKind } from '../protocol/dtos.js';
15
15
  import { G9ERowBatchOp, G9RowBatchFormatError } from '../protocol/rowBatch/format.js';
16
16
  import { openRowBatch } from '../protocol/rowBatch/reader.js';
17
- import { asNumber, asString, G9StatementBatch } from './connection.js';
17
+ import { asNumber, asString, G9StatementBatch, statementsOf } from './connection.js';
18
18
  import { findByServerName, keyValue, meta, nowMs, persistedJson, readPersistedDefinitions, readSubscriptions } from './internals.js';
19
- import { disposeReleasedRow, REVOKED_REJECTION_CODE } from './revocation.js';
19
+ import { disposeReleasedRow, disposeReleasedRows, REVOKED_REJECTION_CODE } from './revocation.js';
20
20
  import { bit, EPHEMERAL_SLOT, quote } from './sql.js';
21
21
  import { frameValueToSql } from './values.js';
22
+ import { applySnapshotFile, attachedSchema, attachFiles, deleteFiles, fileSnapshotTimes, readStagedFiles, takeStagedFiles, verifyStagedFile, } from './snapshotFiles.js';
22
23
  const NEXT_SEQ = '(SELECT seq FROM g9_clock WHERE id = 1) + 1';
23
24
  const BUMP_IF_CHANGED = 'UPDATE g9_clock SET seq = seq + 1 WHERE id = 1 AND changes() > 0';
24
25
  const BUMP = 'UPDATE g9_clock SET seq = seq + 1 WHERE id = 1';
@@ -35,6 +36,8 @@ export class ApplyContext {
35
36
  this.droppedTriggers = [];
36
37
  this.rows = 0;
37
38
  this.hasRevoked = false;
39
+ /** The database holds a trigger the library did not create: bulk disposals would not be exact (row by row then). */
40
+ this.hasForeignTriggers = false;
38
41
  this._dirtyTables = new Set();
39
42
  this._tableTime = new Map();
40
43
  this._tableNames = new Map();
@@ -58,6 +61,17 @@ export class ApplyContext {
58
61
  const keys = this.report.changedKeys[table];
59
62
  return !keys || keys.length < this._maxChangedKeys;
60
63
  }
64
+ get maxChangedKeys() {
65
+ return this._maxChangedKeys;
66
+ }
67
+ /** Counts rows applied in bulk (a snapshot file's table) and reports progress once for them. */
68
+ advance(rows) {
69
+ if (rows <= 0)
70
+ return;
71
+ this.rows += rows;
72
+ if (this._progress)
73
+ this._progress(this.rows);
74
+ }
61
75
  addChanged(table, key) {
62
76
  var _a;
63
77
  const keys = (_a = this.report.changedKeys[table]) !== null && _a !== void 0 ? _a : (this.report.changedKeys[table] = []);
@@ -135,8 +149,9 @@ export async function beginApply(store, connection, options) {
135
149
  context.backfillColumns.set(backfill.tableId, backfill.columns);
136
150
  const diagnostics = context.report.diagnostics;
137
151
  diagnostics.transactions = 1;
152
+ const foreign = await foreignTriggerNames(store, connection);
153
+ context.hasForeignTriggers = foreign.length > 0;
138
154
  if (store.options.skipTriggersDuringApply) {
139
- const foreign = await foreignTriggerNames(store, connection);
140
155
  if (foreign.length > 0) {
141
156
  for (const name of foreign.slice(0, 10))
142
157
  diagnostics.foreignTriggers.push(name);
@@ -197,12 +212,25 @@ export async function foreignTriggerNames(store, connection) {
197
212
  }
198
213
  return foreign;
199
214
  }
200
- /** Rows changed after a sequence, table by table (`g9_rows_feed (tid, local_seq)` serves each read). */
215
+ /**
216
+ * Rows changed after a sequence, table by table (`g9_rows_feed (tid, local_seq)` serves each part) — as ONE statement:
217
+ * a query per table cost a bridge call per table on every apply (LES-0055).
218
+ */
201
219
  export async function readRowsChangedAfter(store, connection, sequence, add) {
202
- for (const table of store.byLocal.values()) {
203
- for (const r of await connection.query('SELECT pk FROM g9_rows WHERE tid = $t AND local_seq > $c', { $t: table.tid, $c: sequence }))
204
- add(table.tid, asString(r[0]));
205
- }
220
+ const tids = Array.from(store.byLocal.values()).map((table) => table.tid);
221
+ // SQLite allows 500 terms in a compound SELECT: one statement per 400 tables, all in one driver call.
222
+ const statements = [];
223
+ for (let i = 0; i < tids.length; i += 400) {
224
+ const sql = tids
225
+ .slice(i, i + 400)
226
+ .map((tid) => `SELECT ${tid} AS tid, pk FROM g9_rows WHERE tid = ${tid} AND local_seq > $c`)
227
+ .join(' UNION ALL ');
228
+ for (const statement of statementsOf(sql, { $c: sequence }))
229
+ statements.push(statement);
230
+ }
231
+ for (const result of await connection.runStatements(statements))
232
+ for (const r of result.rows)
233
+ add(asNumber(r[0]), asString(r[1]));
206
234
  }
207
235
  /** Rows each table receives from snapshot parts of a plan, by server table id. */
208
236
  export function snapshotRowsByTable(plan) {
@@ -241,13 +269,25 @@ async function deferIndexes(store, context, snapshotRows) {
241
269
  store.log('debug', 20111, `G9SyncData loads ${rows} row(s) into ${table.model.localName}: ${indexes.length} index(es) rebuilt after the load`);
242
270
  }
243
271
  }
272
+ /**
273
+ * What `downloadDurability` reads of a connection, read ONCE per connection: the store's connections set their journal
274
+ * mode when they open and nobody else touches their `synchronous`, so asking again for every staged chunk only cost two
275
+ * bridge calls per chunk (LES-0056).
276
+ */
277
+ const durabilityOf = new WeakMap();
244
278
  /** `synchronous=NORMAL` while a transaction writes only server-derived data (WAL only); returns the restore action. */
245
279
  export async function downloadDurability(store, connection) {
246
280
  if (!store.options.synchronousNormalForDownloads)
247
281
  return null;
248
- if (asString(await connection.scalar('PRAGMA journal_mode')).toLowerCase() !== 'wal')
282
+ let known = durabilityOf.get(connection);
283
+ if (!known) {
284
+ const wal = asString(await connection.scalar('PRAGMA journal_mode')).toLowerCase() === 'wal';
285
+ known = { wal, synchronous: wal ? asNumber(await connection.scalar('PRAGMA synchronous')) : 0 };
286
+ durabilityOf.set(connection, known);
287
+ }
288
+ if (!known.wal)
249
289
  return null;
250
- const previous = asNumber(await connection.scalar('PRAGMA synchronous'));
290
+ const previous = known.synchronous;
251
291
  if (previous <= 1)
252
292
  return null;
253
293
  await connection.exec('PRAGMA synchronous = NORMAL');
@@ -287,12 +327,14 @@ export async function applyFrame(store, context, frame, subscriptionKey) {
287
327
  while (reader.readRow())
288
328
  await applyTombstone(context, table, reader.key, reader.version);
289
329
  break;
290
- case G9ERowBatchOp.MoveOut:
291
- while (reader.readRow()) {
292
- if (slot >= 0)
293
- await moveOut(store, context, table, reader.key, subscriptionKey, slot, ephemeral, policy);
294
- }
330
+ case G9ERowBatchOp.MoveOut: {
331
+ const keys = [];
332
+ while (reader.readRow())
333
+ keys.push(reader.key);
334
+ if (slot >= 0)
335
+ await moveOutMany(store, context, table, keys, subscriptionKey, slot, ephemeral, policy);
295
336
  break;
337
+ }
296
338
  }
297
339
  context.addTableTime(table.model.localName, context.rows - rowsBefore, Date.now() - started);
298
340
  }
@@ -446,6 +488,34 @@ export async function moveOut(store, context, table, key, subscriptionKey, slot,
446
488
  if (removed)
447
489
  context.addChanged(table.model.localName, key);
448
490
  }
491
+ /**
492
+ * A run of move-outs of one table (a MoveOut section of a frame, or a reconciliation's move-out keys), with the database
493
+ * state the row-by-row {@link moveOut} leaves. Row by row a released row costs ~8 driver calls (flush, read metadata,
494
+ * commands, the app row, the writes, the clock): ~34 ms per row on the Android emulator, minutes for a large office
495
+ * change. Here every membership update is queued and flushed once, then the released rows are disposed in bulk by
496
+ * disposeReleasedRows — read in three queries, decided in the same order, written in batches with each row's explicit
497
+ * local_seq (the clock's value the row-by-row path gives it), the clock moved once by the number of rows. Exact for the
498
+ * same reasons as an unsubscribe (ADR-0071 addendum) plus one: the keys of a run are distinct, so no row's membership
499
+ * update can change another row's decision. Ephemeral subscriptions, a Keep policy, duplicate keys or a foreign trigger
500
+ * take the row-by-row path.
501
+ */
502
+ export async function moveOutMany(store, context, table, keys, subscriptionKey, slot, ephemeral, policy) {
503
+ if (keys.length === 0)
504
+ return;
505
+ const distinct = new Set(keys).size === keys.length;
506
+ if (keys.length === 1 || ephemeral || policy !== G9EMoveOutPolicy.Delete || !distinct || context.hasForeignTriggers) {
507
+ for (const key of keys)
508
+ await moveOut(store, context, table, key, subscriptionKey, slot, ephemeral, policy);
509
+ return;
510
+ }
511
+ const notBit = bit(slot).not();
512
+ for (const key of keys)
513
+ await context.queue('UPDATE g9_rows SET subs = subs & $n WHERE tid = $t AND pk = $pk', { $n: notBit, $t: table.tid, $pk: key });
514
+ context.report.moveOuts += keys.length;
515
+ // The disposal reads the rows: run what is queued (this run's membership updates and everything the pull queued before).
516
+ await context.flush();
517
+ await disposeReleasedRows(store, context.connection, 'SELECT $tid AS tid, j.value AS pk FROM json_each($keys) AS j ORDER BY j.key', { $tid: table.tid, $keys: JSON.stringify(keys) }, G9ERevocationReason.LeftScope, subscriptionKey, context.report, (_tid, key) => context.addChanged(table.model.localName, key));
518
+ }
449
519
  /** `AddMembership`. */
450
520
  export async function addMembership(context, table, key, slot, ephemeralKey) {
451
521
  context.batch.add('UPDATE g9_rows SET subs = subs | $b WHERE tid = $t AND pk = $pk AND deleted = 0', { $b: bit(slot), $t: table.tid, $pk: key });
@@ -474,110 +544,145 @@ export async function applyStagedPull(store, runId, rowsApplied, slices, readSta
474
544
  if (!staged)
475
545
  throw new G9SyncException(G9SyncErrorCodes.LocalStoreError, 'The staged pull no longer exists.');
476
546
  const sequences = (await connection.query('SELECT seq FROM g9_stage WHERE run_id = $r AND payload IS NOT NULL ORDER BY seq', { $r: runId })).map((r) => asNumber(r[0]));
477
- // Slicing only pays for itself when someone is listening for the slices (ADR-0053); a LARGE snapshot stays in one
478
- // transaction with its indexes deferred (ADR-0056/0058).
479
- const sliceRows = slices === null ? 0 : store.options.applySliceRows;
480
- const snapshotRows = snapshotRowsByTable(staged.plan);
481
- let largeSnapshot = false;
482
- if (store.options.rebuildIndexesAfterRows > 0)
483
- snapshotRows.forEach((rows) => (largeSnapshot = largeSnapshot || rows >= store.options.rebuildIndexesAfterRows));
484
- const progressive = sliceRows > 0 && sequences.length > 1 && !largeSnapshot;
485
- // Read ONCE, before any slice commits (ADR-0046 + ADR-0053).
486
- const protectedRows = progressive ? await readProtectedRows(store, connection, staged.stageSequence) : null;
487
- const skipTriggers = staged.plan.totalRows >= store.options.skipTriggersMinRows && !progressive;
488
- const total = new G9ApplyReport();
489
- let appliedTotal = 0;
490
- let index = 0;
491
- const started = Date.now();
492
- let sliceBudget = sliceRows;
493
- // do/while: a pull with no payload chunks still writes its cursors and clears its stage, exactly once.
494
- do {
495
- if (signal && signal.aborted)
496
- throw cancellation();
497
- const slice = [];
498
- let final = true;
499
- for (; index < sequences.length; index++) {
500
- slice.push(sequences[index]);
501
- if (!progressive)
502
- continue;
503
- if ((await stagedRowCount(connection, runId, slice)) >= sliceBudget && index + 1 < sequences.length) {
504
- index++;
505
- final = false;
506
- break;
507
- }
508
- }
509
- if (store.options.applySliceRowsMax > sliceBudget)
510
- sliceBudget = Math.min(store.options.applySliceRowsMax, sliceBudget * 2);
511
- let context = null;
512
- const syncedUpdates = [];
513
- await connection.transaction('immediate', async () => {
514
- var _a;
515
- context = await beginApply(store, connection, {
516
- progress: rowsApplied,
517
- backfills: staged.backfills,
518
- snapshotRows: progressive ? null : snapshotRows,
519
- changedAfterSequence: staged.stageSequence,
520
- protectedRows,
521
- skipTriggers,
522
- signal,
523
- });
524
- for (const sequence of slice) {
525
- if (signal && signal.aborted)
526
- throw cancellation();
527
- const rows = await connection.query('SELECT payload, subscription_key FROM g9_stage WHERE run_id = $r AND seq = $s', { $r: runId, $s: sequence });
528
- const row = rows[0];
529
- if (!row)
530
- continue;
531
- const payload = row[0];
532
- if (!(payload instanceof ArrayBuffer))
547
+ // Snapshot files (ADR-0072) apply where their part sits in the plan: at the sequence of their first segment. Checked and
548
+ // ATTACHed before the transaction (SQLite cannot attach inside one), detached whatever happens.
549
+ const files = await readStagedFiles(connection, runId);
550
+ for (const file of files)
551
+ await verifyStagedFile(store, file);
552
+ const units = sequences.map((sequence) => ({ sequence, file: -1 }));
553
+ files.forEach((file, i) => units.push({ sequence: file.firstSequence, file: i }));
554
+ units.sort((a, b) => a.sequence - b.sequence || a.file - b.file);
555
+ const detach = await attachFiles(connection, files);
556
+ try {
557
+ // Slicing only pays for itself when someone is listening for the slices (ADR-0053); a LARGE snapshot stays in one
558
+ // transaction with its indexes deferred (ADR-0056/0058).
559
+ const sliceRows = slices === null ? 0 : store.options.applySliceRows;
560
+ const snapshotRows = snapshotRowsByTable(staged.plan);
561
+ let largeSnapshot = files.length > 0;
562
+ if (store.options.rebuildIndexesAfterRows > 0)
563
+ snapshotRows.forEach((rows) => (largeSnapshot = largeSnapshot || rows >= store.options.rebuildIndexesAfterRows));
564
+ const progressive = sliceRows > 0 && units.length > 1 && !largeSnapshot;
565
+ // Read ONCE, before any slice commits (ADR-0046 + ADR-0053).
566
+ const protectedRows = progressive ? await readProtectedRows(store, connection, staged.stageSequence) : null;
567
+ const skipTriggers = staged.plan.totalRows >= store.options.skipTriggersMinRows && !progressive;
568
+ const total = new G9ApplyReport();
569
+ let appliedTotal = 0;
570
+ let index = 0;
571
+ const started = Date.now();
572
+ let sliceBudget = sliceRows;
573
+ // do/while: a pull with no payload chunks still writes its cursors and clears its stage, exactly once.
574
+ do {
575
+ if (signal && signal.aborted)
576
+ throw cancellation();
577
+ const slice = [];
578
+ let final = true;
579
+ for (; index < units.length; index++) {
580
+ slice.push(units[index]);
581
+ if (!progressive)
533
582
  continue;
534
- await applyFrame(store, context, new Uint8Array(payload), row[1] === null ? null : asString(row[1]));
583
+ if ((await stagedRowCount(connection, runId, slice.map((unit) => unit.sequence))) >= sliceBudget && index + 1 < units.length) {
584
+ index++;
585
+ final = false;
586
+ break;
587
+ }
535
588
  }
536
- if (final) {
537
- // Cursors move only here, and only when still the one the plan started from (ADR-0060; IS, a first pull plans from NULL).
538
- for (const cursor of staged.plan.cursors) {
539
- const update = 'UPDATE g9_subscriptions SET cursor = $c, definition_hash = $d, auth_hash = $a, state = 0, error_code = NULL, last_success_utc = $now WHERE key = $k';
540
- const params = { $c: cursor.cursor, $d: cursor.definitionHash, $a: cursor.authHash, $now: nowMs(), $k: cursor.key };
541
- if (Object.prototype.hasOwnProperty.call(staged.plannedFromCursors, cursor.key)) {
542
- await context.flush();
543
- await connection.exec(update + ' AND cursor IS $from', { ...params, $from: (_a = staged.plannedFromCursors[cursor.key]) !== null && _a !== void 0 ? _a : null });
589
+ if (store.options.applySliceRowsMax > sliceBudget)
590
+ sliceBudget = Math.min(store.options.applySliceRowsMax, sliceBudget * 2);
591
+ let context = null;
592
+ let doneFiles = [];
593
+ const syncedUpdates = [];
594
+ await connection.transaction('immediate', async () => {
595
+ var _a;
596
+ context = await beginApply(store, connection, {
597
+ progress: rowsApplied,
598
+ backfills: staged.backfills,
599
+ snapshotRows: progressive ? null : snapshotRows,
600
+ changedAfterSequence: staged.stageSequence,
601
+ protectedRows,
602
+ skipTriggers,
603
+ signal,
604
+ });
605
+ for (const unit of slice) {
606
+ if (signal && signal.aborted)
607
+ throw cancellation();
608
+ if (unit.file >= 0) {
609
+ await applySnapshotFile(store, context, attachedSchema(unit.file), files[unit.file]);
610
+ continue;
544
611
  }
545
- else {
546
- await context.flush();
547
- await connection.exec(update, params);
612
+ const sequence = unit.sequence;
613
+ const rows = await connection.query('SELECT payload, subscription_key FROM g9_stage WHERE run_id = $r AND seq = $s', { $r: runId, $s: sequence });
614
+ const row = rows[0];
615
+ if (!row)
616
+ continue;
617
+ const payload = row[0];
618
+ if (!(payload instanceof ArrayBuffer))
619
+ continue;
620
+ await applyFrame(store, context, new Uint8Array(payload), row[1] === null ? null : asString(row[1]));
621
+ }
622
+ if (final) {
623
+ // Cursors move only here, and only when still the one the plan started from (ADR-0060; IS, a first pull plans from NULL).
624
+ // A subscription that came as a file counts its next safety reconciliation from the file's snapshot (ADR-0072).
625
+ const reconciledAt = fileSnapshotTimes(staged.plan);
626
+ for (const cursor of staged.plan.cursors) {
627
+ const at = reconciledAt[cursor.key];
628
+ const update = 'UPDATE g9_subscriptions SET cursor = $c, definition_hash = $d, auth_hash = $a, state = 0, error_code = NULL, last_success_utc = $now' +
629
+ (at === undefined ? '' : ', last_reconcile_utc = $rec') +
630
+ ' WHERE key = $k';
631
+ const params = { $c: cursor.cursor, $d: cursor.definitionHash, $a: cursor.authHash, $now: nowMs(), $k: cursor.key };
632
+ if (at !== undefined)
633
+ params['$rec'] = at;
634
+ if (Object.prototype.hasOwnProperty.call(staged.plannedFromCursors, cursor.key)) {
635
+ await context.flush();
636
+ await connection.exec(update + ' AND cursor IS $from', { ...params, $from: (_a = staged.plannedFromCursors[cursor.key]) !== null && _a !== void 0 ? _a : null });
637
+ }
638
+ else {
639
+ await context.flush();
640
+ await connection.exec(update, params);
641
+ }
642
+ }
643
+ for (const backfill of staged.backfills) {
644
+ const table = store.byServerId.get(backfill.tableId);
645
+ if (!table)
646
+ continue;
647
+ const synced = table.syncedColumns.slice();
648
+ for (const column of backfill.columns)
649
+ if (!synced.some((s) => s.toLowerCase() === column.toLowerCase()))
650
+ synced.push(column);
651
+ await connection.exec('UPDATE g9_tables SET synced_columns = $c WHERE tid = $t', { $c: persistedJson(synced), $t: table.tid });
652
+ syncedUpdates.push({ table, synced });
548
653
  }
549
654
  }
550
- for (const backfill of staged.backfills) {
551
- const table = store.byServerId.get(backfill.tableId);
552
- if (!table)
553
- continue;
554
- const synced = table.syncedColumns.slice();
555
- for (const column of backfill.columns)
556
- if (!synced.some((s) => s.toLowerCase() === column.toLowerCase()))
557
- synced.push(column);
558
- await connection.exec('UPDATE g9_tables SET synced_columns = $c WHERE tid = $t', { $c: persistedJson(synced), $t: table.tid });
559
- syncedUpdates.push({ table, synced });
655
+ await endApply(context);
656
+ const consumed = slice.filter((unit) => unit.file < 0).map((unit) => unit.sequence);
657
+ if (consumed.length > 0)
658
+ await connection.exec('DELETE FROM g9_stage WHERE run_id = $r AND seq IN (' + consumed.join(',') + ')', { $r: runId });
659
+ if (final) {
660
+ // Everything left of the stage - markers, file checkpoints, file records - goes with the last slice.
661
+ doneFiles = await takeStagedFiles(connection, '$r', { $r: runId });
662
+ await connection.exec('DELETE FROM g9_stage WHERE run_id = $r; DELETE FROM g9_stage_runs WHERE run_id = $r', { $r: runId });
560
663
  }
664
+ });
665
+ if (doneFiles.length > 0) {
666
+ await detach();
667
+ await deleteFiles(store, doneFiles);
561
668
  }
562
- await endApply(context);
563
- if (slice.length > 0)
564
- await connection.exec('DELETE FROM g9_stage WHERE run_id = $r AND seq IN (' + slice.join(',') + ')', { $r: runId });
565
- if (final)
566
- await connection.exec('DELETE FROM g9_stage_runs WHERE run_id = $r', { $r: runId });
567
- });
568
- const applied = context;
569
- for (const { table, synced } of syncedUpdates)
570
- table.syncedColumns = synced;
571
- appliedTotal += applied.rows;
572
- total.merge(applied.report);
573
- rowsApplied === null || rowsApplied === void 0 ? void 0 : rowsApplied(appliedTotal);
574
- slices === null || slices === void 0 ? void 0 : slices({ report: applied.report, rowsApplied: applied.rows, rowsAppliedTotal: appliedTotal, rowsPlanned: staged.plan.totalRows, isFinal: final });
575
- } while (index < sequences.length);
576
- total.diagnostics.elapsedMs = Date.now() - started;
577
- if (total.diagnostics.transactions > 0 && appliedTotal > 0)
578
- store.log('information', 20112, `G9SyncData applied ${appliedTotal} row(s): ${total.diagnostics.toString()}`);
579
- await maintainAfterApply(store, connection, appliedTotal);
580
- return total;
669
+ const applied = context;
670
+ for (const { table, synced } of syncedUpdates)
671
+ table.syncedColumns = synced;
672
+ appliedTotal += applied.rows;
673
+ total.merge(applied.report);
674
+ rowsApplied === null || rowsApplied === void 0 ? void 0 : rowsApplied(appliedTotal);
675
+ slices === null || slices === void 0 ? void 0 : slices({ report: applied.report, rowsApplied: applied.rows, rowsAppliedTotal: appliedTotal, rowsPlanned: staged.plan.totalRows, isFinal: final });
676
+ } while (index < units.length);
677
+ total.diagnostics.elapsedMs = Date.now() - started;
678
+ if (total.diagnostics.transactions > 0 && appliedTotal > 0)
679
+ store.log('information', 20112, `G9SyncData applied ${appliedTotal} row(s): ${total.diagnostics.toString()}`);
680
+ await maintainAfterApply(store, connection, appliedTotal);
681
+ return total;
682
+ }
683
+ finally {
684
+ await detach();
685
+ }
581
686
  }
582
687
  finally {
583
688
  if (restore)
@@ -637,8 +742,7 @@ export async function applyReconcile(store, subscriptionKey, table, fetchedFrame
637
742
  await addMembership(context, local, key, subscription.slot, subscription.ephemeral ? subscriptionKey : null);
638
743
  context.report.membershipAdded++;
639
744
  }
640
- for (const key of moveOutKeys)
641
- await moveOut(store, context, local, key, subscriptionKey, subscription.slot, subscription.ephemeral, policy);
745
+ await moveOutMany(store, context, local, moveOutKeys, subscriptionKey, subscription.slot, subscription.ephemeral, policy);
642
746
  }
643
747
  await endApply(context);
644
748
  return context.report;