@elytracms/studio 0.0.98 → 0.0.100

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 (95) hide show
  1. package/convex/cms.ts +29 -2
  2. package/convex/ingest.test.ts +84 -7
  3. package/convex/ingest.ts +110 -31
  4. package/convex/path-segment-refusal.test.ts +201 -0
  5. package/convex/records-column-transform.convex.test.ts +149 -0
  6. package/convex/records-one-write.convex.test.ts +218 -0
  7. package/convex/schema.ts +1 -1
  8. package/convex/version-kind-stamping.test.ts +118 -0
  9. package/dist/addon.d.ts +5 -3
  10. package/dist/addon.js +4 -3
  11. package/dist/addon.js.map +1 -1
  12. package/dist/components/app-views/default-modules.d.ts +10 -3
  13. package/dist/components/app-views/default-modules.js +5 -1
  14. package/dist/components/app-views/default-modules.js.map +1 -1
  15. package/dist/components/app-views/module.d.ts +15 -8
  16. package/dist/components/app-views/module.js.map +1 -1
  17. package/dist/components/builder/reuse-context.d.ts +29 -1
  18. package/dist/components/builder/reuse-context.js +58 -12
  19. package/dist/components/builder/reuse-context.js.map +1 -1
  20. package/dist/components/cms/cms-store.d.ts +3 -2
  21. package/dist/components/cms/cms-store.js +8 -2
  22. package/dist/components/cms/cms-store.js.map +1 -1
  23. package/dist/components/cms/field-control.js +15 -5
  24. package/dist/components/cms/field-control.js.map +1 -1
  25. package/dist/components/cms/use-document-draft.d.ts +4 -1
  26. package/dist/components/cms/use-document-draft.js +4 -1
  27. package/dist/components/cms/use-document-draft.js.map +1 -1
  28. package/dist/components/cms/use-publish-controller.d.ts +7 -0
  29. package/dist/components/cms/use-publish-controller.js +17 -3
  30. package/dist/components/cms/use-publish-controller.js.map +1 -1
  31. package/dist/components/cms/versions-pane.js +8 -5
  32. package/dist/components/cms/versions-pane.js.map +1 -1
  33. package/dist/components/delivery-health-banner.d.ts +34 -16
  34. package/dist/components/delivery-health-banner.js +59 -27
  35. package/dist/components/delivery-health-banner.js.map +1 -1
  36. package/dist/components/issue-severity.d.ts +3 -2
  37. package/dist/components/issue-severity.js.map +1 -1
  38. package/dist/components/nav-issues-registry.d.ts +39 -24
  39. package/dist/components/nav-issues-registry.js +50 -21
  40. package/dist/components/nav-issues-registry.js.map +1 -1
  41. package/dist/components/nav-issues.d.ts +23 -50
  42. package/dist/components/nav-issues.js +31 -59
  43. package/dist/components/nav-issues.js.map +1 -1
  44. package/dist/components/notifications/notice-center.d.ts +61 -0
  45. package/dist/components/notifications/notice-center.js +119 -0
  46. package/dist/components/notifications/notice-center.js.map +1 -0
  47. package/dist/components/notifications/notice-model.d.ts +137 -0
  48. package/dist/components/notifications/notice-model.js +107 -0
  49. package/dist/components/notifications/notice-model.js.map +1 -0
  50. package/dist/components/notifications/outcome-notice.d.ts +16 -0
  51. package/dist/components/notifications/outcome-notice.js +34 -0
  52. package/dist/components/notifications/outcome-notice.js.map +1 -0
  53. package/dist/components/notifications/shell-findings.d.ts +36 -0
  54. package/dist/components/notifications/shell-findings.js +140 -0
  55. package/dist/components/notifications/shell-findings.js.map +1 -0
  56. package/dist/components/rail-view-source.js +5 -0
  57. package/dist/components/rail-view-source.js.map +1 -1
  58. package/dist/components/records/record-rail.d.ts +10 -2
  59. package/dist/components/records/record-rail.js +85 -4
  60. package/dist/components/records/record-rail.js.map +1 -1
  61. package/dist/components/section-nav.d.ts +15 -3
  62. package/dist/components/section-nav.js +39 -147
  63. package/dist/components/section-nav.js.map +1 -1
  64. package/dist/components/studio-project-screen.js.map +1 -1
  65. package/dist/components/studio-shell.js +8 -3
  66. package/dist/components/studio-shell.js.map +1 -1
  67. package/dist/components/use-narrow-viewport.d.ts +11 -0
  68. package/dist/components/use-narrow-viewport.js +25 -0
  69. package/dist/components/use-narrow-viewport.js.map +1 -0
  70. package/dist/components/users/role-policy-drift-issues.d.ts +14 -7
  71. package/dist/components/users/role-policy-drift-issues.js +17 -8
  72. package/dist/components/users/role-policy-drift-issues.js.map +1 -1
  73. package/dist/lib/collection-view/collection-view-source.d.ts +4 -2
  74. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  75. package/dist/lib/engine-version.d.ts +1 -1
  76. package/dist/lib/engine-version.js +1 -1
  77. package/dist/lib/engine-version.js.map +1 -1
  78. package/dist/lib/i18n/messages.de.js +22 -3
  79. package/dist/lib/i18n/messages.de.js.map +1 -1
  80. package/dist/lib/i18n/messages.en.d.ts +20 -2
  81. package/dist/lib/i18n/messages.en.js +25 -4
  82. package/dist/lib/i18n/messages.en.js.map +1 -1
  83. package/dist/lib/notices/toast-store.d.ts +61 -0
  84. package/dist/lib/notices/toast-store.js +116 -0
  85. package/dist/lib/notices/toast-store.js.map +1 -0
  86. package/dist/lib/operations/cms-persistence.d.ts +24 -4
  87. package/dist/lib/operations/cms-persistence.js +411 -80
  88. package/dist/lib/operations/cms-persistence.js.map +1 -1
  89. package/dist/lib/persistence/convex-adapter.d.ts +2 -0
  90. package/dist/lib/persistence/convex-adapter.js +127 -31
  91. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  92. package/dist/ui/toast.d.ts +24 -16
  93. package/dist/ui/toast.js +23 -32
  94. package/dist/ui/toast.js.map +1 -1
  95. package/package.json +3 -3
@@ -1,5 +1,7 @@
1
- import { applyDocumentPatches, documentKey, formatValuePath, isValuePathPrefix, readValueAt, selfTypeDocument, semanticValueEqual, touchedPaths, touchedPathsOfEntry, valuePathsOverlap, } from '@elytracms/core/cms-core';
1
+ import { applyDocumentPatches, documentKey, expandValuePath, fieldPositionPatches, 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
  /**
@@ -8,7 +10,8 @@ const OWN_WRITES_KEPT = 256;
8
10
  * the floor and of every shown write whose positions OVERLAP `path` structurally — a write
9
11
  * at an ancestor of `path`, at `path`, or at a DESCENDANT of it (the value at `path`
10
12
  * contains that write's content). A write at a sibling position does not lower it.
11
- * `undefined` path — a whole-document writer — the earliest of all.
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).
12
15
  *
13
16
  * Minted when the view is shown, never when the edit commits (Codex F18, F23): a
14
17
  * descendant write may be refused and its conflict resolved before an edit of `path`
@@ -28,9 +31,10 @@ function provenanceAt(provenance, path) {
28
31
  * EC-681 — whether a write at `a` may overlap one at `b` as the STORE judges it: THE
29
32
  * conflict relation (`valuePathsOverlap`) unless BOTH name a component node — then any two
30
33
  * in overlapping field positions. The store compares node paths EXPANDED by their ancestry
31
- * (`value-path.ts`); the persister holds the submitted form, so it never claims two node
32
- * paths of one field apart. Conservative by construction: a "may" that is wrong costs a
33
- * lift (a conflict at worst), never a borrow.
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.
34
38
  */
35
39
  function mayOverlap(a, b) {
36
40
  const fieldOf = (path) => {
@@ -41,6 +45,40 @@ function mayOverlap(a, b) {
41
45
  const fieldB = fieldOf(b);
42
46
  return fieldA && fieldB ? valuePathsOverlap(fieldA, fieldB) : valuePathsOverlap(a, b);
43
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
+ }
44
82
  /** A patch's identity within one call: op + target path (no two value writes of one path share a run). */
45
83
  function targetKey(op, path) {
46
84
  return `${op} ${formatValuePath(path)}`;
@@ -51,9 +89,32 @@ function patchTarget(patch) {
51
89
  function conflictId(ref, path) {
52
90
  return `${documentKey(ref)}|${formatValuePath(path)}`;
53
91
  }
54
- /** Paths two patches have in common (either touches a prefix of the other's). */
55
- function patchesOverlap(a, b) {
56
- 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);
57
118
  }
58
119
  /** A `set`/`unset` on exactly one path — the shape a later edit of the same field supersedes. */
59
120
  function isValueWrite(patch) {
@@ -81,29 +142,56 @@ function earlierBase(a, b) {
81
142
  */
82
143
  export function importWrite(persister, document, shown) {
83
144
  if (shown && persister.revisionOf(document) !== undefined) {
84
- 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);
85
147
  }
86
148
  else
87
149
  persister.create(document);
88
150
  }
89
151
  /**
90
- * EC-674 — THE classification of a write that failed without a per-field conflict (one
91
- * declaration, read by the whole-document and the patch path): `true` = KEPT — put back,
92
- * still shown, re-sent by the next commit or forced flush — for every failure the store did
93
- * not JUDGE: `backend-unavailable` and `unknown`, whatever the message. `false` = DROPPED
94
- * for the store's verdicts, `validation`, `unauthorized` and `not-found`: the same write is
95
- * refused the same way again, so it is reported and the writes behind it drain.
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.
96
158
  *
97
- * Why every `unknown` (measured, EC-674): no adapter emits `backend-unavailable`, and a
98
- * network failure, a gateway's 5xx, an expired session and a crash all arrive as `unknown`
99
- * with whatever text the transport carried — no message set can name every transient (an
100
- * OCC exhaustion, a timeout, a new runtime's wording). Dropping one loses the edit; keeping
101
- * a deterministic `unknown` (an undeclared field type's `canonicalise` crash) loses nothing:
102
- * its error stays visible, each commit or flush re-sends it once, and the document's later
103
- * writes wait behind it. Loss ranks above a wedge (the v1 bar).
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.
104
166
  */
105
167
  function keepsFailedWrite(error) {
106
- return error.code === 'backend-unavailable' || error.code === 'unknown';
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;
107
195
  }
108
196
  /** A write against a document this session no longer holds (deleted here or elsewhere). */
109
197
  function goneError(ref) {
@@ -118,6 +206,42 @@ function unauthoredError(ref) {
118
206
  messageRef: { key: 'cms.conflict.unauthored' },
119
207
  };
120
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. Every value change of
235
+ * a document is one of these; its slot `alias` is the one key that is not.
236
+ *
237
+ * EC-703 (Codex round 6 F33): THE own-property per-position diff (`fieldPositionPatches`,
238
+ * cms-core — the one the publish strip reads too). This copy read `bucket.after[key]`, so a
239
+ * removed field named after an `Object.prototype` member became `set constructor = Object`
240
+ * and every later `documentOf` threw (measured).
241
+ */
242
+ function positionPatches(view, snapshot) {
243
+ return fieldPositionPatches(view, snapshot);
244
+ }
121
245
  export function createCmsPersister(options) {
122
246
  const queue = createPersistQueue((error) => {
123
247
  options.onError({
@@ -130,6 +254,28 @@ export function createCmsPersister(options) {
130
254
  const shown = new WeakMap();
131
255
  /** A server row's document: every position at its revision. */
132
256
  const registerRow = (row) => shown.set(row.document, { floor: row.revision, writes: [] });
257
+ /** EC-693: {@link serverPositions}, per server row and patch (a row and a queued patch never change). */
258
+ const atRow = new WeakMap();
259
+ /**
260
+ * EC-693 — where a patch lands in the server row the session holds (`entry.base`, at or
261
+ * after every write the own-write ledger records): its positions as THE merge reports
262
+ * them on that row alone ({@link mergedPositions}), the submitted form where the merge does
263
+ * not apply it there. What {@link lift} and the actor lookup read — never the view's
264
+ * evolving frame: an own write still queued ahead of the patch (a move of its node) has
265
+ * not happened at the store.
266
+ */
267
+ function serverPositions(entry, patch) {
268
+ const row = entry.base?.document;
269
+ if (!row)
270
+ return touchedPaths(patch);
271
+ let byPatch = atRow.get(row);
272
+ if (!byPatch)
273
+ atRow.set(row, (byPatch = new WeakMap()));
274
+ let positions = byPatch.get(patch);
275
+ if (!positions)
276
+ byPatch.set(patch, (positions = mergedPositions(row, [patch]).positions[0] ?? touchedPaths(patch)));
277
+ return positions;
278
+ }
133
279
  function entryOf(ref) {
134
280
  const key = documentKey(ref);
135
281
  let entry = entries.get(key);
@@ -168,45 +314,73 @@ export function createCmsPersister(options) {
168
314
  }
169
315
  /**
170
316
  * The session's view: base ⊕ unresolved mine ⊕ in-flight ⊕ queued (the pure merge, no
171
- * log; a whole-document write resets it to its snapshot). Registered with what it was
172
- * authored against, position by position (EC-647 → EC-667: the base's revision, a
173
- * pending snapshot's, each shown write's base); a provisional view has none.
317
+ * log; a whole-document write resets it to its snapshot) — and, EC-693, `extra` (a
318
+ * commit's patches) over it. With what each shown position was authored against (EC-647
319
+ * → EC-667: the base's revision, a pending snapshot's, each shown write's base) and where
320
+ * each shown write — and each of `extra` — touches the view ({@link mergedPositions}):
321
+ * computed on every call, never frozen when a write is queued, so a colleague's move
322
+ * adopted underneath moves the pending write's position with its node.
174
323
  */
175
- function display(entry) {
324
+ function compose(entry, extra = []) {
176
325
  const baseDocument = entry.base?.document ?? entry.provisional;
177
326
  if (entry.deleted || !baseDocument)
178
327
  return undefined;
179
328
  let document = baseDocument;
180
329
  let floor = entry.base?.revision;
181
- let patches = entry.conflicts.map((conflict) => conflict.patch);
182
- let writes = entry.conflicts.map((conflict) => ({
183
- paths: touchedPaths(conflict.patch),
184
- base: entry.conflictBases.get(conflict.id),
185
- }));
330
+ let shownWrites = entry.conflicts.map((conflict) => ({ patch: conflict.patch, base: entry.conflictBases.get(conflict.id) }));
186
331
  const pending = [
187
332
  ...entry.inFlight,
188
333
  ...(entry.inFlightReplace ? [entry.inFlightReplace] : []),
189
334
  ...entry.queue,
190
335
  ];
336
+ // EC-686: a pending decomposed alias shows over everything before it (the newest wins).
337
+ let alias;
191
338
  for (const queued of pending) {
192
339
  if (queued.kind === 'replace') {
193
340
  // The snapshot was authored over everything before it (the editor's draft): every
194
341
  // position now shows content authored at the SNAPSHOT's revision (EC-667).
195
- patches = [];
196
- writes = [];
342
+ shownWrites = [];
197
343
  document = queued.document;
198
344
  floor = queued.base;
345
+ alias = undefined;
346
+ }
347
+ else if (queued.kind === 'alias') {
348
+ alias = { value: queued.alias };
199
349
  }
200
350
  else {
201
- patches.push(queued.patch);
202
- writes.push({ paths: touchedPaths(queued.patch), base: queued.base });
351
+ shownWrites.push({ patch: queued.patch, base: queued.base });
203
352
  }
204
353
  }
205
- if (patches.length > 0)
206
- document = applyDocumentPatches({ document, patches, since: [] }).document;
207
- if (entry.base)
208
- shown.set(document, { floor, writes });
209
- return document;
354
+ const merged = mergedPositions(document, [...shownWrites.map((write) => write.patch), ...extra]);
355
+ // A write the merge did not apply in this view shows nothing: its submitted form keeps
356
+ // the tokens it may concern conservative.
357
+ const writes = shownWrites.map((write, index) => ({
358
+ patch: write.patch,
359
+ paths: merged.positions[index] ?? touchedPaths(write.patch),
360
+ base: write.base,
361
+ }));
362
+ const positions = new Map();
363
+ for (const write of writes)
364
+ positions.set(write.patch, write.paths);
365
+ extra.forEach((patch, index) => {
366
+ const at = merged.positions[shownWrites.length + index];
367
+ if (at)
368
+ positions.set(patch, at);
369
+ });
370
+ return { document: alias ? withAlias(merged.document, alias.value) : merged.document, floor, writes, positions: positions };
371
+ }
372
+ /**
373
+ * The session's view ({@link compose}), registered with what it was authored against,
374
+ * position by position; a provisional view has none.
375
+ */
376
+ function display(entry) {
377
+ const view = compose(entry);
378
+ if (!view)
379
+ return undefined;
380
+ if (entry.base) {
381
+ shown.set(view.document, { floor: view.floor, writes: view.writes.map(({ paths, base }) => ({ paths, base })) });
382
+ }
383
+ return view.document;
210
384
  }
211
385
  function emitDocument(entry) {
212
386
  if (entry.deleted)
@@ -293,18 +467,23 @@ export function createCmsPersister(options) {
293
467
  * never a change by anyone else of a position the write touches. Three rules, applied
294
468
  * until none moves it:
295
469
  * 1. the next revision is one of this persister's own writes (a run of own writes);
296
- * 2. an own write that covered every position `paths` touch (each at or under one of its
470
+ * 2. an own write that covered every position `patch` touches (each at or under one of its
297
471
  * paths) and was sent at or below the lifted base: its revision — the store refused
298
- * every foreign change of those positions in between, or it would not have applied;
472
+ * every foreign change of those positions in between, or it would not have applied.
473
+ * EC-693: both read where they land at the store — the own write's {@link footprint},
474
+ * the patch's {@link serverPositions} — node ancestry included: a submitted path names
475
+ * a node's ancestry as the writer believed it, the store reads it from the document;
299
476
  * 3. (EC-681, `log` given) the next revision is someone else's and touched none of
300
- * `paths` ({@link mayOverlap}, the store's own row reading `touchedPathsOfEntry`): the
301
- * store would not refuse the write on it, so crossing it moves nothing — it only lets
302
- * rule 1 reach an own write beyond it. That is the case rule 2 cannot prove: an
303
- * ancestor's write over an own DESCENDANT write (whose acceptance says nothing of the
304
- * ancestor's other descendants), past an unrelated colleague write in between.
305
- * A whole-document write (`paths` undefined) takes rule 1 only.
477
+ * the patch's positions ({@link mayOverlap}, the store's own row reading
478
+ * `touchedPathsOfEntry`): the store would not refuse the write on it, so crossing it
479
+ * moves nothing — it only lets rule 1 reach an own write beyond it. That is the case
480
+ * rule 2 cannot prove: an ancestor's write over an own DESCENDANT write (whose
481
+ * acceptance says nothing of the ancestor's other descendants), past an unrelated
482
+ * colleague write in between.
483
+ * A whole-document write (`patch` undefined) takes rule 1 only.
306
484
  */
307
- function lift(entry, base, paths, log) {
485
+ function lift(entry, base, patch, log) {
486
+ const paths = patch ? () => serverPositions(entry, patch) : undefined;
308
487
  let lifted = base;
309
488
  for (;;) {
310
489
  if (entry.own.some((write) => write.revision === lifted + 1)) {
@@ -316,14 +495,14 @@ export function createCmsPersister(options) {
316
495
  write.paths !== undefined &&
317
496
  write.sentBase !== undefined &&
318
497
  write.sentBase <= lifted &&
319
- paths.every((path) => write.paths.some((covered) => isValuePathPrefix(covered, path))))
498
+ paths().every((path) => write.paths.some((covered) => isValuePathPrefix(covered, path))))
320
499
  : undefined;
321
500
  if (covering) {
322
501
  lifted = covering.revision;
323
502
  continue;
324
503
  }
325
504
  const foreign = paths ? log?.get(lifted + 1) : undefined;
326
- if (foreign && !touchedPathsOfEntry(foreign).some((logged) => paths.some((path) => mayOverlap(logged, path)))) {
505
+ if (foreign && !touchedPathsOfEntry(foreign).some((logged) => paths().some((path) => mayOverlap(logged, path)))) {
327
506
  lifted += 1;
328
507
  continue;
329
508
  }
@@ -337,10 +516,10 @@ export function createCmsPersister(options) {
337
516
  * when not needed or not readable — then no lift, a conflict at worst, never a borrow.
338
517
  */
339
518
  async function liftLog(entry, operations, queued) {
340
- const paths = touchedPaths(queued.patch);
341
- const lifted = lift(entry, queued.base, paths);
519
+ const lifted = lift(entry, queued.base, queued.patch);
342
520
  const blocking = entry.own.some((write) => write.revision > lifted &&
343
- (write.paths === undefined || write.paths.some((own) => paths.some((path) => mayOverlap(own, path)))));
521
+ (write.paths === undefined ||
522
+ write.paths.some((own) => serverPositions(entry, queued.patch).some((path) => mayOverlap(own, path)))));
344
523
  if (!blocking)
345
524
  return undefined;
346
525
  const read = await operations.documents.listPatches({
@@ -353,12 +532,16 @@ export function createCmsPersister(options) {
353
532
  /**
354
533
  * EC-647 — the base a write is QUEUED with: the revision it was authored against, or
355
534
  * the earlier base of an own unacknowledged write on the same positions (the view
356
- * showed mine there, not whatever may sit underneath it).
535
+ * showed mine there, not whatever may sit underneath it) — EC-693: as submitted, or in
536
+ * the view (`positions`).
357
537
  */
358
- function authoredBase(entry, patch, authored) {
538
+ function authoredBase(entry, patch, authored, positions) {
359
539
  let base = authored;
360
- const overlaps = (other) => patch === undefined || patchesOverlap(other, patch);
540
+ const overlaps = (other) => patch === undefined || patchesOverlap(other, patch, positions);
361
541
  for (const queued of [...entry.inFlight, ...entry.queue]) {
542
+ // A decomposed alias names no value position (and is sent at the row it then finds).
543
+ if (queued.kind === 'alias')
544
+ continue;
362
545
  if (queued.kind === 'replace' || overlaps(queued.patch))
363
546
  base = earlierBase(base, queued.base);
364
547
  }
@@ -379,7 +562,7 @@ export function createCmsPersister(options) {
379
562
  if (entry.created === undefined)
380
563
  return;
381
564
  for (const queued of entry.queue)
382
- if (queued.base === undefined)
565
+ if (queued.kind !== 'alias' && queued.base === undefined)
383
566
  queued.base = entry.created;
384
567
  }
385
568
  /**
@@ -405,6 +588,9 @@ export function createCmsPersister(options) {
405
588
  }
406
589
  const read = await operations.documents.get({ projectId: options.projectId, ref: entry.ref });
407
590
  if (!read.ok) {
591
+ // The writes stay queued for the next flush; their commits learn why now (EC-686).
592
+ if (!entry.scheduled)
593
+ settleWaiting(entry, { status: 'failed', error: read.error });
408
594
  options.onError(read.error);
409
595
  return false;
410
596
  }
@@ -439,7 +625,12 @@ export function createCmsPersister(options) {
439
625
  adopt(entry, record);
440
626
  return true;
441
627
  }
442
- /** Who changed a refused path — the newest patch-log row in (base, current] touching it. Best-effort. */
628
+ /**
629
+ * Who changed a refused path — the newest patch-log row in (base, current] touching it.
630
+ * Best-effort. The log stores its paths expanded (node ancestry included), so the refused
631
+ * patch is read where it lands in the server row ({@link serverPositions}, EC-693) — a
632
+ * child prop's conflict names its parent there, as the colleague's logged write does.
633
+ */
443
634
  async function resolveActors(entry, sentBase, refused) {
444
635
  try {
445
636
  const operations = await options.getOperations();
@@ -454,10 +645,10 @@ export function createCmsPersister(options) {
454
645
  entry.conflicts = entry.conflicts.map((conflict) => {
455
646
  if (conflict.actor !== undefined || !refused.some((r) => r.id === conflict.id))
456
647
  return conflict;
648
+ const at = serverPositions(entry, conflict.patch);
457
649
  const row = [...log.value]
458
650
  .reverse()
459
- .find((candidate) => candidate.kind === 'replace' ||
460
- candidate.patches.some((patch) => touchedPaths(patch).some((path) => valuePathsOverlap(path, conflict.path))));
651
+ .find((candidate) => candidate.kind === 'replace' || candidate.patches.some((patch) => pathsOverlap(touchedPaths(patch), at)));
461
652
  if (!row?.actor)
462
653
  return conflict;
463
654
  changed = true;
@@ -476,18 +667,124 @@ export function createCmsPersister(options) {
476
667
  * re-sent by the next commit or forced flush at its own authoring base (a retry never
477
668
  * borrows a newer revision) — or DROPPED on the store's verdict, and the view re-derived
478
669
  * without it. Reported either way. `true` when kept: the drain stops there, so nothing
479
- * committed after it is sent ahead of it. The one path for both the whole-document write
480
- * and the patch run.
670
+ * committed after it is sent ahead of it — and, EC-686, every commit still waiting on a
671
+ * write queued behind it settles now ({@link settleWaiting}). The one path for both the
672
+ * whole-document write and the patch run.
481
673
  */
482
674
  function failedWrite(entry, writes, error) {
483
675
  const kept = keepsFailedWrite(error);
484
- if (kept)
676
+ if (kept) {
485
677
  entry.queue.unshift(...writes);
678
+ // A drain already scheduled (a commit made meanwhile) re-sends them at once: they
679
+ // settle with its outcome instead.
680
+ if (!entry.scheduled)
681
+ settleWaiting(entry, { status: 'failed', error });
682
+ }
486
683
  else
487
684
  emitDocument(entry);
488
685
  options.onError(error);
489
686
  return kept;
490
687
  }
688
+ /**
689
+ * EC-686 — the drain stopped with writes still queued (kept, shown, re-sent by the next
690
+ * commit or forced flush): the commits waiting on them settle with why, never left
691
+ * hanging until a later flush lands them (an awaiting caller — a table cell, bulk
692
+ * set-field — would wait with them). Each settles once; the write stays queued. Not
693
+ * when a drain is already scheduled: it re-sends them at once, and they settle with it.
694
+ */
695
+ function settleWaiting(entry, outcome) {
696
+ entry.queue = entry.queue.map((queued) => {
697
+ if (queued.kind !== 'patch' || queued.settle.length === 0)
698
+ return queued;
699
+ settleAll([queued], outcome);
700
+ return { ...queued, settle: [] };
701
+ });
702
+ }
703
+ /**
704
+ * EC-686 — a verdict on a run of several patches as a whole ({@link isolates}): its
705
+ * innocents are not dropped with its culprit. A `validation` that
706
+ * locates every culprit ({@link locatedCulprits}) drops those, and the rest go back to the
707
+ * head of the queue to be re-sent once as a run (`'resent'`); otherwise each patch is
708
+ * re-sent once alone (`'alone'`), so the refusal finds its culprit. The verdict is
709
+ * reported once; a culprit's commit settles `failed` now, an innocent's after its
710
+ * re-send. The re-sent patches keep their authoring bases (lift rule 1 crosses each
711
+ * own revision a re-send lands at), and are never isolated again.
712
+ */
713
+ function isolate(entry, run, error) {
714
+ const culprits = locatedCulprits(error, run.length);
715
+ const resend = culprits
716
+ ? run.filter((_, index) => !culprits.has(index)).map((queued) => ({ ...queued, isolated: 'resent' }))
717
+ : run.map((queued) => ({ ...queued, isolated: 'alone' }));
718
+ if (culprits)
719
+ settleAll(run.filter((_, index) => culprits.has(index)), { status: 'failed', error });
720
+ entry.queue.unshift(...resend);
721
+ emitDocument(entry);
722
+ options.onError(error);
723
+ }
724
+ /**
725
+ * EC-686 item 1 (the owner's ruling, 2026-10-28: per-position) — a whole-document write the
726
+ * store refused, in the writes that re-send it: what its author changed against the view
727
+ * the snapshot was made from ({@link positionPatches}), each authored where that view had
728
+ * it (its token at the position — EC-667 — else the snapshot's revision). Sent next, each
729
+ * lands on the current row, or — a position someone else changed since — becomes that
730
+ * field's ordinary conflict, the authored value still shown. Never the snapshot again: a
731
+ * whole-document keep-mine would re-send it over every colleague change since.
732
+ *
733
+ * The slot alias, the key the grammar has no position for, follows them alone
734
+ * ({@link QueuedAlias}) when the row still carries the alias the author saw; when someone
735
+ * else changed it too, there is no position for a conflict — theirs is kept and the author
736
+ * is told (`aliasRefused`). `undefined` when nothing can be judged per position: no view,
737
+ * or no revision (an insert-if-absent).
738
+ */
739
+ function decompose(entry, write, current) {
740
+ const base = write.base ?? entry.created;
741
+ const view = write.authoredOn;
742
+ if (base === undefined || !view)
743
+ return undefined;
744
+ const provenance = shown.get(view);
745
+ const writes = positionPatches(view, write.document).map((patch) => ({
746
+ kind: 'patch',
747
+ patch,
748
+ base: (provenance ? provenanceAt(provenance, touchedPaths(patch)[0]) : undefined) ?? base,
749
+ settle: [],
750
+ }));
751
+ let aliasRefused = false;
752
+ const alias = write.document.alias;
753
+ if (alias !== view.alias && alias !== current.alias) {
754
+ if (current.alias === view.alias)
755
+ writes.push({ kind: 'alias', alias, authored: view.alias, attempt: write.aliasAttempt ?? 0 });
756
+ else
757
+ aliasRefused = true;
758
+ }
759
+ return { writes, aliasRefused };
760
+ }
761
+ /**
762
+ * EC-686 — the queue's head is a decomposed alias: it becomes the whole-document write that
763
+ * carries it — the server row the session holds, unchanged, with the alias, at that row's
764
+ * revision (so it changes the alias and nothing else; a write since refuses it, and it is
765
+ * decomposed again, at most {@link ALIAS_ATTEMPTS} times) — or it is done: the row already
766
+ * carries it, or someone else changed the slot since the author's view (theirs kept, told).
767
+ */
768
+ function resolveAlias(entry, write) {
769
+ entry.queue.shift();
770
+ const row = entry.base;
771
+ if (!row || row.document.alias === write.alias) {
772
+ emitDocument(entry);
773
+ return;
774
+ }
775
+ if (row.document.alias !== write.authored || write.attempt >= ALIAS_ATTEMPTS) {
776
+ emitDocument(entry);
777
+ options.onError(aliasRefusedError(entry.ref));
778
+ return;
779
+ }
780
+ entry.queue.unshift({
781
+ kind: 'replace',
782
+ document: withAlias(row.document, write.alias),
783
+ base: row.revision,
784
+ authoredOn: row.document,
785
+ aliasAttempt: write.attempt + 1,
786
+ });
787
+ }
491
788
  /** Send one queued whole-document write at the revision its snapshot was authored against. `false` → stop draining. */
492
789
  async function sendReplace(entry, operations, write) {
493
790
  const precondition = write.base !== undefined
@@ -522,8 +819,15 @@ export function createCmsPersister(options) {
522
819
  adopt(entry, result.value);
523
820
  }
524
821
  else if (result.error.code === 'conflict' && 'current' in result.error && result.error.current) {
822
+ // EC-686 item 1: refused as a whole — re-sent per position, in its place in the queue.
823
+ const parts = decompose(entry, write, result.error.current.document);
824
+ if (parts)
825
+ entry.queue.unshift(...parts.writes);
525
826
  adopt(entry, result.error.current);
526
- options.onError(result.error);
827
+ if (!parts)
828
+ options.onError(result.error);
829
+ else if (parts.aliasRefused)
830
+ options.onError(aliasRefusedError(entry.ref));
527
831
  }
528
832
  else if (failedWrite(entry, [write], result.error)) {
529
833
  return false;
@@ -554,21 +858,29 @@ export function createCmsPersister(options) {
554
858
  return;
555
859
  continue;
556
860
  }
861
+ if (head.kind === 'alias') {
862
+ resolveAlias(entry, head);
863
+ continue;
864
+ }
557
865
  const log = await liftLog(entry, operations, head);
558
866
  // The queue may have moved while the log was read (a superseding edit, a delete): start over.
559
867
  if (entry.queue[0] !== head)
560
868
  continue;
561
- const sendBase = (queued) => lift(entry, queued.base, touchedPaths(queued.patch), log);
869
+ const sendBase = (queued) => lift(entry, queued.base, queued.patch, log);
562
870
  const first = sendBase(head);
563
871
  let end = 1;
564
- while (end < entry.queue.length) {
872
+ // EC-686: a patch re-sent after its run was refused travels only with the run's other
873
+ // innocents — or alone ({@link isolate}).
874
+ while (head.isolated !== 'alone' && end < entry.queue.length) {
565
875
  const next = entry.queue[end];
566
- if (next.kind !== 'patch' || sendBase(next) !== first)
876
+ if (next.kind !== 'patch' || next.isolated !== head.isolated || sendBase(next) !== first)
567
877
  break;
568
878
  end += 1;
569
879
  }
570
880
  const run = entry.queue.splice(0, end);
571
881
  entry.inFlight = run;
882
+ // EC-693: the row the run is sent from — where its applied patches land ({@link footprint}).
883
+ const sentFrom = entry.base?.document;
572
884
  const result = await operations.documents.patch({
573
885
  projectId: options.projectId,
574
886
  ref: entry.ref,
@@ -580,7 +892,7 @@ export function createCmsPersister(options) {
580
892
  recordOwn(entry, {
581
893
  revision: result.value.revision,
582
894
  sentBase: first,
583
- paths: run.flatMap((queued) => touchedPaths(queued.patch)),
895
+ paths: footprint(sentFrom, run.map((queued) => queued.patch)),
584
896
  });
585
897
  adopt(entry, result.value);
586
898
  adoptHeld(entry);
@@ -613,7 +925,7 @@ export function createCmsPersister(options) {
613
925
  recordOwn(entry, {
614
926
  revision: current.revision,
615
927
  sentBase: first,
616
- paths: applied.flatMap((queued) => touchedPaths(queued.patch)),
928
+ paths: footprint(sentFrom, applied.map((queued) => queued.patch)),
617
929
  });
618
930
  }
619
931
  adopt(entry, current);
@@ -630,10 +942,19 @@ export function createCmsPersister(options) {
630
942
  settleAll(run, { status: 'failed', error });
631
943
  return;
632
944
  }
945
+ // EC-686 — a verdict on a run of several patches, not yet isolated: re-sent once
946
+ // without its culprits. A `not-found` is the document's and names none; a kept
947
+ // failure is not a verdict.
948
+ if (run.length > 1 && head.isolated === undefined && isolates(error)) {
949
+ isolate(entry, run, error);
950
+ adoptHeld(entry);
951
+ continue;
952
+ }
633
953
  // EC-674 — any other failure: the commits waiting on the run learn of it now. One the
634
954
  // store did not judge keeps the patches (shown, unsent; the next commit or forced flush
635
- // re-sends them); a verdict (validation, unauthorized, not-found) drops them, and the
636
- // writes behind it drain — put back, it would be refused in front of every later write.
955
+ // re-sends them); a verdict (validation, unauthorized, not-found, an in-process
956
+ // `unknown`) drops them, and the writes behind it drain — put back, it would be refused
957
+ // in front of every later write.
637
958
  settleAll(run, { status: 'failed', error });
638
959
  const kept = failedWrite(entry, run.map((queued) => ({ ...queued, settle: [] })), error);
639
960
  adoptHeld(entry);
@@ -684,7 +1005,10 @@ export function createCmsPersister(options) {
684
1005
  },
685
1006
  revisionOfView: (view, path) => {
686
1007
  const provenance = shown.get(view);
687
- return provenance ? provenanceAt(provenance, path) : undefined;
1008
+ if (!provenance)
1009
+ return undefined;
1010
+ // EC-693: the asked position in the view's structural form, as its shown writes are.
1011
+ return provenanceAt(provenance, path && (expandValuePath(view, path) ?? path));
688
1012
  },
689
1013
  adoptRecord(record) {
690
1014
  const entry = entryOf(record.document);
@@ -712,6 +1036,8 @@ export function createCmsPersister(options) {
712
1036
  }
713
1037
  if (!entry.base && !entry.provisional && view)
714
1038
  entry.provisional = view;
1039
+ // EC-693: where the shown own writes and these patches touch the view they were authored on.
1040
+ const positions = compose(entry, patches)?.positions ?? new Map();
715
1041
  let conflictsChanged = false;
716
1042
  let unauthored = false;
717
1043
  const waits = [];
@@ -728,15 +1054,19 @@ export function createCmsPersister(options) {
728
1054
  }
729
1055
  // A field with an unresolved conflict: the new value replaces MINE, unsent —
730
1056
  // the user decides (keep mine sends it), never an implicit overwrite of theirs.
731
- const open = entry.conflicts.find((conflict) => patchesOverlap(conflict.patch, patch));
732
- if (open && isValueWrite(patch) && isValueWrite(open.patch) && samePath(open.patch.path, patch.path)) {
1057
+ // EC-693: the conflict at THIS position, as submitted or in the view — never merely
1058
+ // the first overlapping one (an open conflict at an ancestor would hide it).
1059
+ const open = isValueWrite(patch)
1060
+ ? entry.conflicts.find((conflict) => isValueWrite(conflict.patch) && samePosition(conflict.patch, patch, positions))
1061
+ : undefined;
1062
+ if (open) {
733
1063
  const replaced = { ...open, patch };
734
1064
  entry.conflicts = entry.conflicts.map((c) => (c === open ? replaced : c));
735
1065
  conflictsChanged = true;
736
1066
  settle({ status: 'refused', conflicts: [replaced] });
737
1067
  continue;
738
1068
  }
739
- const base = authoredBase(entry, patch, authored);
1069
+ const base = authoredBase(entry, patch, authored, positions);
740
1070
  // A later value write of the same field supersedes an unsent one (keeping the
741
1071
  // EARLIER base — the user never saw a foreign value of a field they were typing
742
1072
  // in). Only after the last queued whole-document write: one before it is sent
@@ -819,7 +1149,7 @@ export function createCmsPersister(options) {
819
1149
  options.onError(result.error);
820
1150
  // EC-647: the writes authored on the document we meant to create have no
821
1151
  // revision to be based on — never the revision of a row someone else wrote.
822
- dropQueued(entry, { status: 'failed', error: result.error }, (queued) => queued.base === undefined);
1152
+ dropQueued(entry, { status: 'failed', error: result.error }, (queued) => queued.kind !== 'alias' && queued.base === undefined);
823
1153
  }
824
1154
  }
825
1155
  finally {
@@ -831,7 +1161,7 @@ export function createCmsPersister(options) {
831
1161
  await drain(entry);
832
1162
  });
833
1163
  },
834
- replace(document, revision) {
1164
+ replace(document, revision, authoredOn) {
835
1165
  const doc = canonical(document);
836
1166
  const entry = entryOf(doc);
837
1167
  if (entry.deleted) {
@@ -847,7 +1177,8 @@ export function createCmsPersister(options) {
847
1177
  // holds them), the ones committed after it are sent after it (it does not). EC-647
848
1178
  // (Codex F10): one with no authoring revision on a document this session tracks and
849
1179
  // did not create is refused there, in order — never sent at the tracked revision.
850
- entry.queue.push({ kind: 'replace', document: doc, base: authoredBase(entry, undefined, revision) });
1180
+ // EC-686: with the document it was made from — what a refusal decomposes it against.
1181
+ entry.queue.push({ kind: 'replace', document: doc, base: authoredBase(entry, undefined, revision), ...(authoredOn ? { authoredOn } : {}) });
851
1182
  emitDocument(entry);
852
1183
  schedule(entry);
853
1184
  },