@substrat-run/control-plane-api 0.170.0 → 0.172.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 (44) hide show
  1. package/dist/aggregate-reads.d.ts +0 -14
  2. package/dist/aggregate-reads.d.ts.map +1 -1
  3. package/dist/aggregate-reads.js +15 -11
  4. package/dist/aggregate-reads.js.map +1 -1
  5. package/dist/aggregate-source.d.ts +23 -0
  6. package/dist/aggregate-source.d.ts.map +1 -1
  7. package/dist/aggregate-source.js +16 -1
  8. package/dist/aggregate-source.js.map +1 -1
  9. package/dist/api.d.ts.map +1 -1
  10. package/dist/api.js +33 -9
  11. package/dist/api.js.map +1 -1
  12. package/dist/cf-observability.d.ts.map +1 -1
  13. package/dist/cf-observability.js +105 -21
  14. package/dist/cf-observability.js.map +1 -1
  15. package/dist/copy-job.d.ts.map +1 -1
  16. package/dist/copy-job.js +5 -3
  17. package/dist/copy-job.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/observability.d.ts +48 -2
  23. package/dist/observability.d.ts.map +1 -1
  24. package/dist/observability.js +38 -1
  25. package/dist/observability.js.map +1 -1
  26. package/dist/platform-drain.d.ts.map +1 -1
  27. package/dist/platform-drain.js +1 -0
  28. package/dist/platform-drain.js.map +1 -1
  29. package/dist/platform-entry.generated.d.ts +1 -1
  30. package/dist/platform-entry.generated.d.ts.map +1 -1
  31. package/dist/platform-entry.generated.js +2 -2
  32. package/dist/platform-entry.generated.js.map +1 -1
  33. package/dist/source-builds.d.ts.map +1 -1
  34. package/dist/source-builds.js +14 -3
  35. package/dist/source-builds.js.map +1 -1
  36. package/dist/tenant-request-query.d.ts +12 -3
  37. package/dist/tenant-request-query.d.ts.map +1 -1
  38. package/dist/tenant-request-query.js +29 -6
  39. package/dist/tenant-request-query.js.map +1 -1
  40. package/dist/vertical-client.d.ts +96 -15
  41. package/dist/vertical-client.d.ts.map +1 -1
  42. package/dist/vertical-client.js +266 -34
  43. package/dist/vertical-client.js.map +1 -1
  44. package/package.json +8 -8
@@ -1,5 +1,5 @@
1
1
  import { z } from '@substrat-run/contracts';
2
- import { PREVIEW_CLIENT_PATH, attachmentRecord, projectedConnectionGrant, denialFilterParams, capabilityFilterParams, capabilityPage, capabilityRevokeAnswer, capabilityRevokeRequest, mintedPreviewClient, ownerSeat, ownerClaimLink, memberInviteLink, memberRemoval, scopeMembers, ownerTransferResult, previewClientClaim, retiredPreviewClients, peerGrantsEntry, peerSwitchOutcome, systemSwitchOutcome, lifecycleDelivery, systemScheduleEntry, switchedOffInUnit, exportedBatch, importResult, importCursorMoved, importState, substratError, CONNECTOR_ATTACHMENT_META_HEADER, CONNECTOR_ATTACHMENT_META_MAX_BYTES, CONNECTOR_ATTACHMENT_BUFFERED_MAX_BYTES, CONNECTOR_ATTACHMENT_STREAM_MAX_BYTES, connectorAttachmentMetaBytes, connectorAttachmentMetaRefusal, connectorAttachmentBufferedRefusal, connectorAttachmentSizeRefusal, CONNECTOR_ATTACHMENT_RECORD_HEADER, LOAD_STAMP_HEADER, PLATFORM_SECRET_HEADER, WRITE_REVISION_HEADER, mergeDumpPages, scopeDumpPage, } from '@substrat-run/contracts';
2
+ import { CAPTURE_REFUSALS, PREVIEW_CLIENT_PATH, attachmentRecord, projectedConnectionGrant, denialFilterParams, capabilityFilterParams, capabilityPage, capabilityRevokeAnswer, capabilityRevokeRequest, mintedPreviewClient, ownerSeat, ownerClaimLink, memberInviteLink, memberRemoval, scopeMembers, ownerTransferResult, previewClientClaim, retiredPreviewClients, peerGrantsEntry, peerSwitchOutcome, systemSwitchOutcome, lifecycleDelivery, systemScheduleEntry, switchedOffInUnit, exportedBatch, importResult, importCursorMoved, importState, substratError, CONNECTOR_ATTACHMENT_META_HEADER, CONNECTOR_ATTACHMENT_META_MAX_BYTES, CONNECTOR_ATTACHMENT_BUFFERED_MAX_BYTES, CONNECTOR_ATTACHMENT_STREAM_MAX_BYTES, connectorAttachmentMetaBytes, connectorAttachmentMetaRefusal, connectorAttachmentBufferedRefusal, connectorAttachmentSizeRefusal, CONNECTOR_ATTACHMENT_RECORD_HEADER, LOAD_STAMP_HEADER, PLATFORM_SECRET_HEADER, WRITE_REVISION_HEADER, mergeDumpPages, scopeDumpChanges, scopeDumpPage, } from '@substrat-run/contracts';
3
3
  import { STREAM_IDLE_TIMEOUT_MS, STREAM_MAX_DURATION_MS, attachmentSha256, deadlineFetch, isSizedByteStream, knownLengthInit, runtimeSha256, scopeNotHeldRefusal, ulid, undrainedEventsOf, verifiedStream } from '@substrat-run/kernel';
4
4
  import { ControlPlaneError } from '@substrat-run/control-plane-client';
5
5
  /**
@@ -1170,9 +1170,12 @@ export class VerticalClient {
1170
1170
  * `exportScope` without. `'predates'` from a deployment built before the paged verbs (a 404, or
1171
1171
  * its own 501), which exports whole.
1172
1172
  */
1173
- async exportScopePage(scopeId, cursor, opts = {}) {
1173
+ async exportScopePage(scopeId, cursor,
1174
+ /** `capture`: a copy's id, for a copy that catches up on writes (`exportScopeChanges`). */
1175
+ opts = {}) {
1174
1176
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
1175
1177
  const query = `scopeId=${encodeURIComponent(scopeId)}${opts.stamp ? '&stamp=1' : ''}` +
1178
+ (opts.capture === undefined ? '' : `&capture=${encodeURIComponent(opts.capture)}`) +
1176
1179
  (cursor === null ? '' : `&cursor=${encodeURIComponent(cursor)}`);
1177
1180
  const answer = await withDeadline('export-page', PAGE_CALL_DEADLINE_MS, (signal) => this.routeAnswer('export-page', FENCED, () => this.options.fetch(`${base}/internal/export-page?${query}`, {
1178
1181
  headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
@@ -1200,6 +1203,35 @@ export class VerticalClient {
1200
1203
  async discardStagedScope(scopeId, stageId) {
1201
1204
  await withDeadline('stage-discard', PAGE_CALL_DEADLINE_MS, (signal) => this.routeAnswer('stage-discard', FENCED, () => this.postRaw('/internal/stage/discard', { scopeId, stageId }, signal)));
1202
1205
  }
1206
+ /**
1207
+ * One batch of what `scopeId` took since the capture `copyId`'s first page (#2450). Batch `seq`
1208
+ * acknowledges those before it, and the same `seq` asked again is served afresh, so a lost
1209
+ * answer is retried safely. `'predates'` from a deployment that cannot catch a copy up.
1210
+ */
1211
+ async exportScopeChanges(scopeId, copyId, seq) {
1212
+ const answer = await withDeadline('export-changes', PAGE_CALL_DEADLINE_MS, (signal) => this.routeAnswer('export-changes', FENCED, () => this.postRaw('/internal/export-changes', { scopeId, copyId, seq }, signal)));
1213
+ if (answer === PREDATES)
1214
+ return 'predates';
1215
+ const changes = scopeDumpChanges.safeParse(answer);
1216
+ if (!changes.success)
1217
+ throw new ControlPlaneError(502, 'export-changes: the vertical answered something that is not a change batch');
1218
+ return changes.data;
1219
+ }
1220
+ /** End the capture `copyId` of `scopeId` (#2450): a copy that stopped. Best effort, as a discard is. */
1221
+ async endScopeCapture(scopeId, copyId) {
1222
+ await withDeadline('capture-end', PAGE_CALL_DEADLINE_MS, (signal) => this.routeAnswer('capture-end', FENCED, () => this.postRaw('/internal/capture/end', { scopeId, copyId }, signal)));
1223
+ }
1224
+ /**
1225
+ * Apply one batch of a capture's changes to the stage `stageId` here (#2450), numbered after its
1226
+ * pages. `'predates'` from a deployment that cannot catch a copy up; a batch already applied
1227
+ * answers `staged: false`.
1228
+ */
1229
+ async stageScopeChanges(scopeId, stageId, seq, tables) {
1230
+ const answer = await withDeadline('stage-changes', PAGE_CALL_DEADLINE_MS, (signal) => this.routeAnswer('stage-changes', FENCED, () => this.postRaw('/internal/stage-changes', { scopeId, stageId, seq, tables }, signal)));
1231
+ if (answer === PREDATES)
1232
+ return 'predates';
1233
+ return { staged: answer?.staged === true };
1234
+ }
1203
1235
  /** A platform-authenticated JSON POST, its response unread, for `routeAnswer`. */
1204
1236
  postRaw(path, body, signal) {
1205
1237
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
@@ -1761,6 +1793,12 @@ export class VerticalClient {
1761
1793
  }
1762
1794
  /** How many times a copy is restarted because the source took a write while it was read (#2450). */
1763
1795
  export const TRANSFER_ATTEMPTS = 3;
1796
+ /**
1797
+ * How many change batches one attempt's catch-up reads before it gives up (#2450): a source that
1798
+ * writes faster than the copy drains its log never reaches a final batch, and the copy then starts
1799
+ * again like one torn at a quiet moment, and is refused once the attempts are spent.
1800
+ */
1801
+ export const CATCH_UP_BATCHES = 50;
1764
1802
  /**
1765
1803
  * Move a scope's data out of `source` and into `dest`'s stage, a page at a time (#2450), so no
1766
1804
  * single call carries the whole scope: a Durable Object RPC message holds 32 MiB, and an isolate
@@ -1771,10 +1809,18 @@ export const TRANSFER_ATTEMPTS = 3;
1771
1809
  * before. A destination that predates them gets the pages merged into one dump, which is the old
1772
1810
  * ceiling, and only there.
1773
1811
  *
1774
- * Each page is read in one turn of the source, so it is consistent on its own; the pages are one
1775
- * consistent read only while the source's write revision stays where the first page read it. When
1776
- * it moves the copy starts again, up to `TRANSFER_ATTEMPTS` times, and is then refused (409) with
1777
- * nothing moved: a carry's later fences assume a copy that was true at one moment.
1812
+ * Each page is read in one turn of the source, so it is consistent on its own. A carry's later
1813
+ * fences assume a copy that was true at one moment, and there are two ways to get one. A source
1814
+ * that can capture (#2450 step 3) logs every write from its first page on, and once the pages are
1815
+ * staged the copy catches up on them in change batches until a final batch, read in the same turn
1816
+ * as the log ran empty, names that moment. A source that cannot (an older deployment, or a table
1817
+ * with a unique expression index, which no row trigger can key) is copied at a quiet moment: the
1818
+ * pages are one read only while its write revision stays where the first page read it.
1819
+ *
1820
+ * Either way a copy that does not come true starts again, up to `TRANSFER_ATTEMPTS` times, and is
1821
+ * then refused (409) with nothing moved: a quiet-moment copy the source wrote under, or a catch-up
1822
+ * whose capture was lost (it expired, a migration moved the schema, a load replaced the store) or
1823
+ * that did not reach a final batch in `CATCH_UP_BATCHES`.
1778
1824
  *
1779
1825
  * `beginTransfer` and `transferStep` are the same copy in resumable steps, for one that outlasts a
1780
1826
  * request; this runs them to the end.
@@ -1783,48 +1829,108 @@ export async function transferScope(source, dest,
1783
1829
  /** The scope read at `source`, and (a fork, #2450) the scope `dest` stages into, when another. */
1784
1830
  scope, opts = {}) {
1785
1831
  let progress = await beginTransfer({ source, dest }, scope, opts);
1786
- while ('state' in progress)
1787
- progress = await transferStep({ source, dest }, progress.state, opts);
1832
+ while ('state' in progress) {
1833
+ const { state } = progress;
1834
+ try {
1835
+ progress = await transferStep({ source, dest }, state, opts);
1836
+ }
1837
+ catch (e) {
1838
+ // Nobody resumes a copy run in one request, so its stage and capture go with it. (A job's
1839
+ // survive a fault, for the next step to resume.)
1840
+ await abandonCopy({ source, dest }, state);
1841
+ throw e;
1842
+ }
1843
+ }
1788
1844
  return progress.done;
1789
1845
  }
1790
1846
  /**
1791
1847
  * Start a copy (#2450): read the source's first page and stage it. Finished at once when the scope
1792
1848
  * fits that page (unless `alwaysStage`), when the source predates the paged verbs, or when the
1793
1849
  * destination does (its pages are then merged here, as the whole dump it takes).
1850
+ *
1851
+ * The first page asks the source to capture under the stage's id. A source that refuses (412: a
1852
+ * table it cannot capture) is read again without one, and one that answers a plain page (it does
1853
+ * not echo `captured`) has its capture ended: either way the copy is read at a quiet moment. A copy
1854
+ * that catches up and fits its first page catches up here, so it is still finished at once.
1794
1855
  */
1795
- export async function beginTransfer({ source, dest }, scope, opts = {}) {
1856
+ export async function beginTransfer(ends, scope, opts = {}) {
1857
+ const { source, dest } = ends;
1796
1858
  const retry = opts.retry ?? ((run) => run());
1797
1859
  const stamp = opts.stamp ?? true;
1798
1860
  const from = scope.from;
1799
1861
  const into = scope.to ?? scope.from;
1862
+ // The stage's id names the capture too, so whoever discards a stage ends the capture beside it.
1863
+ const stageId = ulid();
1864
+ const ask = opts.capture !== false && typeof source.exportScopeChanges === 'function';
1865
+ /** The source refused to open the capture, so it holds none. */
1866
+ let refused = false;
1800
1867
  // A client without the paged verbs (one a caller assembled from part of this one) is read as a
1801
1868
  // deployment that predates them.
1802
- const first = typeof source.exportScopePage === 'function'
1803
- ? await retry(() => source.exportScopePage(from, null, { stamp }))
1804
- : 'predates';
1869
+ const first = typeof source.exportScopePage !== 'function'
1870
+ ? 'predates'
1871
+ : ask
1872
+ ? await retry(() => source.exportScopePage(from, null, { stamp, capture: stageId })).catch((e) => {
1873
+ if (!captureRefusal(e, 'expressionIndex'))
1874
+ throw e;
1875
+ refused = true;
1876
+ return retry(() => source.exportScopePage(from, null, { stamp }));
1877
+ })
1878
+ : await retry(() => source.exportScopePage(from, null, { stamp }));
1805
1879
  if (first === 'predates') {
1806
1880
  if (!stamp) {
1807
1881
  const tables = await retry(() => source.exportScope(from));
1808
- return { done: { dump: tables, loadStamp: null, revision: null, meta: metaOf([tables]) } };
1882
+ return { done: { dump: tables, loadStamp: null, revision: null, meta: doneMeta(metaOf([tables])) } };
1809
1883
  }
1810
1884
  const whole = await retry(() => source.exportScopeStamped(from));
1811
- return { done: { dump: whole.tables, loadStamp: whole.loadStamp, revision: whole.revision, meta: metaOf([whole.tables]) } };
1885
+ return { done: { dump: whole.tables, loadStamp: whole.loadStamp, revision: whole.revision, meta: doneMeta(metaOf([whole.tables])) } };
1812
1886
  }
1887
+ const captured = ask && first.captured === true;
1888
+ // A host that took the capture but predates the echo (#2761) still opened one: end it.
1889
+ if (ask && !captured && !refused)
1890
+ await endCapture(ends, from, stageId);
1813
1891
  if (first.next === null && !opts.alwaysStage) {
1814
- return { done: { dump: first.tables, loadStamp: first.loadStamp, revision: first.revision, meta: metaOf([first.tables]) } };
1892
+ // One page is one read whatever the source takes next, so its capture is not needed.
1893
+ if (captured)
1894
+ await endCapture(ends, from, stageId);
1895
+ return { done: { dump: first.tables, loadStamp: first.loadStamp, revision: first.revision, meta: doneMeta(metaOf([first.tables])) } };
1896
+ }
1897
+ let opened;
1898
+ try {
1899
+ opened = typeof dest.stageScopePage === 'function'
1900
+ ? await retry(() => dest.stageScopePage(into, stageId, 0, first.tables))
1901
+ : 'predates';
1902
+ }
1903
+ catch (e) {
1904
+ if (captured)
1905
+ await endCapture(ends, from, stageId);
1906
+ throw e;
1815
1907
  }
1816
- const stageId = ulid();
1817
- const opened = typeof dest.stageScopePage === 'function'
1818
- ? await retry(() => dest.stageScopePage(into, stageId, 0, first.tables))
1819
- : 'predates';
1820
1908
  const state = {
1821
1909
  from, into, stamp, stageId, seq: 0, cursor: first.next,
1822
1910
  revision: first.revision, loadStamp: first.loadStamp,
1823
1911
  attempt: opts.attempt ?? 1, pages: 1, rows: rowsIn(first.tables), meta: metaOf([first.tables]),
1912
+ ...(captured && opened !== 'predates' ? { capture: true, batch: 0 } : {}),
1824
1913
  };
1825
- if (opened !== 'predates')
1826
- return state.cursor === null ? { done: doneOf(state) } : { state };
1827
- // A destination that cannot stage takes the dump whole, so the pages are held here instead.
1914
+ if (opened !== 'predates') {
1915
+ if (state.cursor !== null)
1916
+ return { state };
1917
+ if (!state.capture)
1918
+ return { done: doneOf(state) };
1919
+ // Caught up here, within a step's budget: a source still writing is left for the next step.
1920
+ const until = Date.now() + BEGIN_CATCH_UP_MS;
1921
+ try {
1922
+ return await transferStep(ends, state, { ...opts, until: () => Date.now() >= until });
1923
+ }
1924
+ catch (e) {
1925
+ // Nothing names this copy yet, so nothing else would discard its stage or end its capture.
1926
+ await abandonCopy(ends, state);
1927
+ throw e;
1928
+ }
1929
+ }
1930
+ // A destination that cannot stage takes the dump whole, so the pages are held here instead, read
1931
+ // at a quiet moment: it cannot take a change batch either.
1932
+ if (captured)
1933
+ await endCapture(ends, from, stageId);
1828
1934
  const held = [first.tables];
1829
1935
  for (let cursor = first.next; cursor !== null;) {
1830
1936
  const at = cursor;
@@ -1832,11 +1938,11 @@ export async function beginTransfer({ source, dest }, scope, opts = {}) {
1832
1938
  if (page === 'predates')
1833
1939
  throw stoppedPaging(from);
1834
1940
  if (page.revision !== first.revision)
1835
- return restart({ source, dest }, state, opts, false);
1941
+ return restart(ends, state, { ...opts, capture: false }, false);
1836
1942
  held.push(page.tables);
1837
1943
  cursor = page.next;
1838
1944
  }
1839
- return { done: { dump: mergeDumpPages(held), loadStamp: first.loadStamp, revision: first.revision, meta: metaOf(held) } };
1945
+ return { done: { dump: mergeDumpPages(held), loadStamp: first.loadStamp, revision: first.revision, meta: doneMeta(metaOf(held)) } };
1840
1946
  }
1841
1947
  /**
1842
1948
  * Move the next pages of a copy (#2450), until it is staged whole or `until` says to stop (a job's
@@ -1845,17 +1951,32 @@ export async function beginTransfer({ source, dest }, scope, opts = {}) {
1845
1951
  *
1846
1952
  * `stopped` (a job being cancelled or forgotten): the copy starts nothing new. A torn copy then
1847
1953
  * discards its stage and answers where it was, opening no other: whoever stopped it settles it.
1954
+ *
1955
+ * Once a captured copy's pages are staged, the step catches up: each change batch the source
1956
+ * serves is staged after the last page, until a final one. A batch is staged at the first number
1957
+ * the stage has not taken (`staged: false` means it has), so a step resumed from a record older
1958
+ * than the stage (a step that faulted after staging) applies the batch served now after the one
1959
+ * staged then, and the newer rows win.
1848
1960
  */
1849
- export async function transferStep({ source, dest }, state, opts = {}) {
1961
+ export async function transferStep(ends, state, opts = {}) {
1962
+ const { source, dest } = ends;
1850
1963
  const retry = opts.retry ?? ((run) => run());
1851
1964
  let next = state;
1852
1965
  while (next.cursor !== null) {
1853
1966
  const at = next.cursor;
1854
- const page = await retry(() => source.exportScopePage(next.from, at, { stamp: next.stamp }));
1967
+ const capture = next.capture ? { capture: next.stageId } : {};
1968
+ const page = await retry(() => source.exportScopePage(next.from, at, { stamp: next.stamp, ...capture })).catch((e) => {
1969
+ // A capture that is gone (ended, expired, its schema moved) cannot vouch for the pages.
1970
+ if (next.capture && captureRefusal(e, 'gone', 'schemaMoved'))
1971
+ return 'lost';
1972
+ throw e;
1973
+ });
1974
+ if (page === 'lost')
1975
+ return restart(ends, next, opts, true);
1855
1976
  if (page === 'predates')
1856
1977
  throw stoppedPaging(next.from);
1857
- if (page.revision !== next.revision)
1858
- return restart({ source, dest }, next, opts, true);
1978
+ if (!next.capture && page.revision !== next.revision)
1979
+ return restart(ends, next, opts, true);
1859
1980
  const seq = next.seq + 1;
1860
1981
  const staged = await retry(() => dest.stageScopePage(next.into, next.stageId, seq, page.tables));
1861
1982
  if (staged === 'predates') {
@@ -1867,18 +1988,71 @@ export async function transferStep({ source, dest }, state, opts = {}) {
1867
1988
  cursor: page.next,
1868
1989
  pages: next.pages + 1,
1869
1990
  rows: next.rows + rowsIn(page.tables),
1870
- meta: mergeDumpPages([next.meta, metaOf([page.tables])]),
1991
+ meta: metaOf([next.meta, page.tables]),
1871
1992
  };
1872
1993
  if (next.cursor !== null && opts.until?.())
1873
1994
  return { state: next };
1874
1995
  }
1996
+ while (next.capture) {
1997
+ const batch = next.batch ?? 0;
1998
+ // Stopped: no batch more, the first one included. A budget spent still lets one batch through.
1999
+ if (opts.stopped?.())
2000
+ return { state: next };
2001
+ if (batch >= (opts.catchUpBatches ?? CATCH_UP_BATCHES))
2002
+ return restart(ends, next, opts, true);
2003
+ if (batch > 0 && opts.until?.())
2004
+ return { state: next };
2005
+ const at = next;
2006
+ const changes = typeof source.exportScopeChanges !== 'function'
2007
+ ? 'predates'
2008
+ : await retry(() => source.exportScopeChanges(at.from, at.stageId, batch)).catch((e) => {
2009
+ // Gone, or its schema moved: a final answer that was lost, asked again, is gone too.
2010
+ if (captureRefusal(e, 'gone', 'schemaMoved'))
2011
+ return 'lost';
2012
+ throw e;
2013
+ });
2014
+ if (changes === 'lost')
2015
+ return restart(ends, next, opts, true);
2016
+ // An end that cannot catch up: the copy is one read only if the source took no write since.
2017
+ if (changes === 'predates')
2018
+ return restart(ends, next, { ...opts, capture: false }, true);
2019
+ let seq = next.seq + 1;
2020
+ if (changes.tables.length > 0) {
2021
+ for (;;) {
2022
+ const staged = typeof dest.stageScopeChanges !== 'function'
2023
+ ? 'predates'
2024
+ : await retry(() => dest.stageScopeChanges(at.into, at.stageId, seq, changes.tables));
2025
+ if (staged === 'predates')
2026
+ return restart(ends, next, { ...opts, capture: false }, true);
2027
+ if (staged.staged)
2028
+ break;
2029
+ seq++;
2030
+ }
2031
+ }
2032
+ else {
2033
+ seq = next.seq;
2034
+ }
2035
+ next = {
2036
+ ...next,
2037
+ seq,
2038
+ batch: batch + 1,
2039
+ rows: next.rows + rowsIn(changes.tables),
2040
+ meta: foldMeta(next.meta, changes.tables),
2041
+ };
2042
+ if (changes.final) {
2043
+ // The source ended the capture with this batch: the copy is true at the moment it names.
2044
+ const { capture: _, batch: __, ...caught } = next;
2045
+ return { done: doneOf({ ...caught, revision: changes.revision }) };
2046
+ }
2047
+ }
1875
2048
  return { done: doneOf(next) };
1876
2049
  }
1877
2050
  /** A torn copy: discard its stage and begin again, or refuse once the attempts are spent. */
1878
2051
  async function restart(ends, state, opts, staged) {
1879
- // Best effort: a stage nothing commits is replaced by the next one, and goes with a wipe.
2052
+ // Best effort: a stage nothing commits is replaced by the next one, and goes with a wipe. (An
2053
+ // unstaged copy holds no capture: it was ended when the destination turned out unable to stage.)
1880
2054
  if (staged)
1881
- await ends.dest.discardStagedScope(state.into, state.stageId).catch(() => undefined);
2055
+ await abandonCopy(ends, state);
1882
2056
  // Stopped: a new stage would outlast whoever stopped this copy, so none is opened.
1883
2057
  if (opts.stopped?.())
1884
2058
  return { state };
@@ -1889,9 +2063,67 @@ async function restart(ends, state, opts, staged) {
1889
2063
  }
1890
2064
  return beginTransfer(ends, { from: state.from, to: state.into }, { ...opts, stamp: state.stamp, attempt: state.attempt + 1 });
1891
2065
  }
1892
- const doneOf = (state) => ({ dump: { stageId: state.stageId }, loadStamp: state.loadStamp, revision: state.revision, meta: state.meta });
2066
+ /**
2067
+ * End the capture `copyId` at the source (#2450), best effort: one left open expires on its own once
2068
+ * nobody reads it. Every copy that stops (failed, cancelled, superseded or restarted) calls it.
2069
+ */
2070
+ export async function endCapture(ends, from, copyId) {
2071
+ await ends.source.endScopeCapture?.(from, copyId).catch(() => undefined);
2072
+ }
2073
+ /**
2074
+ * Give a copy up (#2450), best effort: discard its stage at the destination and end its capture at
2075
+ * the source, the two halves every copy that stops leaves behind. An end left out is skipped (one
2076
+ * that could not be resolved); its half then waits for a wipe, or expires.
2077
+ */
2078
+ export async function abandonCopy(ends, state) {
2079
+ await ends.dest?.discardStagedScope(state.into, state.stageId).catch(() => undefined);
2080
+ if (state.capture && ends.source)
2081
+ await endCapture({ source: ends.source }, state.from, state.stageId);
2082
+ }
2083
+ /** How long a copy's first request catches up before it leaves the rest to a step, ms. */
2084
+ const BEGIN_CATCH_UP_MS = 20_000;
2085
+ /** Whether `e` is the source's capture refusing for one of `kinds` (`CAPTURE_REFUSALS`), not another 412. */
2086
+ const captureRefusal = (e, ...kinds) => e instanceof ControlPlaneError && e.status === 412 && kinds.some((k) => e.message.includes(CAPTURE_REFUSALS[k]));
2087
+ const doneOf = (state) => ({ dump: { stageId: state.stageId }, loadStamp: state.loadStamp, revision: state.revision, meta: doneMeta(state.meta) });
1893
2088
  const rowsIn = (tables) => tables.reduce((n, t) => n + t.rows.length, 0);
1894
2089
  const stoppedPaging = (scopeId) => new ControlPlaneError(502, `the source of scope ${scopeId} stopped answering dump pages part way; nothing was moved`);
1895
- /** The `_substrat_meta` fragments of some dump pages, merged. */
1896
- const metaOf = (pages) => mergeDumpPages(pages.map((page) => page.filter((t) => t.name === '_substrat_meta')));
2090
+ const META = '_substrat_meta';
2091
+ /**
2092
+ * The `_substrat_meta` fragments of some dump pages, merged, each row still named by its rowid
2093
+ * when every fragment carried them (a captured copy's), so a change batch can fold into it.
2094
+ */
2095
+ const metaOf = (pages) => {
2096
+ let held;
2097
+ for (const t of pages.flat()) {
2098
+ if (t.name !== META)
2099
+ continue;
2100
+ if (!held) {
2101
+ held = { ...t, rows: [...t.rows], ...(t.rowids ? { rowids: [...t.rowids] } : {}) };
2102
+ continue;
2103
+ }
2104
+ held.rows.push(...t.rows);
2105
+ if (held.rowids && t.rowids)
2106
+ held.rowids.push(...t.rowids);
2107
+ else
2108
+ delete held.rowids;
2109
+ }
2110
+ return held ? [held] : [];
2111
+ };
2112
+ /** `meta` with a change batch's `_substrat_meta` change applied: by rowid, or whole when it came whole. */
2113
+ const foldMeta = (meta, tables) => {
2114
+ const change = tables.find((t) => t.name === META);
2115
+ if (!change)
2116
+ return meta;
2117
+ const { deleted, ...table } = change;
2118
+ if (!change.rowids)
2119
+ return [table];
2120
+ const held = meta[0];
2121
+ const rows = new Map(held?.rowids ? held.rowids.map((id, i) => [id, held.rows[i]]) : []);
2122
+ for (const id of deleted)
2123
+ rows.delete(id);
2124
+ change.rowids.forEach((id, i) => rows.set(id, change.rows[i]));
2125
+ return [{ ...(held ?? table), rows: [...rows.values()], rowids: [...rows.keys()] }];
2126
+ };
2127
+ /** What a finished copy reports of `_substrat_meta`: its rows, the rowids left behind. */
2128
+ const doneMeta = (meta) => meta.map(({ rowids: _, ...t }) => t);
1897
2129
  //# sourceMappingURL=vertical-client.js.map