@elytracms/studio 0.0.97 → 0.0.99

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 (107) hide show
  1. package/convex/assets.ts +16 -2
  2. package/convex/ingest-attempt.test.ts +161 -0
  3. package/convex/ingest.test.ts +269 -16
  4. package/convex/ingest.ts +238 -47
  5. package/convex/path-segment-refusal.test.ts +201 -0
  6. package/convex/prototype-key.test.ts +92 -0
  7. package/convex/records-column-transform.convex.test.ts +147 -0
  8. package/convex/records-zod-refinements.convex.test.ts +94 -0
  9. package/convex/schema.ts +1 -1
  10. package/dist/addon.d.ts +5 -3
  11. package/dist/addon.js +4 -3
  12. package/dist/addon.js.map +1 -1
  13. package/dist/components/app-views/default-modules.d.ts +10 -3
  14. package/dist/components/app-views/default-modules.js +5 -1
  15. package/dist/components/app-views/default-modules.js.map +1 -1
  16. package/dist/components/app-views/module.d.ts +15 -8
  17. package/dist/components/app-views/module.js.map +1 -1
  18. package/dist/components/builder/reuse-context.d.ts +29 -1
  19. package/dist/components/builder/reuse-context.js +58 -12
  20. package/dist/components/builder/reuse-context.js.map +1 -1
  21. package/dist/components/cms/cms-store.d.ts +5 -6
  22. package/dist/components/cms/cms-store.js +12 -24
  23. package/dist/components/cms/cms-store.js.map +1 -1
  24. package/dist/components/cms/field-control.d.ts +12 -1
  25. package/dist/components/cms/field-control.js +31 -6
  26. package/dist/components/cms/field-control.js.map +1 -1
  27. package/dist/components/cms/import-batch.d.ts +23 -0
  28. package/dist/components/cms/import-batch.js +43 -0
  29. package/dist/components/cms/import-batch.js.map +1 -0
  30. package/dist/components/cms/rich-text/editor.js +13 -10
  31. package/dist/components/cms/rich-text/editor.js.map +1 -1
  32. package/dist/components/cms/rich-text/value.d.ts +9 -0
  33. package/dist/components/cms/rich-text/value.js +13 -0
  34. package/dist/components/cms/rich-text/value.js.map +1 -1
  35. package/dist/components/cms/use-document-draft.d.ts +39 -3
  36. package/dist/components/cms/use-document-draft.js +46 -16
  37. package/dist/components/cms/use-document-draft.js.map +1 -1
  38. package/dist/components/cms/use-publish-controller.d.ts +7 -0
  39. package/dist/components/cms/use-publish-controller.js +17 -3
  40. package/dist/components/cms/use-publish-controller.js.map +1 -1
  41. package/dist/components/cms/versions-pane.js +8 -5
  42. package/dist/components/cms/versions-pane.js.map +1 -1
  43. package/dist/components/delivery-health-banner.d.ts +34 -16
  44. package/dist/components/delivery-health-banner.js +59 -27
  45. package/dist/components/delivery-health-banner.js.map +1 -1
  46. package/dist/components/issue-severity.d.ts +3 -2
  47. package/dist/components/issue-severity.js.map +1 -1
  48. package/dist/components/nav-issues-registry.d.ts +39 -24
  49. package/dist/components/nav-issues-registry.js +50 -21
  50. package/dist/components/nav-issues-registry.js.map +1 -1
  51. package/dist/components/nav-issues.d.ts +23 -50
  52. package/dist/components/nav-issues.js +31 -59
  53. package/dist/components/nav-issues.js.map +1 -1
  54. package/dist/components/notifications/notice-center.d.ts +61 -0
  55. package/dist/components/notifications/notice-center.js +119 -0
  56. package/dist/components/notifications/notice-center.js.map +1 -0
  57. package/dist/components/notifications/notice-model.d.ts +137 -0
  58. package/dist/components/notifications/notice-model.js +107 -0
  59. package/dist/components/notifications/notice-model.js.map +1 -0
  60. package/dist/components/notifications/outcome-notice.d.ts +16 -0
  61. package/dist/components/notifications/outcome-notice.js +34 -0
  62. package/dist/components/notifications/outcome-notice.js.map +1 -0
  63. package/dist/components/notifications/shell-findings.d.ts +36 -0
  64. package/dist/components/notifications/shell-findings.js +140 -0
  65. package/dist/components/notifications/shell-findings.js.map +1 -0
  66. package/dist/components/rail-view-source.js +5 -0
  67. package/dist/components/rail-view-source.js.map +1 -1
  68. package/dist/components/records/record-lens.js.map +1 -1
  69. package/dist/components/records/record-rail.d.ts +10 -2
  70. package/dist/components/records/record-rail.js +74 -4
  71. package/dist/components/records/record-rail.js.map +1 -1
  72. package/dist/components/section-nav.d.ts +15 -3
  73. package/dist/components/section-nav.js +39 -147
  74. package/dist/components/section-nav.js.map +1 -1
  75. package/dist/components/studio-project-screen.js.map +1 -1
  76. package/dist/components/studio-shell.js +8 -3
  77. package/dist/components/studio-shell.js.map +1 -1
  78. package/dist/components/use-narrow-viewport.d.ts +11 -0
  79. package/dist/components/use-narrow-viewport.js +25 -0
  80. package/dist/components/use-narrow-viewport.js.map +1 -0
  81. package/dist/components/users/role-policy-drift-issues.d.ts +14 -7
  82. package/dist/components/users/role-policy-drift-issues.js +17 -8
  83. package/dist/components/users/role-policy-drift-issues.js.map +1 -1
  84. package/dist/lib/collection-view/collection-view-source.d.ts +4 -2
  85. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  86. package/dist/lib/engine-version.d.ts +1 -1
  87. package/dist/lib/engine-version.js +1 -1
  88. package/dist/lib/engine-version.js.map +1 -1
  89. package/dist/lib/i18n/messages.de.js +24 -3
  90. package/dist/lib/i18n/messages.de.js.map +1 -1
  91. package/dist/lib/i18n/messages.en.d.ts +22 -2
  92. package/dist/lib/i18n/messages.en.js +27 -4
  93. package/dist/lib/i18n/messages.en.js.map +1 -1
  94. package/dist/lib/notices/toast-store.d.ts +61 -0
  95. package/dist/lib/notices/toast-store.js +116 -0
  96. package/dist/lib/notices/toast-store.js.map +1 -0
  97. package/dist/lib/operations/cms-persistence.d.ts +32 -7
  98. package/dist/lib/operations/cms-persistence.js +511 -71
  99. package/dist/lib/operations/cms-persistence.js.map +1 -1
  100. package/dist/lib/persistence/convex-adapter.js +61 -30
  101. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  102. package/dist/lib/persona/labels.js +1 -0
  103. package/dist/lib/persona/labels.js.map +1 -1
  104. package/dist/ui/toast.d.ts +24 -16
  105. package/dist/ui/toast.js +23 -32
  106. package/dist/ui/toast.js.map +1 -1
  107. package/package.json +4 -3
@@ -1,23 +1,84 @@
1
- import { applyDocumentPatches, documentKey, formatValuePath, isValuePathPrefix, readValueAt, selfTypeDocument, semanticValueEqual, touchedPaths, valuePathsOverlap, } from '@elytracms/core/cms-core';
1
+ import { applyDocumentPatches, documentKey, documentValueBuckets, expandValuePath, fieldPath, formatValuePath, isValuePathPrefix, readValueAt, selfTypeDocument, semanticValueEqual, touchedPaths, touchedPathsOfEntry, valuePathsOverlap, } from '@elytracms/core/cms-core';
2
2
  import { createPersistQueue } from './persist-queue.js';
3
+ /** EC-686 — a decomposed alias is re-sent at most this often (each time a colleague wrote the document in between). */
4
+ const ALIAS_ATTEMPTS = 3;
3
5
  /** How many own writes a document's ledger keeps (an older base just stops lifting — a conflict, never a borrow). */
4
6
  const OWN_WRITES_KEPT = 256;
5
7
  /**
6
- * EC-667 — the revision the content a view shows at `path` was authored against: the
7
- * earliest of the floor and every shown write covering `path` (a write at `path` or at
8
- * an ancestor of it). `undefined` path — a whole-document writer — the earliest of all.
9
- * A write at a DESCENDANT of `path` does not lower it: that position's own token is
10
- * lower, and the write still pending when an edit of `path` commits lowers it there.
8
+ * EC-667 → EC-681 — the revision the content a view shows at `path` was authored against:
9
+ * a token describes the WHOLE value a writer of `path` may replace. It is the earliest of
10
+ * the floor and of every shown write whose positions OVERLAP `path` structurally — a write
11
+ * at an ancestor of `path`, at `path`, or at a DESCENDANT of it (the value at `path`
12
+ * contains that write's content). A write at a sibling position does not lower it.
13
+ * `undefined` path — a whole-document writer — the earliest of all. `path` and the writes'
14
+ * positions are in the view's structural form (EC-693: node ancestry included).
15
+ *
16
+ * Minted when the view is shown, never when the edit commits (Codex F18, F23): a
17
+ * descendant write may be refused and its conflict resolved before an edit of `path`
18
+ * started on this view commits, and that edit still carries the descendant's old content.
19
+ * The own descendant write that then APPLIES is lifted across at send time ({@link lift}).
11
20
  */
12
21
  function provenanceAt(provenance, path) {
13
22
  let at = provenance.floor;
14
23
  for (const write of provenance.writes) {
15
- if (path === undefined || write.paths.some((covered) => isValuePathPrefix(covered, path))) {
24
+ if (path === undefined || write.paths.some((written) => valuePathsOverlap(written, path))) {
16
25
  at = earlierBase(at, write.base);
17
26
  }
18
27
  }
19
28
  return at;
20
29
  }
30
+ /**
31
+ * EC-681 — whether a write at `a` may overlap one at `b` as the STORE judges it: THE
32
+ * conflict relation (`valuePathsOverlap`) unless BOTH name a component node — then any two
33
+ * in overlapping field positions. The store compares node paths EXPANDED by their ancestry
34
+ * (`value-path.ts`) against ITS document, which may hold a colleague's move this session
35
+ * has not adopted, so the persister never claims two node paths of one field apart — not
36
+ * even in their expanded form (EC-693 kept this conservative on purpose). Conservative by
37
+ * construction: a "may" that is wrong costs a lift (a conflict at worst), never a borrow.
38
+ */
39
+ function mayOverlap(a, b) {
40
+ const fieldOf = (path) => {
41
+ const node = path.findIndex((segment) => segment.kind === 'node');
42
+ return node < 0 ? undefined : path.slice(0, node);
43
+ };
44
+ const fieldA = fieldOf(a);
45
+ const fieldB = fieldOf(b);
46
+ return fieldA && fieldB ? valuePathsOverlap(fieldA, fieldB) : valuePathsOverlap(a, b);
47
+ }
48
+ /**
49
+ * EC-693 — THE containment fact the persister reads (Codex F28): the positions each patch
50
+ * touches as THE merge reports them — `applyDocumentPatches(...).applied`, the patches
51
+ * applied in order over `document`, each expanded against the document it applied to
52
+ * (node ancestry included). `undefined` for a patch the merge does not apply there (its
53
+ * node is not in that document): it has no position in it. Never a second expansion.
54
+ *
55
+ * The merge puts every input patch in exactly one of `applied` (in input order) and
56
+ * `refused` (the input object itself); a fresh object per input keeps a patch object that
57
+ * occurs twice apart.
58
+ */
59
+ function mergedPositions(document, patches) {
60
+ if (patches.length === 0)
61
+ return { document, positions: [] };
62
+ const inputs = patches.map((patch) => ({ ...patch }));
63
+ const merged = applyDocumentPatches({ document, patches: inputs, since: [] });
64
+ const refused = new Set(merged.refused.map((refusal) => refusal.patch));
65
+ let next = 0;
66
+ return {
67
+ document: merged.document,
68
+ positions: inputs.map((input) => (refused.has(input) ? undefined : touchedPaths(merged.applied[next++]))),
69
+ };
70
+ }
71
+ /**
72
+ * EC-693 — the positions an applied run touched at the store: {@link mergedPositions} over
73
+ * the row the run was sent from, in order. The store applied exactly these patches, in this
74
+ * order, and would have refused one whose node (or an ancestor) a change since its base had
75
+ * moved — so the ancestry each landed under is the row's. The submitted form where the merge
76
+ * has none.
77
+ */
78
+ function footprint(row, patches) {
79
+ const merged = row ? mergedPositions(row, patches).positions : [];
80
+ return patches.flatMap((patch, index) => merged[index] ?? touchedPaths(patch));
81
+ }
21
82
  /** A patch's identity within one call: op + target path (no two value writes of one path share a run). */
22
83
  function targetKey(op, path) {
23
84
  return `${op} ${formatValuePath(path)}`;
@@ -28,9 +89,32 @@ function patchTarget(patch) {
28
89
  function conflictId(ref, path) {
29
90
  return `${documentKey(ref)}|${formatValuePath(path)}`;
30
91
  }
31
- /** Paths two patches have in common (either touches a prefix of the other's). */
32
- function patchesOverlap(a, b) {
33
- return touchedPaths(a).some((p) => touchedPaths(b).some((q) => valuePathsOverlap(p, q)));
92
+ function pathsOverlap(a, b) {
93
+ return a.some((p) => b.some((q) => valuePathsOverlap(p, q)));
94
+ }
95
+ /**
96
+ * Whether two own writes may touch a common position (either touches a prefix of the
97
+ * other's): as submitted, or — EC-693 — where both touch the view (node ancestry
98
+ * included: a write of a parent node and one of its child's prop). Either is enough; a
99
+ * "may" lowers a base (a conflict at worst), never borrows one.
100
+ */
101
+ function patchesOverlap(a, b, positions) {
102
+ if (pathsOverlap(touchedPaths(a), touchedPaths(b)))
103
+ return true;
104
+ const atA = positions?.get(a);
105
+ const atB = positions?.get(b);
106
+ return atA !== undefined && atB !== undefined && pathsOverlap(atA, atB);
107
+ }
108
+ /**
109
+ * EC-693 — two value writes of ONE position (the open conflict a later edit replaces): the
110
+ * same submitted path, or the same position in the view.
111
+ */
112
+ function samePosition(a, b, positions) {
113
+ if (samePath(a.path, b.path))
114
+ return true;
115
+ const atA = positions.get(a)?.[0];
116
+ const atB = positions.get(b)?.[0];
117
+ return atA !== undefined && atB !== undefined && samePath(atA, atB);
34
118
  }
35
119
  /** A `set`/`unset` on exactly one path — the shape a later edit of the same field supersedes. */
36
120
  function isValueWrite(patch) {
@@ -58,11 +142,57 @@ function earlierBase(a, b) {
58
142
  */
59
143
  export function importWrite(persister, document, shown) {
60
144
  if (shown && persister.revisionOf(document) !== undefined) {
61
- persister.replace(document, persister.revisionOfView(shown));
145
+ // EC-686: with the row it was judged against — what a refusal decomposes it against.
146
+ persister.replace(document, persister.revisionOfView(shown), shown);
62
147
  }
63
148
  else
64
149
  persister.create(document);
65
150
  }
151
+ /**
152
+ * EC-674 → EC-686 — THE classification of a write that failed without a per-field conflict
153
+ * (one declaration, read by the whole-document and the patch path): `true` = KEPT — put
154
+ * back, still shown, re-sent by the next commit or forced flush — for the one failure the
155
+ * store did not JUDGE, `backend-unavailable`, whatever the message. `false` = DROPPED for
156
+ * the store's verdicts, `validation`, `unauthorized` and `not-found`, and for `unknown`: the
157
+ * same write fails the same way again, so it is reported and the writes behind it drain.
158
+ *
159
+ * Why `unknown` is now a verdict (EC-686, classify by WHO failed — the binding rule): the
160
+ * Convex adapter makes every failure out of a backend call that is not a mapped engine
161
+ * error `backend-unavailable` (a network failure, a gateway's 5xx, an expired session, an
162
+ * OCC exhaustion or a timeout, keeping the backend's message), so an `unknown` is a failure
163
+ * of the engine in THIS process — an undeclared field type's `canonicalise` crash — which
164
+ * the same input repeats. Kept, it waited in front of every later write of the document
165
+ * (EC-674's accepted cost); dropped, it is visible and nothing behind it waits.
166
+ */
167
+ function keepsFailedWrite(error) {
168
+ return error.code === 'backend-unavailable';
169
+ }
170
+ /**
171
+ * EC-686 — a dropped verdict on a run as a whole that may be one patch's alone: `validation`
172
+ * (an input or value issue), `unauthorized` (an authorization hook may judge by the
173
+ * patches), an in-process `unknown` (one patch's crash). Not `not-found` — the document is
174
+ * gone for every patch — and never a kept failure.
175
+ */
176
+ function isolates(error) {
177
+ return error.code === 'validation' || error.code === 'unauthorized' || error.code === 'unknown';
178
+ }
179
+ /**
180
+ * EC-686 — the patches a verdict on a whole run names (`validation` issues located at
181
+ * `['patches', i, …]`, i in the run): `undefined` when any issue is not located there (or
182
+ * the verdict carries none) — then no patch is known innocent.
183
+ */
184
+ function locatedCulprits(error, size) {
185
+ if (error.code !== 'validation' || !error.issues?.length)
186
+ return undefined;
187
+ const culprits = new Set();
188
+ for (const issue of error.issues) {
189
+ const index = issue.path?.[0] === 'patches' ? issue.path[1] : undefined;
190
+ if (typeof index !== 'number' || !Number.isInteger(index) || index < 0 || index >= size)
191
+ return undefined;
192
+ culprits.add(index);
193
+ }
194
+ return culprits;
195
+ }
66
196
  /** A write against a document this session no longer holds (deleted here or elsewhere). */
67
197
  function goneError(ref) {
68
198
  return { code: 'not-found', message: 'The document is gone.', entity: 'cms-document', key: documentKey(ref) };
@@ -76,6 +206,48 @@ function unauthoredError(ref) {
76
206
  messageRef: { key: 'cms.conflict.unauthored' },
77
207
  };
78
208
  }
209
+ /**
210
+ * EC-686 — a slot change someone else's slot change came before: the alias has no position
211
+ * to hold an ordinary conflict on, so theirs is kept, and the author is told (the table
212
+ * cell's precedent, `cms.conflict.cellRefused`).
213
+ */
214
+ function aliasRefusedError(ref) {
215
+ return {
216
+ code: 'conflict',
217
+ key: documentKey(ref),
218
+ message: 'The slot was changed by someone else since you chose it — theirs is kept. Choose it again to change it.',
219
+ messageRef: { key: 'cms.conflict.aliasRefused' },
220
+ };
221
+ }
222
+ /** `document` with its slot alias set (or removed) — a fresh object. */
223
+ function withAlias(document, alias) {
224
+ const next = { ...document };
225
+ if (alias === undefined)
226
+ delete next.alias;
227
+ else
228
+ next.alias = alias;
229
+ return next;
230
+ }
231
+ /**
232
+ * EC-686 item 1 — what a whole-document snapshot changed against the view it was made from,
233
+ * position by position: a `set` (or an `unset`, the value gone) of every field position —
234
+ * `values[f]`, `localized[l][f]` — whose value differs by THE equality
235
+ * (`documentValueBuckets` + `semanticValueEqual`, the document diff's own reading). Every
236
+ * value change of a document is one of these; its slot `alias` is the one key that is not.
237
+ */
238
+ function positionPatches(view, snapshot) {
239
+ const patches = [];
240
+ for (const bucket of documentValueBuckets(view, snapshot)) {
241
+ for (const key of new Set([...Object.keys(bucket.before), ...Object.keys(bucket.after)])) {
242
+ if (semanticValueEqual(bucket.before[key], bucket.after[key]))
243
+ continue;
244
+ const path = fieldPath(key, bucket.locale);
245
+ const value = bucket.after[key];
246
+ patches.push(value === undefined ? { op: 'unset', path } : { op: 'set', path, value });
247
+ }
248
+ }
249
+ return patches;
250
+ }
79
251
  export function createCmsPersister(options) {
80
252
  const queue = createPersistQueue((error) => {
81
253
  options.onError({
@@ -88,6 +260,28 @@ export function createCmsPersister(options) {
88
260
  const shown = new WeakMap();
89
261
  /** A server row's document: every position at its revision. */
90
262
  const registerRow = (row) => shown.set(row.document, { floor: row.revision, writes: [] });
263
+ /** EC-693: {@link serverPositions}, per server row and patch (a row and a queued patch never change). */
264
+ const atRow = new WeakMap();
265
+ /**
266
+ * EC-693 — where a patch lands in the server row the session holds (`entry.base`, at or
267
+ * after every write the own-write ledger records): its positions as THE merge reports
268
+ * them on that row alone ({@link mergedPositions}), the submitted form where the merge does
269
+ * not apply it there. What {@link lift} and the actor lookup read — never the view's
270
+ * evolving frame: an own write still queued ahead of the patch (a move of its node) has
271
+ * not happened at the store.
272
+ */
273
+ function serverPositions(entry, patch) {
274
+ const row = entry.base?.document;
275
+ if (!row)
276
+ return touchedPaths(patch);
277
+ let byPatch = atRow.get(row);
278
+ if (!byPatch)
279
+ atRow.set(row, (byPatch = new WeakMap()));
280
+ let positions = byPatch.get(patch);
281
+ if (!positions)
282
+ byPatch.set(patch, (positions = mergedPositions(row, [patch]).positions[0] ?? touchedPaths(patch)));
283
+ return positions;
284
+ }
91
285
  function entryOf(ref) {
92
286
  const key = documentKey(ref);
93
287
  let entry = entries.get(key);
@@ -126,45 +320,73 @@ export function createCmsPersister(options) {
126
320
  }
127
321
  /**
128
322
  * The session's view: base ⊕ unresolved mine ⊕ in-flight ⊕ queued (the pure merge, no
129
- * log; a whole-document write resets it to its snapshot). Registered with what it was
130
- * authored against, position by position (EC-647 → EC-667: the base's revision, a
131
- * pending snapshot's, each shown write's base); a provisional view has none.
323
+ * log; a whole-document write resets it to its snapshot) — and, EC-693, `extra` (a
324
+ * commit's patches) over it. With what each shown position was authored against (EC-647
325
+ * → EC-667: the base's revision, a pending snapshot's, each shown write's base) and where
326
+ * each shown write — and each of `extra` — touches the view ({@link mergedPositions}):
327
+ * computed on every call, never frozen when a write is queued, so a colleague's move
328
+ * adopted underneath moves the pending write's position with its node.
132
329
  */
133
- function display(entry) {
330
+ function compose(entry, extra = []) {
134
331
  const baseDocument = entry.base?.document ?? entry.provisional;
135
332
  if (entry.deleted || !baseDocument)
136
333
  return undefined;
137
334
  let document = baseDocument;
138
335
  let floor = entry.base?.revision;
139
- let patches = entry.conflicts.map((conflict) => conflict.patch);
140
- let writes = entry.conflicts.map((conflict) => ({
141
- paths: touchedPaths(conflict.patch),
142
- base: entry.conflictBases.get(conflict.id),
143
- }));
336
+ let shownWrites = entry.conflicts.map((conflict) => ({ patch: conflict.patch, base: entry.conflictBases.get(conflict.id) }));
144
337
  const pending = [
145
338
  ...entry.inFlight,
146
339
  ...(entry.inFlightReplace ? [entry.inFlightReplace] : []),
147
340
  ...entry.queue,
148
341
  ];
342
+ // EC-686: a pending decomposed alias shows over everything before it (the newest wins).
343
+ let alias;
149
344
  for (const queued of pending) {
150
345
  if (queued.kind === 'replace') {
151
346
  // The snapshot was authored over everything before it (the editor's draft): every
152
347
  // position now shows content authored at the SNAPSHOT's revision (EC-667).
153
- patches = [];
154
- writes = [];
348
+ shownWrites = [];
155
349
  document = queued.document;
156
350
  floor = queued.base;
351
+ alias = undefined;
352
+ }
353
+ else if (queued.kind === 'alias') {
354
+ alias = { value: queued.alias };
157
355
  }
158
356
  else {
159
- patches.push(queued.patch);
160
- writes.push({ paths: touchedPaths(queued.patch), base: queued.base });
357
+ shownWrites.push({ patch: queued.patch, base: queued.base });
161
358
  }
162
359
  }
163
- if (patches.length > 0)
164
- document = applyDocumentPatches({ document, patches, since: [] }).document;
165
- if (entry.base)
166
- shown.set(document, { floor, writes });
167
- return document;
360
+ const merged = mergedPositions(document, [...shownWrites.map((write) => write.patch), ...extra]);
361
+ // A write the merge did not apply in this view shows nothing: its submitted form keeps
362
+ // the tokens it may concern conservative.
363
+ const writes = shownWrites.map((write, index) => ({
364
+ patch: write.patch,
365
+ paths: merged.positions[index] ?? touchedPaths(write.patch),
366
+ base: write.base,
367
+ }));
368
+ const positions = new Map();
369
+ for (const write of writes)
370
+ positions.set(write.patch, write.paths);
371
+ extra.forEach((patch, index) => {
372
+ const at = merged.positions[shownWrites.length + index];
373
+ if (at)
374
+ positions.set(patch, at);
375
+ });
376
+ return { document: alias ? withAlias(merged.document, alias.value) : merged.document, floor, writes, positions: positions };
377
+ }
378
+ /**
379
+ * The session's view ({@link compose}), registered with what it was authored against,
380
+ * position by position; a provisional view has none.
381
+ */
382
+ function display(entry) {
383
+ const view = compose(entry);
384
+ if (!view)
385
+ return undefined;
386
+ if (entry.base) {
387
+ shown.set(view.document, { floor: view.floor, writes: view.writes.map(({ paths, base }) => ({ paths, base })) });
388
+ }
389
+ return view.document;
168
390
  }
169
391
  function emitDocument(entry) {
170
392
  if (entry.deleted)
@@ -246,12 +468,28 @@ export function createCmsPersister(options) {
246
468
  entry.own.splice(0, entry.own.length - OWN_WRITES_KEPT);
247
469
  }
248
470
  /**
249
- * EC-647 — THE lift: the base a write authored at `base` is SENT at. Only across this
250
- * persister's own writes (see the module doc): a run of consecutive own revisions
251
- * right after the base, or an own write that covered every position `paths` touch and
252
- * was sent at or below the base. Never across a revision anyone else wrote.
471
+ * EC-647 → EC-681 — THE lift: the base a write authored at `base` is SENT at. It crosses
472
+ * a revision only where the store's refusal on it would be a refusal of OUR content —
473
+ * never a change by anyone else of a position the write touches. Three rules, applied
474
+ * until none moves it:
475
+ * 1. the next revision is one of this persister's own writes (a run of own writes);
476
+ * 2. an own write that covered every position `patch` touches (each at or under one of its
477
+ * paths) and was sent at or below the lifted base: its revision — the store refused
478
+ * every foreign change of those positions in between, or it would not have applied.
479
+ * EC-693: both read where they land at the store — the own write's {@link footprint},
480
+ * the patch's {@link serverPositions} — node ancestry included: a submitted path names
481
+ * a node's ancestry as the writer believed it, the store reads it from the document;
482
+ * 3. (EC-681, `log` given) the next revision is someone else's and touched none of
483
+ * the patch's positions ({@link mayOverlap}, the store's own row reading
484
+ * `touchedPathsOfEntry`): the store would not refuse the write on it, so crossing it
485
+ * moves nothing — it only lets rule 1 reach an own write beyond it. That is the case
486
+ * rule 2 cannot prove: an ancestor's write over an own DESCENDANT write (whose
487
+ * acceptance says nothing of the ancestor's other descendants), past an unrelated
488
+ * colleague write in between.
489
+ * A whole-document write (`patch` undefined) takes rule 1 only.
253
490
  */
254
- function lift(entry, base, paths) {
491
+ function lift(entry, base, patch, log) {
492
+ const paths = patch ? () => serverPositions(entry, patch) : undefined;
255
493
  let lifted = base;
256
494
  for (;;) {
257
495
  if (entry.own.some((write) => write.revision === lifted + 1)) {
@@ -263,22 +501,53 @@ export function createCmsPersister(options) {
263
501
  write.paths !== undefined &&
264
502
  write.sentBase !== undefined &&
265
503
  write.sentBase <= lifted &&
266
- paths.every((path) => write.paths.some((covered) => isValuePathPrefix(covered, path))))
504
+ paths().every((path) => write.paths.some((covered) => isValuePathPrefix(covered, path))))
267
505
  : undefined;
268
- if (!covering)
269
- return lifted;
270
- lifted = covering.revision;
506
+ if (covering) {
507
+ lifted = covering.revision;
508
+ continue;
509
+ }
510
+ const foreign = paths ? log?.get(lifted + 1) : undefined;
511
+ if (foreign && !touchedPathsOfEntry(foreign).some((logged) => paths().some((path) => mayOverlap(logged, path)))) {
512
+ lifted += 1;
513
+ continue;
514
+ }
515
+ return lifted;
271
516
  }
272
517
  }
518
+ /**
519
+ * EC-681 — the patch log {@link lift} rule 3 reads for a queued patch: only when an own
520
+ * write the store would refuse it on lies beyond its lift (an own write that may overlap
521
+ * its positions, past a revision someone else wrote), read after that lift. `undefined`
522
+ * when not needed or not readable — then no lift, a conflict at worst, never a borrow.
523
+ */
524
+ async function liftLog(entry, operations, queued) {
525
+ const lifted = lift(entry, queued.base, queued.patch);
526
+ const blocking = entry.own.some((write) => write.revision > lifted &&
527
+ (write.paths === undefined ||
528
+ write.paths.some((own) => serverPositions(entry, queued.patch).some((path) => mayOverlap(own, path)))));
529
+ if (!blocking)
530
+ return undefined;
531
+ const read = await operations.documents.listPatches({
532
+ projectId: options.projectId,
533
+ ref: entry.ref,
534
+ afterRevision: lifted,
535
+ });
536
+ return read.ok ? new Map(read.value.map((row) => [row.revision, row])) : undefined;
537
+ }
273
538
  /**
274
539
  * EC-647 — the base a write is QUEUED with: the revision it was authored against, or
275
540
  * the earlier base of an own unacknowledged write on the same positions (the view
276
- * showed mine there, not whatever may sit underneath it).
541
+ * showed mine there, not whatever may sit underneath it) — EC-693: as submitted, or in
542
+ * the view (`positions`).
277
543
  */
278
- function authoredBase(entry, patch, authored) {
544
+ function authoredBase(entry, patch, authored, positions) {
279
545
  let base = authored;
280
- const overlaps = (other) => patch === undefined || patchesOverlap(other, patch);
546
+ const overlaps = (other) => patch === undefined || patchesOverlap(other, patch, positions);
281
547
  for (const queued of [...entry.inFlight, ...entry.queue]) {
548
+ // A decomposed alias names no value position (and is sent at the row it then finds).
549
+ if (queued.kind === 'alias')
550
+ continue;
282
551
  if (queued.kind === 'replace' || overlaps(queued.patch))
283
552
  base = earlierBase(base, queued.base);
284
553
  }
@@ -299,7 +568,7 @@ export function createCmsPersister(options) {
299
568
  if (entry.created === undefined)
300
569
  return;
301
570
  for (const queued of entry.queue)
302
- if (queued.base === undefined)
571
+ if (queued.kind !== 'alias' && queued.base === undefined)
303
572
  queued.base = entry.created;
304
573
  }
305
574
  /**
@@ -325,6 +594,9 @@ export function createCmsPersister(options) {
325
594
  }
326
595
  const read = await operations.documents.get({ projectId: options.projectId, ref: entry.ref });
327
596
  if (!read.ok) {
597
+ // The writes stay queued for the next flush; their commits learn why now (EC-686).
598
+ if (!entry.scheduled)
599
+ settleWaiting(entry, { status: 'failed', error: read.error });
328
600
  options.onError(read.error);
329
601
  return false;
330
602
  }
@@ -359,7 +631,12 @@ export function createCmsPersister(options) {
359
631
  adopt(entry, record);
360
632
  return true;
361
633
  }
362
- /** Who changed a refused path — the newest patch-log row in (base, current] touching it. Best-effort. */
634
+ /**
635
+ * Who changed a refused path — the newest patch-log row in (base, current] touching it.
636
+ * Best-effort. The log stores its paths expanded (node ancestry included), so the refused
637
+ * patch is read where it lands in the server row ({@link serverPositions}, EC-693) — a
638
+ * child prop's conflict names its parent there, as the colleague's logged write does.
639
+ */
363
640
  async function resolveActors(entry, sentBase, refused) {
364
641
  try {
365
642
  const operations = await options.getOperations();
@@ -374,10 +651,10 @@ export function createCmsPersister(options) {
374
651
  entry.conflicts = entry.conflicts.map((conflict) => {
375
652
  if (conflict.actor !== undefined || !refused.some((r) => r.id === conflict.id))
376
653
  return conflict;
654
+ const at = serverPositions(entry, conflict.patch);
377
655
  const row = [...log.value]
378
656
  .reverse()
379
- .find((candidate) => candidate.kind === 'replace' ||
380
- candidate.patches.some((patch) => touchedPaths(patch).some((path) => valuePathsOverlap(path, conflict.path))));
657
+ .find((candidate) => candidate.kind === 'replace' || candidate.patches.some((patch) => pathsOverlap(touchedPaths(patch), at)));
381
658
  if (!row?.actor)
382
659
  return conflict;
383
660
  changed = true;
@@ -390,6 +667,130 @@ export function createCmsPersister(options) {
390
667
  // The actor is decoration on a conflict the user can already resolve.
391
668
  }
392
669
  }
670
+ /**
671
+ * EC-674 — a write that failed without a per-field conflict: KEPT when the store did not
672
+ * judge it ({@link keepsFailedWrite}) — back at the head of the queue, still shown,
673
+ * re-sent by the next commit or forced flush at its own authoring base (a retry never
674
+ * borrows a newer revision) — or DROPPED on the store's verdict, and the view re-derived
675
+ * without it. Reported either way. `true` when kept: the drain stops there, so nothing
676
+ * committed after it is sent ahead of it — and, EC-686, every commit still waiting on a
677
+ * write queued behind it settles now ({@link settleWaiting}). The one path for both the
678
+ * whole-document write and the patch run.
679
+ */
680
+ function failedWrite(entry, writes, error) {
681
+ const kept = keepsFailedWrite(error);
682
+ if (kept) {
683
+ entry.queue.unshift(...writes);
684
+ // A drain already scheduled (a commit made meanwhile) re-sends them at once: they
685
+ // settle with its outcome instead.
686
+ if (!entry.scheduled)
687
+ settleWaiting(entry, { status: 'failed', error });
688
+ }
689
+ else
690
+ emitDocument(entry);
691
+ options.onError(error);
692
+ return kept;
693
+ }
694
+ /**
695
+ * EC-686 — the drain stopped with writes still queued (kept, shown, re-sent by the next
696
+ * commit or forced flush): the commits waiting on them settle with why, never left
697
+ * hanging until a later flush lands them (an awaiting caller — a table cell, bulk
698
+ * set-field — would wait with them). Each settles once; the write stays queued. Not
699
+ * when a drain is already scheduled: it re-sends them at once, and they settle with it.
700
+ */
701
+ function settleWaiting(entry, outcome) {
702
+ entry.queue = entry.queue.map((queued) => {
703
+ if (queued.kind !== 'patch' || queued.settle.length === 0)
704
+ return queued;
705
+ settleAll([queued], outcome);
706
+ return { ...queued, settle: [] };
707
+ });
708
+ }
709
+ /**
710
+ * EC-686 — a verdict on a run of several patches as a whole ({@link isolates}): its
711
+ * innocents are not dropped with its culprit. A `validation` that
712
+ * locates every culprit ({@link locatedCulprits}) drops those, and the rest go back to the
713
+ * head of the queue to be re-sent once as a run (`'resent'`); otherwise each patch is
714
+ * re-sent once alone (`'alone'`), so the refusal finds its culprit. The verdict is
715
+ * reported once; a culprit's commit settles `failed` now, an innocent's after its
716
+ * re-send. The re-sent patches keep their authoring bases (lift rule 1 crosses each
717
+ * own revision a re-send lands at), and are never isolated again.
718
+ */
719
+ function isolate(entry, run, error) {
720
+ const culprits = locatedCulprits(error, run.length);
721
+ const resend = culprits
722
+ ? run.filter((_, index) => !culprits.has(index)).map((queued) => ({ ...queued, isolated: 'resent' }))
723
+ : run.map((queued) => ({ ...queued, isolated: 'alone' }));
724
+ if (culprits)
725
+ settleAll(run.filter((_, index) => culprits.has(index)), { status: 'failed', error });
726
+ entry.queue.unshift(...resend);
727
+ emitDocument(entry);
728
+ options.onError(error);
729
+ }
730
+ /**
731
+ * EC-686 item 1 (the owner's ruling, 2026-10-28: per-position) — a whole-document write the
732
+ * store refused, in the writes that re-send it: what its author changed against the view
733
+ * the snapshot was made from ({@link positionPatches}), each authored where that view had
734
+ * it (its token at the position — EC-667 — else the snapshot's revision). Sent next, each
735
+ * lands on the current row, or — a position someone else changed since — becomes that
736
+ * field's ordinary conflict, the authored value still shown. Never the snapshot again: a
737
+ * whole-document keep-mine would re-send it over every colleague change since.
738
+ *
739
+ * The slot alias, the key the grammar has no position for, follows them alone
740
+ * ({@link QueuedAlias}) when the row still carries the alias the author saw; when someone
741
+ * else changed it too, there is no position for a conflict — theirs is kept and the author
742
+ * is told (`aliasRefused`). `undefined` when nothing can be judged per position: no view,
743
+ * or no revision (an insert-if-absent).
744
+ */
745
+ function decompose(entry, write, current) {
746
+ const base = write.base ?? entry.created;
747
+ const view = write.authoredOn;
748
+ if (base === undefined || !view)
749
+ return undefined;
750
+ const provenance = shown.get(view);
751
+ const writes = positionPatches(view, write.document).map((patch) => ({
752
+ kind: 'patch',
753
+ patch,
754
+ base: (provenance ? provenanceAt(provenance, touchedPaths(patch)[0]) : undefined) ?? base,
755
+ settle: [],
756
+ }));
757
+ let aliasRefused = false;
758
+ const alias = write.document.alias;
759
+ if (alias !== view.alias && alias !== current.alias) {
760
+ if (current.alias === view.alias)
761
+ writes.push({ kind: 'alias', alias, authored: view.alias, attempt: write.aliasAttempt ?? 0 });
762
+ else
763
+ aliasRefused = true;
764
+ }
765
+ return { writes, aliasRefused };
766
+ }
767
+ /**
768
+ * EC-686 — the queue's head is a decomposed alias: it becomes the whole-document write that
769
+ * carries it — the server row the session holds, unchanged, with the alias, at that row's
770
+ * revision (so it changes the alias and nothing else; a write since refuses it, and it is
771
+ * decomposed again, at most {@link ALIAS_ATTEMPTS} times) — or it is done: the row already
772
+ * carries it, or someone else changed the slot since the author's view (theirs kept, told).
773
+ */
774
+ function resolveAlias(entry, write) {
775
+ entry.queue.shift();
776
+ const row = entry.base;
777
+ if (!row || row.document.alias === write.alias) {
778
+ emitDocument(entry);
779
+ return;
780
+ }
781
+ if (row.document.alias !== write.authored || write.attempt >= ALIAS_ATTEMPTS) {
782
+ emitDocument(entry);
783
+ options.onError(aliasRefusedError(entry.ref));
784
+ return;
785
+ }
786
+ entry.queue.unshift({
787
+ kind: 'replace',
788
+ document: withAlias(row.document, write.alias),
789
+ base: row.revision,
790
+ authoredOn: row.document,
791
+ aliasAttempt: write.attempt + 1,
792
+ });
793
+ }
393
794
  /** Send one queued whole-document write at the revision its snapshot was authored against. `false` → stop draining. */
394
795
  async function sendReplace(entry, operations, write) {
395
796
  const precondition = write.base !== undefined
@@ -423,13 +824,19 @@ export function createCmsPersister(options) {
423
824
  });
424
825
  adopt(entry, result.value);
425
826
  }
426
- else {
427
- if (result.error.code === 'conflict' && 'current' in result.error && result.error.current) {
428
- adopt(entry, result.error.current);
429
- }
430
- else
431
- emitDocument(entry);
432
- options.onError(result.error);
827
+ else if (result.error.code === 'conflict' && 'current' in result.error && result.error.current) {
828
+ // EC-686 item 1: refused as a whole — re-sent per position, in its place in the queue.
829
+ const parts = decompose(entry, write, result.error.current.document);
830
+ if (parts)
831
+ entry.queue.unshift(...parts.writes);
832
+ adopt(entry, result.error.current);
833
+ if (!parts)
834
+ options.onError(result.error);
835
+ else if (parts.aliasRefused)
836
+ options.onError(aliasRefusedError(entry.ref));
837
+ }
838
+ else if (failedWrite(entry, [write], result.error)) {
839
+ return false;
433
840
  }
434
841
  }
435
842
  finally {
@@ -453,20 +860,33 @@ export function createCmsPersister(options) {
453
860
  if (!head)
454
861
  return;
455
862
  if (head.kind === 'replace') {
456
- await sendReplace(entry, operations, head);
863
+ if (!(await sendReplace(entry, operations, head)))
864
+ return;
865
+ continue;
866
+ }
867
+ if (head.kind === 'alias') {
868
+ resolveAlias(entry, head);
457
869
  continue;
458
870
  }
459
- const sendBase = (queued) => lift(entry, queued.base, touchedPaths(queued.patch));
871
+ const log = await liftLog(entry, operations, head);
872
+ // The queue may have moved while the log was read (a superseding edit, a delete): start over.
873
+ if (entry.queue[0] !== head)
874
+ continue;
875
+ const sendBase = (queued) => lift(entry, queued.base, queued.patch, log);
460
876
  const first = sendBase(head);
461
877
  let end = 1;
462
- while (end < entry.queue.length) {
878
+ // EC-686: a patch re-sent after its run was refused travels only with the run's other
879
+ // innocents — or alone ({@link isolate}).
880
+ while (head.isolated !== 'alone' && end < entry.queue.length) {
463
881
  const next = entry.queue[end];
464
- if (next.kind !== 'patch' || sendBase(next) !== first)
882
+ if (next.kind !== 'patch' || next.isolated !== head.isolated || sendBase(next) !== first)
465
883
  break;
466
884
  end += 1;
467
885
  }
468
886
  const run = entry.queue.splice(0, end);
469
887
  entry.inFlight = run;
888
+ // EC-693: the row the run is sent from — where its applied patches land ({@link footprint}).
889
+ const sentFrom = entry.base?.document;
470
890
  const result = await operations.documents.patch({
471
891
  projectId: options.projectId,
472
892
  ref: entry.ref,
@@ -478,7 +898,7 @@ export function createCmsPersister(options) {
478
898
  recordOwn(entry, {
479
899
  revision: result.value.revision,
480
900
  sentBase: first,
481
- paths: run.flatMap((queued) => touchedPaths(queued.patch)),
901
+ paths: footprint(sentFrom, run.map((queued) => queued.patch)),
482
902
  });
483
903
  adopt(entry, result.value);
484
904
  adoptHeld(entry);
@@ -511,7 +931,7 @@ export function createCmsPersister(options) {
511
931
  recordOwn(entry, {
512
932
  revision: current.revision,
513
933
  sentBase: first,
514
- paths: applied.flatMap((queued) => touchedPaths(queued.patch)),
934
+ paths: footprint(sentFrom, applied.map((queued) => queued.patch)),
515
935
  });
516
936
  }
517
937
  adopt(entry, current);
@@ -528,14 +948,24 @@ export function createCmsPersister(options) {
528
948
  settleAll(run, { status: 'failed', error });
529
949
  return;
530
950
  }
531
- // A transport / authorization failure: the patches stay local (shown, unsent) and
532
- // the error is visible; the next commit or forced flush retries them. The commits
533
- // waiting on them learn of the failure now.
951
+ // EC-686 — a verdict on a run of several patches, not yet isolated: re-sent once
952
+ // without its culprits. A `not-found` is the document's and names none; a kept
953
+ // failure is not a verdict.
954
+ if (run.length > 1 && head.isolated === undefined && isolates(error)) {
955
+ isolate(entry, run, error);
956
+ adoptHeld(entry);
957
+ continue;
958
+ }
959
+ // EC-674 — any other failure: the commits waiting on the run learn of it now. One the
960
+ // store did not judge keeps the patches (shown, unsent; the next commit or forced flush
961
+ // re-sends them); a verdict (validation, unauthorized, not-found, an in-process
962
+ // `unknown`) drops them, and the writes behind it drain — put back, it would be refused
963
+ // in front of every later write.
534
964
  settleAll(run, { status: 'failed', error });
535
- entry.queue.unshift(...run.map((queued) => ({ ...queued, settle: [] })));
965
+ const kept = failedWrite(entry, run.map((queued) => ({ ...queued, settle: [] })), error);
536
966
  adoptHeld(entry);
537
- options.onError(error);
538
- return;
967
+ if (kept)
968
+ return;
539
969
  }
540
970
  }
541
971
  function upsertConflict(entry, refused, revision, base) {
@@ -581,7 +1011,10 @@ export function createCmsPersister(options) {
581
1011
  },
582
1012
  revisionOfView: (view, path) => {
583
1013
  const provenance = shown.get(view);
584
- return provenance ? provenanceAt(provenance, path) : undefined;
1014
+ if (!provenance)
1015
+ return undefined;
1016
+ // EC-693: the asked position in the view's structural form, as its shown writes are.
1017
+ return provenanceAt(provenance, path && (expandValuePath(view, path) ?? path));
585
1018
  },
586
1019
  adoptRecord(record) {
587
1020
  const entry = entryOf(record.document);
@@ -609,6 +1042,8 @@ export function createCmsPersister(options) {
609
1042
  }
610
1043
  if (!entry.base && !entry.provisional && view)
611
1044
  entry.provisional = view;
1045
+ // EC-693: where the shown own writes and these patches touch the view they were authored on.
1046
+ const positions = compose(entry, patches)?.positions ?? new Map();
612
1047
  let conflictsChanged = false;
613
1048
  let unauthored = false;
614
1049
  const waits = [];
@@ -625,15 +1060,19 @@ export function createCmsPersister(options) {
625
1060
  }
626
1061
  // A field with an unresolved conflict: the new value replaces MINE, unsent —
627
1062
  // the user decides (keep mine sends it), never an implicit overwrite of theirs.
628
- const open = entry.conflicts.find((conflict) => patchesOverlap(conflict.patch, patch));
629
- if (open && isValueWrite(patch) && isValueWrite(open.patch) && samePath(open.patch.path, patch.path)) {
1063
+ // EC-693: the conflict at THIS position, as submitted or in the view — never merely
1064
+ // the first overlapping one (an open conflict at an ancestor would hide it).
1065
+ const open = isValueWrite(patch)
1066
+ ? entry.conflicts.find((conflict) => isValueWrite(conflict.patch) && samePosition(conflict.patch, patch, positions))
1067
+ : undefined;
1068
+ if (open) {
630
1069
  const replaced = { ...open, patch };
631
1070
  entry.conflicts = entry.conflicts.map((c) => (c === open ? replaced : c));
632
1071
  conflictsChanged = true;
633
1072
  settle({ status: 'refused', conflicts: [replaced] });
634
1073
  continue;
635
1074
  }
636
- const base = authoredBase(entry, patch, authored);
1075
+ const base = authoredBase(entry, patch, authored, positions);
637
1076
  // A later value write of the same field supersedes an unsent one (keeping the
638
1077
  // EARLIER base — the user never saw a foreign value of a field they were typing
639
1078
  // in). Only after the last queued whole-document write: one before it is sent
@@ -716,7 +1155,7 @@ export function createCmsPersister(options) {
716
1155
  options.onError(result.error);
717
1156
  // EC-647: the writes authored on the document we meant to create have no
718
1157
  // revision to be based on — never the revision of a row someone else wrote.
719
- dropQueued(entry, { status: 'failed', error: result.error }, (queued) => queued.base === undefined);
1158
+ dropQueued(entry, { status: 'failed', error: result.error }, (queued) => queued.kind !== 'alias' && queued.base === undefined);
720
1159
  }
721
1160
  }
722
1161
  finally {
@@ -728,7 +1167,7 @@ export function createCmsPersister(options) {
728
1167
  await drain(entry);
729
1168
  });
730
1169
  },
731
- replace(document, revision) {
1170
+ replace(document, revision, authoredOn) {
732
1171
  const doc = canonical(document);
733
1172
  const entry = entryOf(doc);
734
1173
  if (entry.deleted) {
@@ -744,7 +1183,8 @@ export function createCmsPersister(options) {
744
1183
  // holds them), the ones committed after it are sent after it (it does not). EC-647
745
1184
  // (Codex F10): one with no authoring revision on a document this session tracks and
746
1185
  // did not create is refused there, in order — never sent at the tracked revision.
747
- entry.queue.push({ kind: 'replace', document: doc, base: authoredBase(entry, undefined, revision) });
1186
+ // EC-686: with the document it was made from — what a refusal decomposes it against.
1187
+ entry.queue.push({ kind: 'replace', document: doc, base: authoredBase(entry, undefined, revision), ...(authoredOn ? { authoredOn } : {}) });
748
1188
  emitDocument(entry);
749
1189
  schedule(entry);
750
1190
  },