@superdoc/sdk 2.10.0-next.1 → 2.10.0-next.3

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.
@@ -775,13 +775,28 @@ async function executeCreateHeading(doc, text, level, placement, changeMode) {
775
775
  // Dual dialect — see executeCreateParagraph.
776
776
  return fn(params, changeMode ? { changeMode } : undefined);
777
777
  }
778
- async function executeMutations(doc, steps, changeMode) {
778
+ async function executeMutations(doc, steps, changeMode, expectedRevision) {
779
779
  const fn = maybeMethod(doc, ['mutations', 'apply']);
780
780
  if (!fn)
781
781
  throw new errors.SuperDocCliError('doc.mutations.apply is not available on the document handle.', {
782
782
  code: 'TOOL_DISPATCH_NOT_FOUND',
783
783
  });
784
- return fn({ atomic: true, changeMode: changeMode ?? 'direct', steps });
784
+ return fn({
785
+ atomic: true,
786
+ changeMode: changeMode ?? 'direct',
787
+ steps,
788
+ ...(expectedRevision ? { expectedRevision } : {}),
789
+ });
790
+ }
791
+ async function previewMutations(doc, steps, changeMode, expectedRevision) {
792
+ const fn = maybeMethod(doc, ['mutations', 'preview']);
793
+ if (!fn) {
794
+ throw new errors.SuperDocCliError('doc.mutations.preview is not available on the document handle.', {
795
+ code: 'TOOL_DISPATCH_NOT_FOUND',
796
+ });
797
+ }
798
+ const result = await fn({ atomic: true, changeMode: changeMode ?? 'direct', expectedRevision, steps });
799
+ return isRecord(result) ? result : {};
785
800
  }
786
801
  async function executeCreateTable(doc, args, placement) {
787
802
  const fn = maybeMethod(doc, ['create', 'table']);
@@ -1136,7 +1151,7 @@ async function runInsertHeading(doc, args) {
1136
1151
  }
1137
1152
  async function runReplaceText(doc, args) {
1138
1153
  const selectorDomains = args.selector ? snapshotDomainsForSelector(args.selector) : null;
1139
- const requiresBlockSnapshot = args.selector != null || args.edits.length > 1;
1154
+ const requiresBlockSnapshot = args.selector != null;
1140
1155
  const preIdentity = requiresBlockSnapshot ? null : await readDocumentIdentity(doc);
1141
1156
  const pre = requiresBlockSnapshot
1142
1157
  ? await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] })
@@ -1149,6 +1164,7 @@ async function runReplaceText(doc, args) {
1149
1164
  const selectedTargets = [];
1150
1165
  let skippedEdits = [];
1151
1166
  let steps;
1167
+ let applyExpectedRevision;
1152
1168
  // Blocks whose text this call rewrites, with their pre-mutation run layout:
1153
1169
  // the engine replace lane renders the replaced range as uniform runs, so a
1154
1170
  // whole-paragraph find/replace flattens intra-paragraph formatting (bold
@@ -1200,98 +1216,9 @@ async function runReplaceText(doc, args) {
1200
1216
  });
1201
1217
  }
1202
1218
  else {
1203
- const matchingEdits = args.edits.length === 1
1204
- ? args.edits
1205
- : args.edits.filter((edit) => pre.blocks.some((block) => textIncludes(block.text, edit.find, caseSensitive)));
1206
- skippedEdits = args.edits.filter((edit) => !matchingEdits.includes(edit));
1207
- if (matchingEdits.length === 0) {
1208
- return {
1209
- status: 'failed',
1210
- intent: 'replace_text',
1211
- preSnapshot: { revision: pre.revision, counts: pre.counts },
1212
- selectedTargets: [],
1213
- executedOperations: [],
1214
- verification: [],
1215
- errors: [
1216
- {
1217
- code: 'ACTION_FAILED',
1218
- message: 'none of the requested text replacements matched the current document',
1219
- },
1220
- ],
1221
- };
1222
- }
1223
- // Rewrite affected BODY blocks with block-targeted rewrites. The select
1224
- // (matched-range) rewrite lane flattens the WHOLE paragraph's runs in
1225
- // collaborative sessions and leaves format.apply inert on the block
1226
- // afterwards, so per-block rewrites + the preserve pass below are the
1227
- // only path that keeps intra-paragraph formatting alive. Bounded; edits
1228
- // that touch no scanned body block (e.g. header/footer text) fall back
1229
- // to a select-rewrite step.
1230
- // Tracked mode keeps the matched-range lane. A block rewrite asks the
1231
- // engine to delete and re-insert the WHOLE paragraph, so a reviewer sees
1232
- // the entire block struck and re-added instead of the words that changed,
1233
- // and each block contributes its own replacement/deletion pair. The
1234
- // per-block lane exists to keep intra-paragraph formatting alive, which
1235
- // is a direct-mode concern; under tracked mode the revision itself is the
1236
- // reviewable unit and must stay scoped to the edit.
1237
- const scanBlocks = args.changeMode === 'tracked'
1238
- ? []
1239
- : pre.blocks.length > 0
1240
- ? pre.blocks
1241
- : (await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: ['blocks'] })).blocks;
1242
- // An edit counts as covered only when EVERY block holding it got a
1243
- // rewrite target. The per-block lane is bounded (run capture costs a read
1244
- // each), so a find present in more blocks than the cap must fall through
1245
- // to the select lane below, which carries `require: 'all'`. Marking such
1246
- // an edit covered after its first match would replace only the first
1247
- // PRESERVE_TARGET_CAP blocks and still report success.
1248
- const blocksHoldingEdit = matchingEdits.map((edit) => scanBlocks.filter((block) => block.nodeId && block.text && textIncludes(block.text, edit.find, caseSensitive))
1249
- .length);
1250
- const blocksCoveredForEdit = Array.from({ length: matchingEdits.length }, () => 0);
1251
- for (const block of scanBlocks) {
1252
- if (preserveTargets.length >= PRESERVE_TARGET_CAP)
1253
- break;
1254
- if (!block.nodeId || !block.text)
1255
- continue;
1256
- let newText = block.text;
1257
- const touched = [];
1258
- matchingEdits.forEach((edit, editIndex) => {
1259
- if (textIncludes(newText, edit.find, caseSensitive)) {
1260
- newText = replaceAllText(newText, edit.find, edit.replace, caseSensitive);
1261
- touched.push(editIndex);
1262
- }
1263
- });
1264
- if (newText === block.text)
1265
- continue;
1266
- preserveTargets.push({
1267
- nodeId: block.nodeId,
1268
- nodeType: block.nodeType ?? 'paragraph',
1269
- oldText: block.text,
1270
- newText,
1271
- runs: await captureBlockRuns(doc, block.nodeId, block.text),
1272
- });
1273
- for (const editIndex of touched)
1274
- blocksCoveredForEdit[editIndex] += 1;
1275
- }
1276
- const coveredEdits = new Set(matchingEdits
1277
- .map((_edit, editIndex) => editIndex)
1278
- .filter((editIndex) => blocksHoldingEdit[editIndex] > 0 && blocksCoveredForEdit[editIndex] >= blocksHoldingEdit[editIndex]));
1279
- steps = [
1280
- ...preserveTargets.map((entry, index) => ({
1281
- id: `replace-block-${index + 1}`,
1282
- op: 'text.rewrite',
1283
- where: { by: 'block', nodeType: entry.nodeType, nodeId: entry.nodeId },
1284
- args: {
1285
- replacement: { text: entry.newText },
1286
- style: preserveRewriteStyle(),
1287
- },
1288
- })),
1289
- // Edits that touched no scanned body block (header/footer text, or a
1290
- // doc larger than the scan window) keep the original select-rewrite.
1291
- ...matchingEdits
1292
- .map((edit, editIndex) => ({ edit, editIndex }))
1293
- .filter(({ editIndex }) => !coveredEdits.has(editIndex))
1294
- .map(({ edit, editIndex }) => ({
1219
+ let candidates = args.edits.map((edit, editIndex) => ({
1220
+ edit,
1221
+ step: {
1295
1222
  id: `replace-${editIndex + 1}`,
1296
1223
  op: 'text.rewrite',
1297
1224
  where: {
@@ -1308,10 +1235,42 @@ async function runReplaceText(doc, args) {
1308
1235
  replacement: { text: edit.replace },
1309
1236
  style: preserveRewriteStyle(),
1310
1237
  },
1311
- })),
1312
- ];
1238
+ },
1239
+ }));
1240
+ if (candidates.length > 1) {
1241
+ applyExpectedRevision = pre.revision;
1242
+ while (candidates.length > 0) {
1243
+ const preview = await previewMutations(doc, candidates.map((candidate) => candidate.step), args.changeMode, pre.revision);
1244
+ if (preview.valid === true)
1245
+ break;
1246
+ const failures = Array.isArray(preview.failures) ? preview.failures.filter(isRecord) : [];
1247
+ const absent = failures.find((failure) => failure.code === 'MATCH_NOT_FOUND' && typeof failure.stepId === 'string');
1248
+ if (!absent) {
1249
+ throw new Error(typeof failures[0]?.message === 'string' ? failures[0].message : 'replacement plan could not be prepared');
1250
+ }
1251
+ const absentIndex = candidates.findIndex((candidate) => candidate.step.id === absent.stepId);
1252
+ if (absentIndex < 0)
1253
+ throw new Error('replacement preview returned an unknown step id');
1254
+ skippedEdits.push({ find: candidates[absentIndex].edit.find });
1255
+ candidates = candidates.filter((_candidate, index) => index !== absentIndex);
1256
+ }
1257
+ }
1258
+ if (candidates.length === 0) {
1259
+ return {
1260
+ status: 'failed',
1261
+ intent: 'replace_text',
1262
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
1263
+ selectedTargets: [],
1264
+ executedOperations: [],
1265
+ verification: [],
1266
+ errors: [
1267
+ { code: 'ACTION_FAILED', message: 'none of the requested text replacements matched the current document' },
1268
+ ],
1269
+ };
1270
+ }
1271
+ steps = candidates.map((candidate) => candidate.step);
1313
1272
  }
1314
- const result = await executeMutations(doc, steps, args.changeMode);
1273
+ const result = await executeMutations(doc, steps, args.changeMode, applyExpectedRevision);
1315
1274
  const preserved = [];
1316
1275
  for (const entry of preserveTargets) {
1317
1276
  const restored = await preserveRunPatternAfterRewrite(doc, entry.nodeId, entry.oldText, entry.runs, entry.newText);
@@ -4839,12 +4798,6 @@ function paragraphMarkRunPropsOf(payload) {
4839
4798
  * Spaced dashes are listed separately from bare ones because the label run
4840
4799
  * usually stops BEFORE the space ("JavaScript / TypeScript" + " — SuperDoc…").
4841
4800
  */
4842
- /**
4843
- * How many blocks the per-block replace lane will capture runs for. Each target
4844
- * costs a `query.match` read, so the lane is bounded; edits reaching past it
4845
- * fall through to the select lane rather than being silently truncated.
4846
- */
4847
- const PRESERVE_TARGET_CAP = 20;
4848
4801
  const LEAD_IN_DELIMITERS = [':', ' — ', ' – ', ' - ', '—', '–'];
4849
4802
  /** A lead-in is a LABEL, not a sentence — longer spans are prose, not a pattern. */
4850
4803
  const LEAD_IN_MAX_CHARS = 90;
@@ -772,13 +772,28 @@ async function executeCreateHeading(doc, text, level, placement, changeMode) {
772
772
  // Dual dialect — see executeCreateParagraph.
773
773
  return fn(params, changeMode ? { changeMode } : undefined);
774
774
  }
775
- async function executeMutations(doc, steps, changeMode) {
775
+ async function executeMutations(doc, steps, changeMode, expectedRevision) {
776
776
  const fn = maybeMethod(doc, ['mutations', 'apply']);
777
777
  if (!fn)
778
778
  throw new SuperDocCliError('doc.mutations.apply is not available on the document handle.', {
779
779
  code: 'TOOL_DISPATCH_NOT_FOUND',
780
780
  });
781
- return fn({ atomic: true, changeMode: changeMode ?? 'direct', steps });
781
+ return fn({
782
+ atomic: true,
783
+ changeMode: changeMode ?? 'direct',
784
+ steps,
785
+ ...(expectedRevision ? { expectedRevision } : {}),
786
+ });
787
+ }
788
+ async function previewMutations(doc, steps, changeMode, expectedRevision) {
789
+ const fn = maybeMethod(doc, ['mutations', 'preview']);
790
+ if (!fn) {
791
+ throw new SuperDocCliError('doc.mutations.preview is not available on the document handle.', {
792
+ code: 'TOOL_DISPATCH_NOT_FOUND',
793
+ });
794
+ }
795
+ const result = await fn({ atomic: true, changeMode: changeMode ?? 'direct', expectedRevision, steps });
796
+ return isRecord(result) ? result : {};
782
797
  }
783
798
  async function executeCreateTable(doc, args, placement) {
784
799
  const fn = maybeMethod(doc, ['create', 'table']);
@@ -1133,7 +1148,7 @@ async function runInsertHeading(doc, args) {
1133
1148
  }
1134
1149
  async function runReplaceText(doc, args) {
1135
1150
  const selectorDomains = args.selector ? snapshotDomainsForSelector(args.selector) : null;
1136
- const requiresBlockSnapshot = args.selector != null || args.edits.length > 1;
1151
+ const requiresBlockSnapshot = args.selector != null;
1137
1152
  const preIdentity = requiresBlockSnapshot ? null : await readDocumentIdentity(doc);
1138
1153
  const pre = requiresBlockSnapshot
1139
1154
  ? await buildDocumentSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] })
@@ -1146,6 +1161,7 @@ async function runReplaceText(doc, args) {
1146
1161
  const selectedTargets = [];
1147
1162
  let skippedEdits = [];
1148
1163
  let steps;
1164
+ let applyExpectedRevision;
1149
1165
  // Blocks whose text this call rewrites, with their pre-mutation run layout:
1150
1166
  // the engine replace lane renders the replaced range as uniform runs, so a
1151
1167
  // whole-paragraph find/replace flattens intra-paragraph formatting (bold
@@ -1197,98 +1213,9 @@ async function runReplaceText(doc, args) {
1197
1213
  });
1198
1214
  }
1199
1215
  else {
1200
- const matchingEdits = args.edits.length === 1
1201
- ? args.edits
1202
- : args.edits.filter((edit) => pre.blocks.some((block) => textIncludes(block.text, edit.find, caseSensitive)));
1203
- skippedEdits = args.edits.filter((edit) => !matchingEdits.includes(edit));
1204
- if (matchingEdits.length === 0) {
1205
- return {
1206
- status: 'failed',
1207
- intent: 'replace_text',
1208
- preSnapshot: { revision: pre.revision, counts: pre.counts },
1209
- selectedTargets: [],
1210
- executedOperations: [],
1211
- verification: [],
1212
- errors: [
1213
- {
1214
- code: 'ACTION_FAILED',
1215
- message: 'none of the requested text replacements matched the current document',
1216
- },
1217
- ],
1218
- };
1219
- }
1220
- // Rewrite affected BODY blocks with block-targeted rewrites. The select
1221
- // (matched-range) rewrite lane flattens the WHOLE paragraph's runs in
1222
- // collaborative sessions and leaves format.apply inert on the block
1223
- // afterwards, so per-block rewrites + the preserve pass below are the
1224
- // only path that keeps intra-paragraph formatting alive. Bounded; edits
1225
- // that touch no scanned body block (e.g. header/footer text) fall back
1226
- // to a select-rewrite step.
1227
- // Tracked mode keeps the matched-range lane. A block rewrite asks the
1228
- // engine to delete and re-insert the WHOLE paragraph, so a reviewer sees
1229
- // the entire block struck and re-added instead of the words that changed,
1230
- // and each block contributes its own replacement/deletion pair. The
1231
- // per-block lane exists to keep intra-paragraph formatting alive, which
1232
- // is a direct-mode concern; under tracked mode the revision itself is the
1233
- // reviewable unit and must stay scoped to the edit.
1234
- const scanBlocks = args.changeMode === 'tracked'
1235
- ? []
1236
- : pre.blocks.length > 0
1237
- ? pre.blocks
1238
- : (await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] })).blocks;
1239
- // An edit counts as covered only when EVERY block holding it got a
1240
- // rewrite target. The per-block lane is bounded (run capture costs a read
1241
- // each), so a find present in more blocks than the cap must fall through
1242
- // to the select lane below, which carries `require: 'all'`. Marking such
1243
- // an edit covered after its first match would replace only the first
1244
- // PRESERVE_TARGET_CAP blocks and still report success.
1245
- const blocksHoldingEdit = matchingEdits.map((edit) => scanBlocks.filter((block) => block.nodeId && block.text && textIncludes(block.text, edit.find, caseSensitive))
1246
- .length);
1247
- const blocksCoveredForEdit = Array.from({ length: matchingEdits.length }, () => 0);
1248
- for (const block of scanBlocks) {
1249
- if (preserveTargets.length >= PRESERVE_TARGET_CAP)
1250
- break;
1251
- if (!block.nodeId || !block.text)
1252
- continue;
1253
- let newText = block.text;
1254
- const touched = [];
1255
- matchingEdits.forEach((edit, editIndex) => {
1256
- if (textIncludes(newText, edit.find, caseSensitive)) {
1257
- newText = replaceAllText(newText, edit.find, edit.replace, caseSensitive);
1258
- touched.push(editIndex);
1259
- }
1260
- });
1261
- if (newText === block.text)
1262
- continue;
1263
- preserveTargets.push({
1264
- nodeId: block.nodeId,
1265
- nodeType: block.nodeType ?? 'paragraph',
1266
- oldText: block.text,
1267
- newText,
1268
- runs: await captureBlockRuns(doc, block.nodeId, block.text),
1269
- });
1270
- for (const editIndex of touched)
1271
- blocksCoveredForEdit[editIndex] += 1;
1272
- }
1273
- const coveredEdits = new Set(matchingEdits
1274
- .map((_edit, editIndex) => editIndex)
1275
- .filter((editIndex) => blocksHoldingEdit[editIndex] > 0 && blocksCoveredForEdit[editIndex] >= blocksHoldingEdit[editIndex]));
1276
- steps = [
1277
- ...preserveTargets.map((entry, index) => ({
1278
- id: `replace-block-${index + 1}`,
1279
- op: 'text.rewrite',
1280
- where: { by: 'block', nodeType: entry.nodeType, nodeId: entry.nodeId },
1281
- args: {
1282
- replacement: { text: entry.newText },
1283
- style: preserveRewriteStyle(),
1284
- },
1285
- })),
1286
- // Edits that touched no scanned body block (header/footer text, or a
1287
- // doc larger than the scan window) keep the original select-rewrite.
1288
- ...matchingEdits
1289
- .map((edit, editIndex) => ({ edit, editIndex }))
1290
- .filter(({ editIndex }) => !coveredEdits.has(editIndex))
1291
- .map(({ edit, editIndex }) => ({
1216
+ let candidates = args.edits.map((edit, editIndex) => ({
1217
+ edit,
1218
+ step: {
1292
1219
  id: `replace-${editIndex + 1}`,
1293
1220
  op: 'text.rewrite',
1294
1221
  where: {
@@ -1305,10 +1232,42 @@ async function runReplaceText(doc, args) {
1305
1232
  replacement: { text: edit.replace },
1306
1233
  style: preserveRewriteStyle(),
1307
1234
  },
1308
- })),
1309
- ];
1235
+ },
1236
+ }));
1237
+ if (candidates.length > 1) {
1238
+ applyExpectedRevision = pre.revision;
1239
+ while (candidates.length > 0) {
1240
+ const preview = await previewMutations(doc, candidates.map((candidate) => candidate.step), args.changeMode, pre.revision);
1241
+ if (preview.valid === true)
1242
+ break;
1243
+ const failures = Array.isArray(preview.failures) ? preview.failures.filter(isRecord) : [];
1244
+ const absent = failures.find((failure) => failure.code === 'MATCH_NOT_FOUND' && typeof failure.stepId === 'string');
1245
+ if (!absent) {
1246
+ throw new Error(typeof failures[0]?.message === 'string' ? failures[0].message : 'replacement plan could not be prepared');
1247
+ }
1248
+ const absentIndex = candidates.findIndex((candidate) => candidate.step.id === absent.stepId);
1249
+ if (absentIndex < 0)
1250
+ throw new Error('replacement preview returned an unknown step id');
1251
+ skippedEdits.push({ find: candidates[absentIndex].edit.find });
1252
+ candidates = candidates.filter((_candidate, index) => index !== absentIndex);
1253
+ }
1254
+ }
1255
+ if (candidates.length === 0) {
1256
+ return {
1257
+ status: 'failed',
1258
+ intent: 'replace_text',
1259
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
1260
+ selectedTargets: [],
1261
+ executedOperations: [],
1262
+ verification: [],
1263
+ errors: [
1264
+ { code: 'ACTION_FAILED', message: 'none of the requested text replacements matched the current document' },
1265
+ ],
1266
+ };
1267
+ }
1268
+ steps = candidates.map((candidate) => candidate.step);
1310
1269
  }
1311
- const result = await executeMutations(doc, steps, args.changeMode);
1270
+ const result = await executeMutations(doc, steps, args.changeMode, applyExpectedRevision);
1312
1271
  const preserved = [];
1313
1272
  for (const entry of preserveTargets) {
1314
1273
  const restored = await preserveRunPatternAfterRewrite(doc, entry.nodeId, entry.oldText, entry.runs, entry.newText);
@@ -4836,12 +4795,6 @@ function paragraphMarkRunPropsOf(payload) {
4836
4795
  * Spaced dashes are listed separately from bare ones because the label run
4837
4796
  * usually stops BEFORE the space ("JavaScript / TypeScript" + " — SuperDoc…").
4838
4797
  */
4839
- /**
4840
- * How many blocks the per-block replace lane will capture runs for. Each target
4841
- * costs a `query.match` read, so the lane is bounded; edits reaching past it
4842
- * fall through to the select lane rather than being silently truncated.
4843
- */
4844
- const PRESERVE_TARGET_CAP = 20;
4845
4798
  const LEAD_IN_DELIMITERS = [':', ' — ', ' – ', ' - ', '—', '–'];
4846
4799
  /** A lead-in is a LABEL, not a sentence — longer spans are prose, not a pattern. */
4847
4800
  const LEAD_IN_MAX_CHARS = 90;