@buoy-gg/network 7.0.34 → 7.0.36

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 (38) hide show
  1. package/lib/commonjs/network/components/NetworkCopySettingsView.js +2 -2
  2. package/lib/commonjs/network/components/NetworkEventDetailView.js +6 -6
  3. package/lib/commonjs/network/components/NetworkEventItemCompact.js +3 -3
  4. package/lib/commonjs/network/components/NetworkFilterViewV3.js +3 -3
  5. package/lib/commonjs/network/components/NetworkHeaderLive.js +2 -2
  6. package/lib/commonjs/network/components/NetworkListScreen.js +1 -1
  7. package/lib/commonjs/network/components/NetworkOverrideEditor.js +1 -1
  8. package/lib/commonjs/network/components/NetworkOverridesScreen.js +1 -1
  9. package/lib/commonjs/network/components/NetworkSavedScreen.js +1 -1
  10. package/lib/commonjs/network/components/response-editor/Explorer.js +13 -13
  11. package/lib/commonjs/network/sync/networkSyncAdapter.js +198 -2
  12. package/lib/commonjs/network/utils/networkListener.js +156 -2
  13. package/lib/module/network/components/NetworkCopySettingsView.js +3 -3
  14. package/lib/module/network/components/NetworkEventDetailView.js +7 -7
  15. package/lib/module/network/components/NetworkEventItemCompact.js +4 -4
  16. package/lib/module/network/components/NetworkFilterViewV3.js +4 -4
  17. package/lib/module/network/components/NetworkHeaderLive.js +3 -3
  18. package/lib/module/network/components/NetworkListScreen.js +2 -2
  19. package/lib/module/network/components/NetworkOverrideEditor.js +2 -2
  20. package/lib/module/network/components/NetworkOverridesScreen.js +2 -2
  21. package/lib/module/network/components/NetworkSavedScreen.js +2 -2
  22. package/lib/module/network/components/response-editor/Explorer.js +14 -14
  23. package/lib/module/network/sync/networkSyncAdapter.js +199 -2
  24. package/lib/module/network/utils/networkListener.js +156 -2
  25. package/lib/typescript/network/components/NetworkCopySettingsView.d.ts.map +1 -1
  26. package/lib/typescript/network/components/NetworkEventDetailView.d.ts.map +1 -1
  27. package/lib/typescript/network/components/NetworkEventItemCompact.d.ts.map +1 -1
  28. package/lib/typescript/network/components/NetworkFilterViewV3.d.ts.map +1 -1
  29. package/lib/typescript/network/components/NetworkHeaderLive.d.ts.map +1 -1
  30. package/lib/typescript/network/components/NetworkListScreen.d.ts.map +1 -1
  31. package/lib/typescript/network/components/NetworkOverrideEditor.d.ts.map +1 -1
  32. package/lib/typescript/network/components/NetworkOverridesScreen.d.ts.map +1 -1
  33. package/lib/typescript/network/components/NetworkSavedScreen.d.ts.map +1 -1
  34. package/lib/typescript/network/sync/networkSyncAdapter.d.ts +12 -0
  35. package/lib/typescript/network/sync/networkSyncAdapter.d.ts.map +1 -1
  36. package/lib/typescript/network/utils/networkListener.d.ts +11 -27
  37. package/lib/typescript/network/utils/networkListener.d.ts.map +1 -1
  38. package/package.json +2 -2
@@ -726,13 +726,13 @@ const styles = _reactNative.StyleSheet.create({
726
726
  maxHeight: 300
727
727
  },
728
728
  markdownText: {
729
- fontFamily: "monospace",
729
+ fontFamily: _sharedUi.monoFont,
730
730
  fontSize: 11,
731
731
  color: _sharedUi.macOSColors.text.primary,
732
732
  lineHeight: 18
733
733
  },
734
734
  plaintextText: {
735
- fontFamily: "monospace",
735
+ fontFamily: _sharedUi.monoFont,
736
736
  fontSize: 11,
737
737
  color: _sharedUi.macOSColors.text.primary,
738
738
  lineHeight: 18
@@ -870,12 +870,12 @@ const styles = _reactNative.StyleSheet.create({
870
870
  urlPath: {
871
871
  color: _sharedUi.night.color.textSecondary,
872
872
  fontSize: 11,
873
- fontFamily: "monospace"
873
+ fontFamily: _sharedUi.monoFont
874
874
  },
875
875
  urlQueryString: {
876
876
  color: _sharedUi.night.color.info,
877
877
  fontSize: 11,
878
- fontFamily: "monospace"
878
+ fontFamily: _sharedUi.monoFont
879
879
  },
880
880
  // Query Parameters - Collapsible section
881
881
  queryParamsCollapsible: {
@@ -952,7 +952,7 @@ const styles = _reactNative.StyleSheet.create({
952
952
  sizeValue: {
953
953
  color: _sharedUi.night.color.info,
954
954
  fontSize: 10,
955
- fontFamily: "monospace",
955
+ fontFamily: _sharedUi.monoFont,
956
956
  fontWeight: "600"
957
957
  },
958
958
  /* ---- Timing phases ---- */
@@ -997,7 +997,7 @@ const styles = _reactNative.StyleSheet.create({
997
997
  width: 72,
998
998
  textAlign: "right",
999
999
  fontSize: 12,
1000
- fontFamily: "monospace",
1000
+ fontFamily: _sharedUi.monoFont,
1001
1001
  color: _sharedUi.night.color.text
1002
1002
  },
1003
1003
  // Collapsible section styles
@@ -1033,7 +1033,7 @@ const styles = _reactNative.StyleSheet.create({
1033
1033
  collapsibleCount: {
1034
1034
  color: _sharedUi.night.color.textTertiary,
1035
1035
  fontSize: 11.5,
1036
- fontFamily: "monospace"
1036
+ fontFamily: _sharedUi.monoFont
1037
1037
  },
1038
1038
  collapsibleContent: {
1039
1039
  padding: 12
@@ -1107,7 +1107,7 @@ const styles = _reactNative.StyleSheet.create({
1107
1107
  filterOptionValue: {
1108
1108
  color: _sharedUi.night.color.text,
1109
1109
  fontSize: 13,
1110
- fontFamily: "monospace"
1110
+ fontFamily: _sharedUi.monoFont
1111
1111
  },
1112
1112
  filterToggle: {
1113
1113
  paddingHorizontal: 10,
@@ -319,7 +319,7 @@ const styles = _reactNative.StyleSheet.create({
319
319
  fontSize: 12,
320
320
  color: _sharedUi.night.color.text,
321
321
  lineHeight: 16,
322
- fontFamily: "monospace"
322
+ fontFamily: _sharedUi.monoFont
323
323
  },
324
324
  rightSection: {
325
325
  flexDirection: "row",
@@ -334,7 +334,7 @@ const styles = _reactNative.StyleSheet.create({
334
334
  bottomRightText: {
335
335
  fontSize: 9,
336
336
  color: _sharedUi.night.color.textTertiary,
337
- fontFamily: "monospace"
337
+ fontFamily: _sharedUi.monoFont
338
338
  },
339
339
  statusBadge: {
340
340
  paddingHorizontal: 4,
@@ -390,7 +390,7 @@ const styles = _reactNative.StyleSheet.create({
390
390
  sizeText: {
391
391
  fontSize: 8,
392
392
  color: _sharedUi.night.color.textSecondary,
393
- fontFamily: "monospace"
393
+ fontFamily: _sharedUi.monoFont
394
394
  },
395
395
  clientText: {
396
396
  fontSize: 8,
@@ -388,7 +388,7 @@ const styles = _reactNative.StyleSheet.create({
388
388
  },
389
389
  modeBadgeText: {
390
390
  fontSize: 9,
391
- fontFamily: "monospace",
391
+ fontFamily: _sharedUi.monoFont,
392
392
  fontWeight: "700",
393
393
  letterSpacing: 0.5
394
394
  },
@@ -402,7 +402,7 @@ const styles = _reactNative.StyleSheet.create({
402
402
  fontSize: 11,
403
403
  fontWeight: "600",
404
404
  color: _sharedUi.night.color.textSecondary,
405
- fontFamily: "monospace",
405
+ fontFamily: _sharedUi.monoFont,
406
406
  letterSpacing: 0.3
407
407
  },
408
408
  modeToggleGroup: {
@@ -428,7 +428,7 @@ const styles = _reactNative.StyleSheet.create({
428
428
  fontSize: 11,
429
429
  fontWeight: "700",
430
430
  color: _sharedUi.night.color.textSecondary,
431
- fontFamily: "monospace",
431
+ fontFamily: _sharedUi.monoFont,
432
432
  letterSpacing: 0.3
433
433
  },
434
434
  modeToggleButtonTextActive: {
@@ -331,7 +331,7 @@ const styles = _reactNative.StyleSheet.create({
331
331
  headerChipValue: {
332
332
  fontSize: 12,
333
333
  fontWeight: "600",
334
- fontFamily: "monospace"
334
+ fontFamily: _sharedUi.monoFont
335
335
  },
336
336
  headerActionButton: {
337
337
  width: 32,
@@ -370,6 +370,6 @@ const styles = _reactNative.StyleSheet.create({
370
370
  color: _sharedUi.night.color.onAccent,
371
371
  fontSize: 9,
372
372
  fontWeight: "700",
373
- fontFamily: "monospace"
373
+ fontFamily: _sharedUi.monoFont
374
374
  }
375
375
  });
@@ -371,7 +371,7 @@ const styles = _reactNative.StyleSheet.create({
371
371
  fontSize: 10,
372
372
  fontWeight: "700",
373
373
  letterSpacing: 0.6,
374
- fontFamily: "monospace"
374
+ fontFamily: _sharedUi.monoFont
375
375
  },
376
376
  pinnedClear: {
377
377
  paddingHorizontal: 6,
@@ -681,7 +681,7 @@ const styles = _reactNative.StyleSheet.create({
681
681
  paddingVertical: 8,
682
682
  color: _sharedUi.macOSColors.text.primary,
683
683
  fontSize: 12,
684
- fontFamily: "monospace",
684
+ fontFamily: _sharedUi.monoFont,
685
685
  minHeight: 40
686
686
  },
687
687
  bodyInput: {
@@ -493,7 +493,7 @@ const styles = _reactNative.StyleSheet.create({
493
493
  fontSize: 12,
494
494
  color: _sharedUi.macOSColors.text.primary,
495
495
  lineHeight: 16,
496
- fontFamily: "monospace"
496
+ fontFamily: _sharedUi.monoFont
497
497
  },
498
498
  urlTextOff: {
499
499
  color: _sharedUi.macOSColors.text.muted
@@ -181,7 +181,7 @@ const styles = _reactNative.StyleSheet.create({
181
181
  fontSize: 10,
182
182
  fontWeight: "700",
183
183
  letterSpacing: 0.6,
184
- fontFamily: "monospace"
184
+ fontFamily: _sharedUi.monoFont
185
185
  },
186
186
  toolbarButton: {
187
187
  width: 26,
@@ -540,7 +540,7 @@ const styles = _reactNative.StyleSheet.create({
540
540
  clearButtonText: {
541
541
  fontSize: 12,
542
542
  fontWeight: "700",
543
- fontFamily: "monospace"
543
+ fontFamily: _sharedUi.monoFont
544
544
  },
545
545
  expanderIcon: {
546
546
  width: 18,
@@ -627,7 +627,7 @@ const styles = _reactNative.StyleSheet.create({
627
627
  fontSize: 10,
628
628
  fontWeight: "600",
629
629
  marginRight: 4,
630
- fontFamily: "monospace",
630
+ fontFamily: _sharedUi.monoFont,
631
631
  letterSpacing: 0.4
632
632
  },
633
633
  labelTextFocused: {
@@ -643,14 +643,14 @@ const styles = _reactNative.StyleSheet.create({
643
643
  color: _sharedUi.buoyColors.textMuted,
644
644
  fontSize: 10,
645
645
  fontWeight: "400",
646
- fontFamily: "monospace",
646
+ fontFamily: _sharedUi.monoFont,
647
647
  opacity: 0.7
648
648
  },
649
649
  pageRangeText: {
650
650
  color: _sharedUi.buoyColors.textSecondary,
651
651
  fontSize: 10,
652
652
  fontWeight: "600",
653
- fontFamily: "monospace"
653
+ fontFamily: _sharedUi.monoFont
654
654
  },
655
655
  flexRowGapItemsCenter: {
656
656
  flexDirection: "row",
@@ -708,11 +708,11 @@ const styles = _reactNative.StyleSheet.create({
708
708
  textNumber: {
709
709
  color: _sharedUi.buoyColors.text,
710
710
  fontWeight: "600",
711
- fontFamily: "monospace"
711
+ fontFamily: _sharedUi.monoFont
712
712
  },
713
713
  textString: {
714
714
  color: _sharedUi.buoyColors.text,
715
- fontFamily: "monospace"
715
+ fontFamily: _sharedUi.monoFont
716
716
  },
717
717
  flexRowGapFullWidth: {
718
718
  flexDirection: "row",
@@ -729,7 +729,7 @@ const styles = _reactNative.StyleSheet.create({
729
729
  fontWeight: "600",
730
730
  fontSize: 9,
731
731
  minWidth: 50,
732
- fontFamily: "monospace",
732
+ fontFamily: _sharedUi.monoFont,
733
733
  letterSpacing: 0.4,
734
734
  opacity: 0.8
735
735
  },
@@ -783,7 +783,7 @@ const styles = _reactNative.StyleSheet.create({
783
783
  flex: 1,
784
784
  color: _sharedUi.buoyColors.text,
785
785
  fontWeight: "400",
786
- fontFamily: "monospace",
786
+ fontFamily: _sharedUi.monoFont,
787
787
  fontSize: 12,
788
788
  paddingVertical: 6,
789
789
  paddingHorizontal: 10,
@@ -839,7 +839,7 @@ const styles = _reactNative.StyleSheet.create({
839
839
  readOnlyValue: {
840
840
  color: _sharedUi.buoyColors.text,
841
841
  fontSize: 13,
842
- fontFamily: "monospace",
842
+ fontFamily: _sharedUi.monoFont,
843
843
  paddingVertical: 8,
844
844
  paddingHorizontal: 12,
845
845
  borderRadius: 6,
@@ -865,7 +865,7 @@ const styles = _reactNative.StyleSheet.create({
865
865
  marginLeft: 8,
866
866
  color: _sharedUi.buoyColors.warning,
867
867
  fontWeight: "500",
868
- fontFamily: "monospace"
868
+ fontFamily: _sharedUi.monoFont
869
869
  },
870
870
  modernToggleButton: {
871
871
  flexDirection: "row",
@@ -904,7 +904,7 @@ const styles = _reactNative.StyleSheet.create({
904
904
  color: "#E5E7EB",
905
905
  fontSize: 11,
906
906
  fontWeight: "600",
907
- fontFamily: "monospace",
907
+ fontFamily: _sharedUi.monoFont,
908
908
  letterSpacing: 0.3
909
909
  },
910
910
  toggleStatus: {
@@ -923,7 +923,7 @@ const styles = _reactNative.StyleSheet.create({
923
923
  fontWeight: "700",
924
924
  textTransform: "uppercase",
925
925
  letterSpacing: 0.8,
926
- fontFamily: "monospace"
926
+ fontFamily: _sharedUi.monoFont
927
927
  },
928
928
  // Pre-computed toggle icon styles to avoid inline objects [[memory:4875251]]
929
929
  toggleIconTrue: {
@@ -962,7 +962,7 @@ const styles = _reactNative.StyleSheet.create({
962
962
  color: _sharedUi.buoyColors.textSecondary,
963
963
  fontSize: 10,
964
964
  fontWeight: "600",
965
- fontFamily: "monospace",
965
+ fontFamily: _sharedUi.monoFont,
966
966
  letterSpacing: 0.4,
967
967
  minWidth: 60,
968
968
  flexShrink: 0
@@ -311,13 +311,116 @@ base) {
311
311
  headers: rule.headers && typeof rule.headers === "object" ? rule.headers : base?.headers,
312
312
  // Objects are accepted and serialized so a caller can send JSON directly
313
313
  // instead of pre-stringifying it, which is the mistake everyone makes once.
314
- body: typeof rule.body === "string" ? rule.body : rule.body != null ? JSON.stringify(rule.body, null, 2) : base?.body,
314
+ body: patchedBody(rule, base),
315
315
  failKind: rule.failKind === "timeout" ? "timeout" : undefined,
316
316
  delayMs: typeof rule.delayMs === "number" ? rule.delayMs : undefined,
317
317
  times: typeof rule.times === "number" ? rule.times : null,
318
318
  alternate: rule.alternate === true
319
319
  };
320
320
  }
321
+ const isPlainObject = v => !!v && typeof v === "object" && !Array.isArray(v);
322
+ /**
323
+ * Objects merge key by key; arrays and primitives are replaced. Collects two
324
+ * dangers instead of writing them: `shrunk` (a non-empty array shorter than
325
+ * the one it replaces) and `breaks` (an array/object field overwritten by null
326
+ * or a primitive — the shape the app renders, changed out from under it). The
327
+ * handler refuses either unless forced.
328
+ *
329
+ * This stays local while the typed-edit RULE is shared: it is the merge itself,
330
+ * not the check, and it reports network-specific dangers the store surfaces do
331
+ * not have. `shapeKind` comes from the shared module so both agree on what
332
+ * counts as an array.
333
+ */
334
+ function deepMerge(base, patch, path = "", issues = {
335
+ shrunk: [],
336
+ breaks: []
337
+ }) {
338
+ if (Array.isArray(base) && Array.isArray(patch)) {
339
+ if (patch.length > 0 && patch.length < base.length) {
340
+ issues.shrunk.push({
341
+ path: path || "(root)",
342
+ have: base.length,
343
+ sent: patch.length
344
+ });
345
+ }
346
+ return patch;
347
+ }
348
+ if (!isPlainObject(base) || !isPlainObject(patch)) return patch;
349
+ const out = {
350
+ ...base
351
+ };
352
+ for (const [k, v] of Object.entries(patch)) {
353
+ const p = path ? `${path}.${k}` : k;
354
+ if (k in base) {
355
+ const from = (0, _sharedUi.shapeKind)(base[k]);
356
+ const to = (0, _sharedUi.shapeKind)(v);
357
+ if ((from === "array" || from === "object") && from !== to) {
358
+ issues.breaks.push({
359
+ path: p,
360
+ from,
361
+ to
362
+ });
363
+ }
364
+ out[k] = deepMerge(base[k], v, p, issues);
365
+ } else {
366
+ out[k] = v;
367
+ }
368
+ }
369
+ return out;
370
+ }
371
+
372
+ /** Typed-edit violations a bodyPatch would cause against the seed body, or []. */
373
+ function patchViolations(rule, seed) {
374
+ if (rule.body != null || !isPlainObject(rule.bodyPatch) || typeof seed !== "string") return [];
375
+ try {
376
+ return (0, _sharedUi.typedEditViolations)(JSON.parse(seed), rule.bodyPatch, {
377
+ noun: "response body"
378
+ });
379
+ } catch {
380
+ return [];
381
+ }
382
+ }
383
+
384
+ /**
385
+ * The rule's body: explicit `body` wins, else `bodyPatch` deep-merged into the
386
+ * seeded body (from `fromRequestId`, or the rule being replaced), else the seed.
387
+ *
388
+ * `bodyPatch` exists for the same reason `setQueryData` has `merge`: a remote
389
+ * caller reads the real response through a wire that truncates big payloads,
390
+ * edits one field, and pastes the torn object back as `body`. The app then
391
+ * renders a response missing half its fields. With a patch the caller sends
392
+ * `{ stats: [...] }` and the other 30KB stay exactly as the server sent them.
393
+ */
394
+ function patchedBody(rule, base) {
395
+ if (typeof rule.body === "string") return rule.body;
396
+ if (rule.body != null) return JSON.stringify(rule.body, null, 2);
397
+ const seed = base?.body ?? (typeof rule.id === "string" ? _overrideRulesStore.overrideRulesStore.getState().rules.find(r => r.id === rule.id)?.body : undefined);
398
+ if (isPlainObject(rule.bodyPatch) && typeof seed === "string") {
399
+ try {
400
+ return JSON.stringify(deepMerge(JSON.parse(seed), rule.bodyPatch), null, 2);
401
+ } catch {
402
+ return seed;
403
+ }
404
+ }
405
+ return seed;
406
+ }
407
+
408
+ /**
409
+ * Replacing a rule by id inherits every field the caller did not send, so a
410
+ * remote driver can flip one thing — `{ id, bodyPatch }`, `{ id, status: 500 }`
411
+ * — without re-sending the urlPattern it never had to know.
412
+ */
413
+ function existingRule(params) {
414
+ if (!params || typeof params !== "object") return null;
415
+ const raw = params.rule ?? params;
416
+ if (!raw || typeof raw !== "object") return null;
417
+ const id = raw.id;
418
+ if (typeof id !== "string") return null;
419
+ const existing = _overrideRulesStore.overrideRulesStore.getState().rules.find(r => r.id === id);
420
+ return existing ? {
421
+ ...existing
422
+ } : null;
423
+ }
321
424
 
322
425
  /**
323
426
  * The body a remote surface never received, when it says so.
@@ -511,7 +614,89 @@ const networkSyncAdapter = exports.networkSyncAdapter = {
511
614
  },
512
615
  /** Create a rule, or replace one by id. */
513
616
  upsertOverrideRule: params => {
514
- const base = baseFromRequest(params) ?? preservedBody(params);
617
+ const base = baseFromRequest(params) ?? preservedBody(params) ?? existingRule(params);
618
+ const rawRule = params?.rule ?? params;
619
+ // `bodyPath` + `bodyValue` are sugar over `bodyPatch`, resolved against the
620
+ // real response before anything else looks at the rule — so the keyed
621
+ // expansion, the typed-edit guard and the merge below all run on an
622
+ // ordinary patch and gain no special cases. This is the same form the
623
+ // state tools take; it exists here because a response body is the surface
624
+ // where the caller is LEAST likely to have seen the shape (it read the
625
+ // body over a wire that truncates at 24,000 characters), and therefore
626
+ // most likely to get the nesting wrong.
627
+ if (rawRule && typeof rawRule.bodyPath === "string" && rawRule.body == null) {
628
+ const seed = base?.body ?? (typeof rawRule.id === "string" ? _overrideRulesStore.overrideRulesStore.getState().rules.find(r => r.id === rawRule.id)?.body : undefined);
629
+ if (typeof seed !== "string") {
630
+ return {
631
+ ok: false,
632
+ error: "bodyPath needs something to patch: send fromRequestId (the captured request whose real response it addresses) or the id of an existing rule."
633
+ };
634
+ }
635
+ if (rawRule.bodyValue === undefined) {
636
+ return {
637
+ ok: false,
638
+ error: `Refused: sent \`bodyPath\` without \`bodyValue\`. Say what to set "${rawRule.bodyPath}" to.`
639
+ };
640
+ }
641
+ let parsed;
642
+ try {
643
+ parsed = JSON.parse(seed);
644
+ } catch {
645
+ return {
646
+ ok: false,
647
+ error: "The captured response body is not JSON, so it cannot be addressed by path. Send a whole `body` instead."
648
+ };
649
+ }
650
+ const built = (0, _sharedUi.patchFromPath)(parsed, rawRule.bodyPath, rawRule.bodyValue);
651
+ if (!built.ok) return {
652
+ ok: false,
653
+ error: `Refused: ${built.error}`
654
+ };
655
+ rawRule.bodyPatch = built.patch;
656
+ delete rawRule.bodyPath;
657
+ delete rawRule.bodyValue;
658
+ }
659
+ if (rawRule && isPlainObject(rawRule.bodyPatch) && rawRule.body == null) {
660
+ const seed = base?.body ?? (typeof rawRule.id === "string" ? _overrideRulesStore.overrideRulesStore.getState().rules.find(r => r.id === rawRule.id)?.body : undefined);
661
+ if (typeof seed !== "string") {
662
+ return {
663
+ ok: false,
664
+ error: "bodyPatch needs something to patch: send fromRequestId (the captured request whose real response the patch is merged into) or the id of an existing rule."
665
+ };
666
+ }
667
+ if (rawRule.force !== true) {
668
+ // Address list items in the real response by their own id rather than
669
+ // resending the array: {results:{pikachu:{name:"test123"}}}. Expanded
670
+ // against the seed BEFORE the guard and before the merge, so the
671
+ // typed-edit rule still holds over the result. This is the only form
672
+ // that is correct when the caller read the response through a wire
673
+ // that truncated it — see packages/shared/src/sync/keyedArrays.ts.
674
+ let keyedError;
675
+ try {
676
+ const expanded = (0, _sharedUi.expandKeyedArrays)(JSON.parse(seed), rawRule.bodyPatch);
677
+ keyedError = expanded.error;
678
+ if (expanded.notes.length) rawRule.bodyPatch = expanded.patch;
679
+ } catch {
680
+ /* a non-JSON seed has no lists to address; leave the patch alone */
681
+ }
682
+ // See keyedArrays.ts: an added item whose id disagrees with the key
683
+ // it was sent under is ambiguous, and only this layer can say so.
684
+ if (keyedError) {
685
+ return {
686
+ ok: false,
687
+ error: `Refused: ${keyedError}`
688
+ };
689
+ }
690
+ const violations = patchViolations(rawRule, seed);
691
+ if (violations.length > 0) {
692
+ return {
693
+ ok: false,
694
+ error: (0, _sharedUi.refusalText)(violations, "response body", (0, _sharedUi.suggestedPatch)(rawRule.bodyPatch, violations)),
695
+ violations
696
+ };
697
+ }
698
+ }
699
+ }
515
700
  const draft = readRuleDraft(params, base);
516
701
  if (!draft) {
517
702
  const wanted = params?.fromRequestId;
@@ -541,8 +726,19 @@ const networkSyncAdapter = exports.networkSyncAdapter = {
541
726
  enabled: rule.enabled,
542
727
  kind: rule.kind,
543
728
  status: rule.status,
729
+ times: rule.times,
544
730
  bodySize: rule.body ? rule.body.length : 0
545
731
  },
732
+ // A rule with no budget overrides EVERY matching request until someone
733
+ // deletes it, and that is rarely what a one-off ask meant. Measured on
734
+ // `api-sim/next-call-fails-once` ("just the next one, then let it work
735
+ // again"): ten of fourteen attempts left the budget off and got a rule
736
+ // that never expires. Saying so is not a nag — it is the difference
737
+ // between a caller that adds `times` on its next turn and one that walks
738
+ // away leaving the app permanently broken.
739
+ ...(rule.times === undefined || rule.times === null ? {
740
+ lingers: `This rule has no \`times\` budget, so it overrides EVERY matching request until it is disabled or deleted (id \`${rule.id}\`). If you meant only the next one, set times:1.`
741
+ } : {}),
546
742
  ...inactiveStamp()
547
743
  };
548
744
  },