@superdoc/sdk 2.10.0-next.4 → 2.10.0-next.6

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.
@@ -99,6 +99,48 @@ function includesDomain(requested, domain) {
99
99
  function truncateBlockText(value, limit) {
100
100
  return limit == null ? value : value.slice(0, limit);
101
101
  }
102
+ function populateTableCellsFromExtract(tables, extractRaw, tableOrdinalBase = 0) {
103
+ const extractRec = asRecord(extractRaw);
104
+ const extractBlocks = Array.isArray(extractRec?.blocks) ? extractRec.blocks : [];
105
+ const cellsByTableNodeId = new Map();
106
+ for (const block of extractBlocks) {
107
+ const rec = asRecord(block);
108
+ const tableContext = asRecord(rec?.tableContext);
109
+ if (!rec || !tableContext)
110
+ continue;
111
+ const tableOrdinal = asNumber(tableContext.tableOrdinal, -1);
112
+ const rowIndex = asNumber(tableContext.rowIndex, -1);
113
+ const columnIndex = asNumber(tableContext.columnIndex, -1);
114
+ if (tableOrdinal < 0 || rowIndex < 0 || columnIndex < 0)
115
+ continue;
116
+ const table = tables[tableOrdinal - tableOrdinalBase];
117
+ if (!table)
118
+ continue;
119
+ const key = `${rowIndex}:${columnIndex}`;
120
+ const text = asString(rec.text);
121
+ const nodeId = asString(rec.nodeId) || undefined;
122
+ const cellMap = cellsByTableNodeId.get(table.nodeId) ??
123
+ new Map();
124
+ const existing = cellMap.get(key);
125
+ cellMap.set(key, {
126
+ rowIndex,
127
+ columnIndex,
128
+ text: existing == null || text.length === 0
129
+ ? (existing?.text ?? text)
130
+ : existing.text.length === 0
131
+ ? text
132
+ : `${existing.text}\n${text}`,
133
+ nodeId: existing?.nodeId ?? nodeId,
134
+ });
135
+ cellsByTableNodeId.set(table.nodeId, cellMap);
136
+ }
137
+ for (const table of tables) {
138
+ const cellMap = cellsByTableNodeId.get(table.nodeId);
139
+ if (!cellMap)
140
+ continue;
141
+ table.cells = [...cellMap.values()].sort((left, right) => left.rowIndex - right.rowIndex || left.columnIndex - right.columnIndex);
142
+ }
143
+ }
102
144
  /**
103
145
  * Build a deterministic snapshot of a document. The snapshot uses only
104
146
  * read-mode operations from the generated contract — it never mutates state,
@@ -404,46 +446,7 @@ async function buildDocumentSnapshot(doc, options = {}) {
404
446
  const extractFn = maybeMethod(doc, ['extract']);
405
447
  if (tables.length > 0 && extractFn) {
406
448
  const extractRaw = await safeCall(() => extractFn({}), null, recordError('extract'));
407
- const extractRec = asRecord(extractRaw);
408
- const extractBlocks = Array.isArray(extractRec?.blocks) ? extractRec.blocks : [];
409
- const cellsByTableNodeId = new Map();
410
- for (const block of extractBlocks) {
411
- const rec = asRecord(block);
412
- const tableContext = asRecord(rec?.tableContext);
413
- if (!rec || !tableContext)
414
- continue;
415
- const tableOrdinal = asNumber(tableContext.tableOrdinal, -1);
416
- const rowIndex = asNumber(tableContext.rowIndex, -1);
417
- const columnIndex = asNumber(tableContext.columnIndex, -1);
418
- if (tableOrdinal < 0 || rowIndex < 0 || columnIndex < 0)
419
- continue;
420
- const table = tables[tableOrdinal - precedingTableCount];
421
- if (!table)
422
- continue;
423
- const key = `${rowIndex}:${columnIndex}`;
424
- const text = asString(rec.text);
425
- const nodeId = asString(rec.nodeId) || undefined;
426
- const cellMap = cellsByTableNodeId.get(table.nodeId) ??
427
- new Map();
428
- const existing = cellMap.get(key);
429
- cellMap.set(key, {
430
- rowIndex,
431
- columnIndex,
432
- text: existing == null || text.length === 0
433
- ? (existing?.text ?? text)
434
- : existing.text.length === 0
435
- ? text
436
- : `${existing.text}\n${text}`,
437
- nodeId: existing?.nodeId ?? nodeId,
438
- });
439
- cellsByTableNodeId.set(table.nodeId, cellMap);
440
- }
441
- for (const table of tables) {
442
- const cellMap = cellsByTableNodeId.get(table.nodeId);
443
- if (!cellMap)
444
- continue;
445
- table.cells = [...cellMap.values()].sort((left, right) => left.rowIndex - right.rowIndex || left.columnIndex - right.columnIndex);
446
- }
449
+ populateTableCellsFromExtract(tables, extractRaw, precedingTableCount);
447
450
  }
448
451
  // Optionally enrich table cells with per-run formatting (opt-in; one
449
452
  // query.match per cell, bounded) so a reader can match a cell's pattern.
@@ -748,6 +751,144 @@ async function buildDocumentSnapshot(doc, options = {}) {
748
751
  ...(finds ? { finds } : {}),
749
752
  };
750
753
  }
754
+ class MutationSnapshotError extends Error {
755
+ code;
756
+ constructor(code, message) {
757
+ super(message);
758
+ this.code = code;
759
+ this.name = 'MutationSnapshotError';
760
+ }
761
+ }
762
+ const MUTATION_BLOCK_PAGE_SIZE = 1000;
763
+ const MUTATION_BLOCK_PAGE_CONCURRENCY = 4;
764
+ const MUTATION_NON_TABLE_DOMAINS = [
765
+ 'blocks',
766
+ 'lists',
767
+ 'comments',
768
+ 'trackedChanges',
769
+ 'sections',
770
+ 'headerFooters',
771
+ 'styles',
772
+ 'contentControls',
773
+ 'fields',
774
+ 'hyperlinks',
775
+ 'bookmarks',
776
+ 'permissionRanges',
777
+ 'images',
778
+ ];
779
+ /**
780
+ * Build the complete block index used to authorize mutations. Inspection
781
+ * snapshots stay windowed; mutation selectors must not treat that presentation
782
+ * window as the end of the document. Reads remain bounded and the revision is
783
+ * checked before the combined snapshot is returned.
784
+ */
785
+ async function buildMutationSnapshot(doc, options = {}) {
786
+ const requestedDomains = options.includeDomains == null || options.includeDomains.length === 0 ? null : new Set(options.includeDomains);
787
+ const includeTables = requestedDomains == null || requestedDomains.has('tables');
788
+ const firstDomains = requestedDomains == null
789
+ ? MUTATION_NON_TABLE_DOMAINS
790
+ : [...requestedDomains].filter((domain) => domain !== 'tables').concat('blocks');
791
+ const first = await buildDocumentSnapshot(doc, {
792
+ includeDomains: firstDomains,
793
+ blockOffset: 0,
794
+ blockLimit: MUTATION_BLOCK_PAGE_SIZE,
795
+ });
796
+ assertMutationBlockRead(first, 0);
797
+ const total = first.counts.blocks;
798
+ if (first.blocks.length > total) {
799
+ throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: document reports ${total} blocks but returned ${first.blocks.length}`);
800
+ }
801
+ if (first.blocks.length === 0 && total > 0) {
802
+ throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: document reports ${total} blocks but returned none`);
803
+ }
804
+ const blocks = [...first.blocks];
805
+ const diagnostics = [...first.diagnostics];
806
+ const pageStride = first.blocks.length || MUTATION_BLOCK_PAGE_SIZE;
807
+ const offsets = first.blocks.length === total
808
+ ? []
809
+ : Array.from({ length: Math.ceil((total - pageStride) / pageStride) }, (_, index) => pageStride * (index + 1));
810
+ for (let index = 0; index < offsets.length; index += MUTATION_BLOCK_PAGE_CONCURRENCY) {
811
+ const batchOffsets = offsets.slice(index, index + MUTATION_BLOCK_PAGE_CONCURRENCY);
812
+ const pages = await Promise.all(batchOffsets.map((blockOffset) => buildDocumentSnapshot(doc, {
813
+ includeDomains: ['blocks'],
814
+ blockOffset,
815
+ blockLimit: pageStride,
816
+ })));
817
+ for (let pageIndex = 0; pageIndex < pages.length; pageIndex += 1) {
818
+ const page = pages[pageIndex];
819
+ const blockOffset = batchOffsets[pageIndex];
820
+ assertMutationBlockRead(page, blockOffset);
821
+ assertMutationSnapshotRevision(first.revision, page.revision);
822
+ if (page.blocks.length === 0) {
823
+ throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: block pagination stopped at ${blockOffset} of ${total} blocks`);
824
+ }
825
+ blocks.push(...page.blocks);
826
+ diagnostics.push(...page.diagnostics);
827
+ }
828
+ }
829
+ if (blocks.length !== total) {
830
+ throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: expected ${total} blocks but materialized ${blocks.length}`);
831
+ }
832
+ const tables = includeTables ? await buildMutationTables(doc, blocks, diagnostics) : [];
833
+ const finalIdentity = await buildDocumentSnapshot(doc, { countsOnly: true });
834
+ assertMutationSnapshotRevision(first.revision, finalIdentity.revision);
835
+ return {
836
+ ...first,
837
+ blocks,
838
+ tables,
839
+ diagnostics,
840
+ };
841
+ }
842
+ async function buildMutationTables(doc, blocks, diagnostics) {
843
+ const tableBlocks = blocks.filter((block) => block.nodeType === 'table');
844
+ const tables = tableBlocks.map((block, index) => ({
845
+ nodeId: block.nodeId,
846
+ ordinal: index + 1,
847
+ rows: 0,
848
+ columns: 0,
849
+ cells: [],
850
+ }));
851
+ const getTable = maybeMethod(doc, ['tables', 'get']);
852
+ if (getTable) {
853
+ for (let index = 0; index < tables.length; index += MUTATION_BLOCK_PAGE_CONCURRENCY) {
854
+ const batch = tables.slice(index, index + MUTATION_BLOCK_PAGE_CONCURRENCY);
855
+ await Promise.all(batch.map(async (table) => {
856
+ try {
857
+ const raw = asRecord(await getTable({ nodeId: table.nodeId }));
858
+ table.rows = asNumber(raw?.rows);
859
+ table.columns = asNumber(raw?.columns);
860
+ }
861
+ catch (error) {
862
+ diagnostics.push({
863
+ section: `tables.get:${table.nodeId}`,
864
+ message: error instanceof Error ? error.message : String(error),
865
+ });
866
+ }
867
+ }));
868
+ }
869
+ }
870
+ const extract = maybeMethod(doc, ['extract']);
871
+ if (tables.length > 0 && extract) {
872
+ try {
873
+ populateTableCellsFromExtract(tables, await extract({}));
874
+ }
875
+ catch (error) {
876
+ diagnostics.push({ section: 'extract', message: error instanceof Error ? error.message : String(error) });
877
+ }
878
+ }
879
+ return tables;
880
+ }
881
+ function assertMutationBlockRead(snapshot, blockOffset) {
882
+ const failure = snapshot.diagnostics.find((diagnostic) => diagnostic.section === 'blocks.list');
883
+ if (!failure)
884
+ return;
885
+ throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: block page at offset ${blockOffset} failed: ${failure.message}`);
886
+ }
887
+ function assertMutationSnapshotRevision(expected, actual) {
888
+ if (expected === 'unknown' || actual === 'unknown' || expected === actual)
889
+ return;
890
+ throw new MutationSnapshotError('REVISION_CONFLICT', `document changed while resolving mutation selectors (${expected} -> ${actual}); retry the action`);
891
+ }
751
892
  /**
752
893
  * Structured ambiguity error returned when multiple candidates match a
753
894
  * selector and the plan required uniqueness.
@@ -882,6 +1023,8 @@ function resolveSnapshotSelector(snapshot, selector) {
882
1023
  }
883
1024
 
884
1025
  exports.AmbiguousSelectorError = AmbiguousSelectorError;
1026
+ exports.MutationSnapshotError = MutationSnapshotError;
885
1027
  exports.buildDocumentSnapshot = buildDocumentSnapshot;
1028
+ exports.buildMutationSnapshot = buildMutationSnapshot;
886
1029
  exports.matchRunsForBlock = matchRunsForBlock;
887
1030
  exports.resolveSnapshotSelector = resolveSnapshotSelector;
@@ -313,6 +313,17 @@ type SnapshotOptions = {
313
313
  * rest of the snapshot remains usable.
314
314
  */
315
315
  export declare function buildDocumentSnapshot(doc: BoundDocApi, options?: SnapshotOptions): Promise<DocumentSnapshot>;
316
+ export declare class MutationSnapshotError extends Error {
317
+ readonly code: 'SNAPSHOT_INCOMPLETE' | 'REVISION_CONFLICT';
318
+ constructor(code: 'SNAPSHOT_INCOMPLETE' | 'REVISION_CONFLICT', message: string);
319
+ }
320
+ /**
321
+ * Build the complete block index used to authorize mutations. Inspection
322
+ * snapshots stay windowed; mutation selectors must not treat that presentation
323
+ * window as the end of the document. Reads remain bounded and the revision is
324
+ * checked before the combined snapshot is returned.
325
+ */
326
+ export declare function buildMutationSnapshot(doc: BoundDocApi, options?: Pick<SnapshotOptions, 'includeDomains'>): Promise<DocumentSnapshot>;
316
327
  /**
317
328
  * Structured ambiguity error returned when multiple candidates match a
318
329
  * selector and the plan required uniqueness.
@@ -97,6 +97,48 @@ function includesDomain(requested, domain) {
97
97
  function truncateBlockText(value, limit) {
98
98
  return limit == null ? value : value.slice(0, limit);
99
99
  }
100
+ function populateTableCellsFromExtract(tables, extractRaw, tableOrdinalBase = 0) {
101
+ const extractRec = asRecord(extractRaw);
102
+ const extractBlocks = Array.isArray(extractRec?.blocks) ? extractRec.blocks : [];
103
+ const cellsByTableNodeId = new Map();
104
+ for (const block of extractBlocks) {
105
+ const rec = asRecord(block);
106
+ const tableContext = asRecord(rec?.tableContext);
107
+ if (!rec || !tableContext)
108
+ continue;
109
+ const tableOrdinal = asNumber(tableContext.tableOrdinal, -1);
110
+ const rowIndex = asNumber(tableContext.rowIndex, -1);
111
+ const columnIndex = asNumber(tableContext.columnIndex, -1);
112
+ if (tableOrdinal < 0 || rowIndex < 0 || columnIndex < 0)
113
+ continue;
114
+ const table = tables[tableOrdinal - tableOrdinalBase];
115
+ if (!table)
116
+ continue;
117
+ const key = `${rowIndex}:${columnIndex}`;
118
+ const text = asString(rec.text);
119
+ const nodeId = asString(rec.nodeId) || undefined;
120
+ const cellMap = cellsByTableNodeId.get(table.nodeId) ??
121
+ new Map();
122
+ const existing = cellMap.get(key);
123
+ cellMap.set(key, {
124
+ rowIndex,
125
+ columnIndex,
126
+ text: existing == null || text.length === 0
127
+ ? (existing?.text ?? text)
128
+ : existing.text.length === 0
129
+ ? text
130
+ : `${existing.text}\n${text}`,
131
+ nodeId: existing?.nodeId ?? nodeId,
132
+ });
133
+ cellsByTableNodeId.set(table.nodeId, cellMap);
134
+ }
135
+ for (const table of tables) {
136
+ const cellMap = cellsByTableNodeId.get(table.nodeId);
137
+ if (!cellMap)
138
+ continue;
139
+ table.cells = [...cellMap.values()].sort((left, right) => left.rowIndex - right.rowIndex || left.columnIndex - right.columnIndex);
140
+ }
141
+ }
100
142
  /**
101
143
  * Build a deterministic snapshot of a document. The snapshot uses only
102
144
  * read-mode operations from the generated contract — it never mutates state,
@@ -402,46 +444,7 @@ export async function buildDocumentSnapshot(doc, options = {}) {
402
444
  const extractFn = maybeMethod(doc, ['extract']);
403
445
  if (tables.length > 0 && extractFn) {
404
446
  const extractRaw = await safeCall(() => extractFn({}), null, recordError('extract'));
405
- const extractRec = asRecord(extractRaw);
406
- const extractBlocks = Array.isArray(extractRec?.blocks) ? extractRec.blocks : [];
407
- const cellsByTableNodeId = new Map();
408
- for (const block of extractBlocks) {
409
- const rec = asRecord(block);
410
- const tableContext = asRecord(rec?.tableContext);
411
- if (!rec || !tableContext)
412
- continue;
413
- const tableOrdinal = asNumber(tableContext.tableOrdinal, -1);
414
- const rowIndex = asNumber(tableContext.rowIndex, -1);
415
- const columnIndex = asNumber(tableContext.columnIndex, -1);
416
- if (tableOrdinal < 0 || rowIndex < 0 || columnIndex < 0)
417
- continue;
418
- const table = tables[tableOrdinal - precedingTableCount];
419
- if (!table)
420
- continue;
421
- const key = `${rowIndex}:${columnIndex}`;
422
- const text = asString(rec.text);
423
- const nodeId = asString(rec.nodeId) || undefined;
424
- const cellMap = cellsByTableNodeId.get(table.nodeId) ??
425
- new Map();
426
- const existing = cellMap.get(key);
427
- cellMap.set(key, {
428
- rowIndex,
429
- columnIndex,
430
- text: existing == null || text.length === 0
431
- ? (existing?.text ?? text)
432
- : existing.text.length === 0
433
- ? text
434
- : `${existing.text}\n${text}`,
435
- nodeId: existing?.nodeId ?? nodeId,
436
- });
437
- cellsByTableNodeId.set(table.nodeId, cellMap);
438
- }
439
- for (const table of tables) {
440
- const cellMap = cellsByTableNodeId.get(table.nodeId);
441
- if (!cellMap)
442
- continue;
443
- table.cells = [...cellMap.values()].sort((left, right) => left.rowIndex - right.rowIndex || left.columnIndex - right.columnIndex);
444
- }
447
+ populateTableCellsFromExtract(tables, extractRaw, precedingTableCount);
445
448
  }
446
449
  // Optionally enrich table cells with per-run formatting (opt-in; one
447
450
  // query.match per cell, bounded) so a reader can match a cell's pattern.
@@ -746,6 +749,144 @@ export async function buildDocumentSnapshot(doc, options = {}) {
746
749
  ...(finds ? { finds } : {}),
747
750
  };
748
751
  }
752
+ export class MutationSnapshotError extends Error {
753
+ code;
754
+ constructor(code, message) {
755
+ super(message);
756
+ this.code = code;
757
+ this.name = 'MutationSnapshotError';
758
+ }
759
+ }
760
+ const MUTATION_BLOCK_PAGE_SIZE = 1000;
761
+ const MUTATION_BLOCK_PAGE_CONCURRENCY = 4;
762
+ const MUTATION_NON_TABLE_DOMAINS = [
763
+ 'blocks',
764
+ 'lists',
765
+ 'comments',
766
+ 'trackedChanges',
767
+ 'sections',
768
+ 'headerFooters',
769
+ 'styles',
770
+ 'contentControls',
771
+ 'fields',
772
+ 'hyperlinks',
773
+ 'bookmarks',
774
+ 'permissionRanges',
775
+ 'images',
776
+ ];
777
+ /**
778
+ * Build the complete block index used to authorize mutations. Inspection
779
+ * snapshots stay windowed; mutation selectors must not treat that presentation
780
+ * window as the end of the document. Reads remain bounded and the revision is
781
+ * checked before the combined snapshot is returned.
782
+ */
783
+ export async function buildMutationSnapshot(doc, options = {}) {
784
+ const requestedDomains = options.includeDomains == null || options.includeDomains.length === 0 ? null : new Set(options.includeDomains);
785
+ const includeTables = requestedDomains == null || requestedDomains.has('tables');
786
+ const firstDomains = requestedDomains == null
787
+ ? MUTATION_NON_TABLE_DOMAINS
788
+ : [...requestedDomains].filter((domain) => domain !== 'tables').concat('blocks');
789
+ const first = await buildDocumentSnapshot(doc, {
790
+ includeDomains: firstDomains,
791
+ blockOffset: 0,
792
+ blockLimit: MUTATION_BLOCK_PAGE_SIZE,
793
+ });
794
+ assertMutationBlockRead(first, 0);
795
+ const total = first.counts.blocks;
796
+ if (first.blocks.length > total) {
797
+ throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: document reports ${total} blocks but returned ${first.blocks.length}`);
798
+ }
799
+ if (first.blocks.length === 0 && total > 0) {
800
+ throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: document reports ${total} blocks but returned none`);
801
+ }
802
+ const blocks = [...first.blocks];
803
+ const diagnostics = [...first.diagnostics];
804
+ const pageStride = first.blocks.length || MUTATION_BLOCK_PAGE_SIZE;
805
+ const offsets = first.blocks.length === total
806
+ ? []
807
+ : Array.from({ length: Math.ceil((total - pageStride) / pageStride) }, (_, index) => pageStride * (index + 1));
808
+ for (let index = 0; index < offsets.length; index += MUTATION_BLOCK_PAGE_CONCURRENCY) {
809
+ const batchOffsets = offsets.slice(index, index + MUTATION_BLOCK_PAGE_CONCURRENCY);
810
+ const pages = await Promise.all(batchOffsets.map((blockOffset) => buildDocumentSnapshot(doc, {
811
+ includeDomains: ['blocks'],
812
+ blockOffset,
813
+ blockLimit: pageStride,
814
+ })));
815
+ for (let pageIndex = 0; pageIndex < pages.length; pageIndex += 1) {
816
+ const page = pages[pageIndex];
817
+ const blockOffset = batchOffsets[pageIndex];
818
+ assertMutationBlockRead(page, blockOffset);
819
+ assertMutationSnapshotRevision(first.revision, page.revision);
820
+ if (page.blocks.length === 0) {
821
+ throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: block pagination stopped at ${blockOffset} of ${total} blocks`);
822
+ }
823
+ blocks.push(...page.blocks);
824
+ diagnostics.push(...page.diagnostics);
825
+ }
826
+ }
827
+ if (blocks.length !== total) {
828
+ throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: expected ${total} blocks but materialized ${blocks.length}`);
829
+ }
830
+ const tables = includeTables ? await buildMutationTables(doc, blocks, diagnostics) : [];
831
+ const finalIdentity = await buildDocumentSnapshot(doc, { countsOnly: true });
832
+ assertMutationSnapshotRevision(first.revision, finalIdentity.revision);
833
+ return {
834
+ ...first,
835
+ blocks,
836
+ tables,
837
+ diagnostics,
838
+ };
839
+ }
840
+ async function buildMutationTables(doc, blocks, diagnostics) {
841
+ const tableBlocks = blocks.filter((block) => block.nodeType === 'table');
842
+ const tables = tableBlocks.map((block, index) => ({
843
+ nodeId: block.nodeId,
844
+ ordinal: index + 1,
845
+ rows: 0,
846
+ columns: 0,
847
+ cells: [],
848
+ }));
849
+ const getTable = maybeMethod(doc, ['tables', 'get']);
850
+ if (getTable) {
851
+ for (let index = 0; index < tables.length; index += MUTATION_BLOCK_PAGE_CONCURRENCY) {
852
+ const batch = tables.slice(index, index + MUTATION_BLOCK_PAGE_CONCURRENCY);
853
+ await Promise.all(batch.map(async (table) => {
854
+ try {
855
+ const raw = asRecord(await getTable({ nodeId: table.nodeId }));
856
+ table.rows = asNumber(raw?.rows);
857
+ table.columns = asNumber(raw?.columns);
858
+ }
859
+ catch (error) {
860
+ diagnostics.push({
861
+ section: `tables.get:${table.nodeId}`,
862
+ message: error instanceof Error ? error.message : String(error),
863
+ });
864
+ }
865
+ }));
866
+ }
867
+ }
868
+ const extract = maybeMethod(doc, ['extract']);
869
+ if (tables.length > 0 && extract) {
870
+ try {
871
+ populateTableCellsFromExtract(tables, await extract({}));
872
+ }
873
+ catch (error) {
874
+ diagnostics.push({ section: 'extract', message: error instanceof Error ? error.message : String(error) });
875
+ }
876
+ }
877
+ return tables;
878
+ }
879
+ function assertMutationBlockRead(snapshot, blockOffset) {
880
+ const failure = snapshot.diagnostics.find((diagnostic) => diagnostic.section === 'blocks.list');
881
+ if (!failure)
882
+ return;
883
+ throw new MutationSnapshotError('SNAPSHOT_INCOMPLETE', `cannot build complete mutation snapshot: block page at offset ${blockOffset} failed: ${failure.message}`);
884
+ }
885
+ function assertMutationSnapshotRevision(expected, actual) {
886
+ if (expected === 'unknown' || actual === 'unknown' || expected === actual)
887
+ return;
888
+ throw new MutationSnapshotError('REVISION_CONFLICT', `document changed while resolving mutation selectors (${expected} -> ${actual}); retry the action`);
889
+ }
749
890
  /**
750
891
  * Structured ambiguity error returned when multiple candidates match a
751
892
  * selector and the plan required uniqueness.
@@ -227,8 +227,8 @@ function computeDeltaChecks(pre, post, checks, saveReopen) {
227
227
  else if (check.kind === 'comment-count-delta') {
228
228
  results.push({
229
229
  check,
230
- passed: post.comments.length - pre.comments.length === check.delta,
231
- detail: `pre=${pre.comments.length} post=${post.comments.length}`,
230
+ passed: post.counts.comments - pre.counts.comments === check.delta,
231
+ detail: `pre=${pre.counts.comments} post=${post.counts.comments}`,
232
232
  });
233
233
  }
234
234
  else if (check.kind === 'tracked-change-count-delta') {
@@ -313,7 +313,7 @@ async function trySaveReopen(doc, checks) {
313
313
  await saveAny.call(doc, {});
314
314
  // Rebuild a fresh snapshot after save. Host-level true reopen still needs
315
315
  // a new document handle, which this runtime cannot force on its own.
316
- const fresh = await docSnapshot.buildDocumentSnapshot(doc);
316
+ const fresh = await docSnapshot.buildMutationSnapshot(doc);
317
317
  for (const check of checks) {
318
318
  if (check.kind === 'save-reopen-text-contains') {
319
319
  const found = fresh.blocks.some((b) => b.text.includes(check.text));
@@ -350,7 +350,23 @@ async function agentApply(doc, args) {
350
350
  errors: validation.errors.map((e) => ({ code: e.code, message: e.message })),
351
351
  };
352
352
  }
353
- const preSnapshot = await docSnapshot.buildDocumentSnapshot(doc);
353
+ let preSnapshot;
354
+ try {
355
+ preSnapshot = await docSnapshot.buildMutationSnapshot(doc);
356
+ }
357
+ catch (error) {
358
+ if (!(error instanceof docSnapshot.MutationSnapshotError))
359
+ throw error;
360
+ return {
361
+ status: 'failed',
362
+ intent: plan.intent,
363
+ preSnapshot: { revision: 'unknown', counts: emptyCounts() },
364
+ selectedTargets: [],
365
+ executedOperations: [],
366
+ verification: [],
367
+ errors: [{ code: error.code, message: error.message, recovery: { kind: 'retry' } }],
368
+ };
369
+ }
354
370
  const selectedTargets = [];
355
371
  const executedOperations = [];
356
372
  const bindings = new Map();
@@ -415,7 +431,23 @@ async function agentApply(doc, args) {
415
431
  errors: [{ code: 'APPLY_FAILED', message }],
416
432
  };
417
433
  }
418
- const postSnapshot = await docSnapshot.buildDocumentSnapshot(doc);
434
+ let postSnapshot;
435
+ try {
436
+ postSnapshot = await docSnapshot.buildMutationSnapshot(doc);
437
+ }
438
+ catch (error) {
439
+ if (!(error instanceof docSnapshot.MutationSnapshotError))
440
+ throw error;
441
+ return {
442
+ status: 'failed',
443
+ intent: plan.intent,
444
+ preSnapshot: { revision: preSnapshot.revision, counts: preSnapshot.counts },
445
+ selectedTargets,
446
+ executedOperations,
447
+ verification: [],
448
+ errors: [{ code: error.code, message: error.message, recovery: { kind: 'reinspect' } }],
449
+ };
450
+ }
419
451
  const verifyStep = plan.steps.find((s) => s.kind === 'verify');
420
452
  let saveReopen;
421
453
  const shouldSaveReopen = (verifyStep?.kind === 'verify' && (verifyStep.saveReopen || verificationNeedsSaveReopen(verifyStep.checks))) ||
@@ -437,7 +469,23 @@ async function agentApply(doc, args) {
437
469
  };
438
470
  }
439
471
  async function agentVerify(doc, args) {
440
- const snapshot = await docSnapshot.buildDocumentSnapshot(doc);
472
+ let snapshot;
473
+ try {
474
+ snapshot = await docSnapshot.buildMutationSnapshot(doc);
475
+ }
476
+ catch (error) {
477
+ if (!(error instanceof docSnapshot.MutationSnapshotError))
478
+ throw error;
479
+ return {
480
+ status: 'failed',
481
+ intent: 'verify',
482
+ preSnapshot: { revision: 'unknown', counts: emptyCounts() },
483
+ selectedTargets: [],
484
+ executedOperations: [],
485
+ verification: [],
486
+ errors: [{ code: error.code, message: error.message, recovery: { kind: 'retry' } }],
487
+ };
488
+ }
441
489
  let saveReopen;
442
490
  if (args.saveReopen || verificationNeedsSaveReopen(args.checks)) {
443
491
  saveReopen = await trySaveReopen(doc, args.checks);