@elytracms/studio 0.0.96 → 0.0.98

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 (53) hide show
  1. package/convex/assets.ts +16 -2
  2. package/convex/cms.ts +153 -13
  3. package/convex/delivery-folded-drift.test.ts +104 -0
  4. package/convex/delivery.ts +24 -8
  5. package/convex/depth-codec-lookalike.test.ts +192 -0
  6. package/convex/fixture-accounts.test.ts +305 -0
  7. package/convex/fixtureAccounts.ts +298 -0
  8. package/convex/incarnation.test.ts +149 -0
  9. package/convex/ingest-attempt.test.ts +161 -0
  10. package/convex/ingest.test.ts +327 -9
  11. package/convex/ingest.ts +242 -24
  12. package/convex/prototype-key.test.ts +92 -0
  13. package/convex/receipt-races.test.ts +184 -0
  14. package/convex/records-zod-copy.test.ts +76 -0
  15. package/convex/records-zod-refinements.convex.test.ts +94 -0
  16. package/convex/schema.ts +20 -1
  17. package/convex/strict-delete.test.ts +129 -0
  18. package/dist/components/cms/cms-store.d.ts +6 -5
  19. package/dist/components/cms/cms-store.js +14 -34
  20. package/dist/components/cms/cms-store.js.map +1 -1
  21. package/dist/components/cms/field-control.d.ts +12 -1
  22. package/dist/components/cms/field-control.js +16 -1
  23. package/dist/components/cms/field-control.js.map +1 -1
  24. package/dist/components/cms/import-batch.d.ts +23 -0
  25. package/dist/components/cms/import-batch.js +43 -0
  26. package/dist/components/cms/import-batch.js.map +1 -0
  27. package/dist/components/cms/rich-text/editor.js +13 -10
  28. package/dist/components/cms/rich-text/editor.js.map +1 -1
  29. package/dist/components/cms/rich-text/value.d.ts +9 -0
  30. package/dist/components/cms/rich-text/value.js +13 -0
  31. package/dist/components/cms/rich-text/value.js.map +1 -1
  32. package/dist/components/cms/use-document-draft.d.ts +76 -4
  33. package/dist/components/cms/use-document-draft.js +94 -25
  34. package/dist/components/cms/use-document-draft.js.map +1 -1
  35. package/dist/components/records/record-lens.js.map +1 -1
  36. package/dist/lib/collection-view/collection-view-source.d.ts +5 -2
  37. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  38. package/dist/lib/engine-version.d.ts +1 -1
  39. package/dist/lib/engine-version.js +1 -1
  40. package/dist/lib/engine-version.js.map +1 -1
  41. package/dist/lib/i18n/messages.de.js +5 -0
  42. package/dist/lib/i18n/messages.de.js.map +1 -1
  43. package/dist/lib/i18n/messages.en.d.ts +5 -0
  44. package/dist/lib/i18n/messages.en.js +5 -0
  45. package/dist/lib/i18n/messages.en.js.map +1 -1
  46. package/dist/lib/operations/cms-persistence.d.ts +49 -5
  47. package/dist/lib/operations/cms-persistence.js +237 -47
  48. package/dist/lib/operations/cms-persistence.js.map +1 -1
  49. package/dist/lib/persistence/convex-adapter.js +27 -1
  50. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  51. package/dist/lib/persona/labels.js +2 -0
  52. package/dist/lib/persona/labels.js.map +1 -1
  53. package/package.json +5 -3
@@ -36,6 +36,21 @@ import type { OperationError, OperationsClient } from './operations-package.js';
36
36
  * (which sent at whatever revision a later read returned). A write that cannot name the
37
37
  * revision it was authored against is refused, visibly — it never borrows one.
38
38
  *
39
+ * - **A view's token is per position (EC-667, Codex F18).** A view that shows a pending
40
+ * write is a COMPOSITE: no single underlying revision names it. Each shown view is
41
+ * registered with its provenance at display time — the base's revision, or under a
42
+ * pending whole-document write its snapshot's authoring revision, and over that each
43
+ * own unacknowledged write shown (queued, in flight, an open conflict's "mine") with
44
+ * the base it was authored at. `revisionOfView(view, path)` is the revision the content
45
+ * at `path` was authored against: the earliest of the floor and of the shown writes that
46
+ * OVERLAP `path` — at it, at an ancestor, or at a descendant, whose content the value at
47
+ * `path` contains (EC-681, Codex F23); a sibling's write does not lower it.
48
+ * `revisionOfView(view)` — a whole-document writer — the EARLIEST of all. The record is
49
+ * immutable, so it outlives the pending write: an edit started on a view that showed a
50
+ * snapshot keeps the snapshot's revision after the snapshot is refused or fails
51
+ * (before EC-667 the composite was named by the base's revision — a colleague's change
52
+ * adopted underneath a pending snapshot lent the edit its revision, and the edit,
53
+ * authored on the old content, overwrote it).
39
54
  * - **A position under one of this session's own unacknowledged writes** (queued, in
40
55
  * flight, or an open conflict's "mine") was authored against THAT write's base: the
41
56
  * view showed mine there, not a colleague's change that may sit underneath it. The
@@ -46,9 +61,12 @@ import type { OperationError, OperationsClient } from './operations-package.js';
46
61
  * after it (nothing foreign happened in between), or to an own write's revision when
47
62
  * the patch only touches positions that write covered and the write was sent at or
48
63
  * below the patch's base (the store refused every foreign change of those positions in
49
- * between, or the write would not have applied). Without it, the session's second
50
- * edit of a position, made at the revision its first was authored against, would be
51
- * refused by its own first write.
64
+ * between, or the write would not have applied) — or, EC-681, across a revision someone
65
+ * else wrote that the patch log shows touched none of the patch's positions (the store
66
+ * would not refuse the patch on it), read only when an own write beyond it would
67
+ * otherwise refuse the patch. Without it, the session's second edit of a position, made
68
+ * at the revision its first was authored against, would be refused by its own first
69
+ * write — and an edit of a parent, authored over an own child write, by that child's.
52
70
  * - **The provisional view** (`undefined` authoring revision): a document whose view
53
71
  * has no revision yet — our own create in flight, or a document the session never
54
72
  * tracked (a staged transfer). Writes on it are based on the revision our create
@@ -102,7 +120,13 @@ export type CommitOutcome = {
102
120
  export interface CmsPersister {
103
121
  /** Adopt a server row as this session's base for its document (hydration, a create result). */
104
122
  track(row: TrackedRow): void;
105
- /** The revision the session's view of a document is at now (`undefined` = untracked). */
123
+ /**
124
+ * The revision of the server row the session last adopted for a document (`undefined` =
125
+ * untracked). A document deleted since still answers its removed lifetime's last
126
+ * revision (EC-673): a publish conditioned on it is refused, never sent unconditionally
127
+ * into a re-creation this session never showed — so whether to create or replace is
128
+ * never decided on it alone ({@link importWrite} decides on the row the session shows).
129
+ */
106
130
  revisionOf(ref: DocumentRef): number | undefined;
107
131
  /** The session's view of a tracked document (base ⊕ local), or `undefined`. */
108
132
  documentOf(ref: DocumentRef): CmsDocument | undefined;
@@ -113,8 +137,14 @@ export interface CmsPersister {
113
137
  * the pair cannot drift apart. `undefined` for a value the persister never showed (a
114
138
  * copy, a fixture), or a provisional view (our own create in flight, an untracked
115
139
  * document).
140
+ *
141
+ * EC-667 — per position: `path` given, the revision the content the view shows AT
142
+ * `path` was authored against (an editor's first keystroke there); without it, the
143
+ * EARLIEST authoring revision of any position the view shows — the token of a
144
+ * whole-document writer (a replace, an import). A view showing a pending write is never
145
+ * named by the base's revision alone (see the module doc).
116
146
  */
117
- revisionOfView(view: CmsDocument): number | undefined;
147
+ revisionOfView(view: CmsDocument, path?: ValuePath): number | undefined;
118
148
  /**
119
149
  * Commit patches a writer made and flush them. The session's view updates
120
150
  * synchronously (via `onDocument`); the patches persist in order through
@@ -185,4 +215,18 @@ export interface CmsPersisterOptions {
185
215
  }
186
216
  /** What the session hydrated for a document: its value and the revision it is at. */
187
217
  export type TrackedRow = Pick<CmsDocumentRecord, 'document' | 'revision'>;
218
+ /**
219
+ * An import's whole-document write (EC-595 A2 → EC-647 → EC-673): a row the session SHOWS
220
+ * and holds a revision for is REPLACED at the revision of the row it showed (the one the
221
+ * import was judged against); any other row is CREATED (insert-if-absent).
222
+ *
223
+ * EC-673: the decision is the SHOWN row's, never the tracked revision alone. A document
224
+ * deleted earlier in the session is not shown, but its entry can still answer a
225
+ * revision — the editor's Delete and a colleague's delete keep the deleted lifetime's
226
+ * base, and the list's bulk Delete never tells the persister at all. Deciding on that
227
+ * revision sent the import down `replace`: queued on a deleted entry and skipped, or
228
+ * refused as unauthored with the deleted lifetime's values shown again. A replace with no
229
+ * shown row could never be right anyway — it names no revision it was authored against.
230
+ */
231
+ export declare function importWrite(persister: Pick<CmsPersister, 'revisionOf' | 'revisionOfView' | 'replace' | 'create'>, document: CmsDocument, shown: CmsDocument | undefined): void;
188
232
  export declare function createCmsPersister(options: CmsPersisterOptions): CmsPersister;
@@ -1,7 +1,46 @@
1
- import { applyDocumentPatches, documentKey, formatValuePath, isValuePathPrefix, readValueAt, selfTypeDocument, semanticValueEqual, touchedPaths, valuePathsOverlap, } from '@elytracms/core/cms-core';
1
+ import { applyDocumentPatches, documentKey, formatValuePath, isValuePathPrefix, readValueAt, selfTypeDocument, semanticValueEqual, touchedPaths, touchedPathsOfEntry, valuePathsOverlap, } from '@elytracms/core/cms-core';
2
2
  import { createPersistQueue } from './persist-queue.js';
3
3
  /** How many own writes a document's ledger keeps (an older base just stops lifting — a conflict, never a borrow). */
4
4
  const OWN_WRITES_KEPT = 256;
5
+ /**
6
+ * EC-667 → EC-681 — the revision the content a view shows at `path` was authored against:
7
+ * a token describes the WHOLE value a writer of `path` may replace. It is the earliest of
8
+ * the floor and of every shown write whose positions OVERLAP `path` structurally — a write
9
+ * at an ancestor of `path`, at `path`, or at a DESCENDANT of it (the value at `path`
10
+ * 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.
12
+ *
13
+ * Minted when the view is shown, never when the edit commits (Codex F18, F23): a
14
+ * descendant write may be refused and its conflict resolved before an edit of `path`
15
+ * started on this view commits, and that edit still carries the descendant's old content.
16
+ * The own descendant write that then APPLIES is lifted across at send time ({@link lift}).
17
+ */
18
+ function provenanceAt(provenance, path) {
19
+ let at = provenance.floor;
20
+ for (const write of provenance.writes) {
21
+ if (path === undefined || write.paths.some((written) => valuePathsOverlap(written, path))) {
22
+ at = earlierBase(at, write.base);
23
+ }
24
+ }
25
+ return at;
26
+ }
27
+ /**
28
+ * EC-681 — whether a write at `a` may overlap one at `b` as the STORE judges it: THE
29
+ * conflict relation (`valuePathsOverlap`) unless BOTH name a component node — then any two
30
+ * 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
+ */
35
+ function mayOverlap(a, b) {
36
+ const fieldOf = (path) => {
37
+ const node = path.findIndex((segment) => segment.kind === 'node');
38
+ return node < 0 ? undefined : path.slice(0, node);
39
+ };
40
+ const fieldA = fieldOf(a);
41
+ const fieldB = fieldOf(b);
42
+ return fieldA && fieldB ? valuePathsOverlap(fieldA, fieldB) : valuePathsOverlap(a, b);
43
+ }
5
44
  /** A patch's identity within one call: op + target path (no two value writes of one path share a run). */
6
45
  function targetKey(op, path) {
7
46
  return `${op} ${formatValuePath(path)}`;
@@ -27,6 +66,49 @@ function samePath(a, b) {
27
66
  function earlierBase(a, b) {
28
67
  return a === undefined || b === undefined ? undefined : Math.min(a, b);
29
68
  }
69
+ /**
70
+ * An import's whole-document write (EC-595 A2 → EC-647 → EC-673): a row the session SHOWS
71
+ * and holds a revision for is REPLACED at the revision of the row it showed (the one the
72
+ * import was judged against); any other row is CREATED (insert-if-absent).
73
+ *
74
+ * EC-673: the decision is the SHOWN row's, never the tracked revision alone. A document
75
+ * deleted earlier in the session is not shown, but its entry can still answer a
76
+ * revision — the editor's Delete and a colleague's delete keep the deleted lifetime's
77
+ * base, and the list's bulk Delete never tells the persister at all. Deciding on that
78
+ * revision sent the import down `replace`: queued on a deleted entry and skipped, or
79
+ * refused as unauthored with the deleted lifetime's values shown again. A replace with no
80
+ * shown row could never be right anyway — it names no revision it was authored against.
81
+ */
82
+ export function importWrite(persister, document, shown) {
83
+ if (shown && persister.revisionOf(document) !== undefined) {
84
+ persister.replace(document, persister.revisionOfView(shown));
85
+ }
86
+ else
87
+ persister.create(document);
88
+ }
89
+ /**
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.
96
+ *
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).
104
+ */
105
+ function keepsFailedWrite(error) {
106
+ return error.code === 'backend-unavailable' || error.code === 'unknown';
107
+ }
108
+ /** A write against a document this session no longer holds (deleted here or elsewhere). */
109
+ function goneError(ref) {
110
+ return { code: 'not-found', message: 'The document is gone.', entity: 'cms-document', key: documentKey(ref) };
111
+ }
30
112
  /** The refusal of a write that names no revision it was authored against (EC-647). */
31
113
  function unauthoredError(ref) {
32
114
  return {
@@ -44,8 +126,10 @@ export function createCmsPersister(options) {
44
126
  });
45
127
  });
46
128
  const entries = new Map();
47
- /** EC-647: every document value this persister showed or tracked → the revision it is at. */
129
+ /** EC-647 / EC-667: every document value this persister showed or tracked → what it was authored against. */
48
130
  const shown = new WeakMap();
131
+ /** A server row's document: every position at its revision. */
132
+ const registerRow = (row) => shown.set(row.document, { floor: row.revision, writes: [] });
49
133
  function entryOf(ref) {
50
134
  const key = documentKey(ref);
51
135
  let entry = entries.get(key);
@@ -84,15 +168,21 @@ export function createCmsPersister(options) {
84
168
  }
85
169
  /**
86
170
  * The session's view: base ⊕ unresolved mine ⊕ in-flight ⊕ queued (the pure merge, no
87
- * log; a whole-document write resets it to its snapshot). Registered with the revision
88
- * it is at (EC-647) — the base's; a provisional view has none.
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.
89
174
  */
90
175
  function display(entry) {
91
176
  const baseDocument = entry.base?.document ?? entry.provisional;
92
177
  if (entry.deleted || !baseDocument)
93
178
  return undefined;
94
179
  let document = baseDocument;
180
+ let floor = entry.base?.revision;
95
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
+ }));
96
186
  const pending = [
97
187
  ...entry.inFlight,
98
188
  ...(entry.inFlightReplace ? [entry.inFlightReplace] : []),
@@ -100,17 +190,22 @@ export function createCmsPersister(options) {
100
190
  ];
101
191
  for (const queued of pending) {
102
192
  if (queued.kind === 'replace') {
103
- // The snapshot was authored over everything before it (the editor's draft).
193
+ // The snapshot was authored over everything before it (the editor's draft): every
194
+ // position now shows content authored at the SNAPSHOT's revision (EC-667).
104
195
  patches = [];
196
+ writes = [];
105
197
  document = queued.document;
198
+ floor = queued.base;
106
199
  }
107
- else
200
+ else {
108
201
  patches.push(queued.patch);
202
+ writes.push({ paths: touchedPaths(queued.patch), base: queued.base });
203
+ }
109
204
  }
110
205
  if (patches.length > 0)
111
206
  document = applyDocumentPatches({ document, patches, since: [] }).document;
112
207
  if (entry.base)
113
- shown.set(document, entry.base.revision);
208
+ shown.set(document, { floor, writes });
114
209
  return document;
115
210
  }
116
211
  function emitDocument(entry) {
@@ -128,7 +223,7 @@ export function createCmsPersister(options) {
128
223
  if (entry.base && record.revision < entry.base.revision)
129
224
  return;
130
225
  entry.base = record;
131
- shown.set(record.document, record.revision);
226
+ registerRow(record);
132
227
  options.onRecord?.(record);
133
228
  emitDocument(entry);
134
229
  }
@@ -160,12 +255,22 @@ export function createCmsPersister(options) {
160
255
  if (entry.deleted)
161
256
  return;
162
257
  entry.deleted = true;
163
- dropQueued(entry, {
164
- status: 'failed',
165
- error: { code: 'not-found', message: 'The document is gone.', entity: 'cms-document', key: documentKey(entry.ref) },
166
- });
258
+ dropQueued(entry, { status: 'failed', error: goneError(entry.ref) });
167
259
  options.onDocument?.(entry.ref, undefined);
168
260
  }
261
+ /**
262
+ * EC-673 — writes queued on a document this session no longer holds (one put back by a
263
+ * failed send after a delete, a "keep mine" on a document that went away): refused and
264
+ * REPORTED, never left in the queue for the drain to skip — nor sent into a later
265
+ * re-create of the id, whose first revision the own-write ledger would lift them across.
266
+ */
267
+ function refuseQueued(entry) {
268
+ if (entry.queue.length === 0)
269
+ return;
270
+ const error = goneError(entry.ref);
271
+ dropQueued(entry, { status: 'failed', error });
272
+ options.onError(error);
273
+ }
169
274
  function schedule(entry) {
170
275
  if (entry.scheduled)
171
276
  return;
@@ -183,12 +288,23 @@ export function createCmsPersister(options) {
183
288
  entry.own.splice(0, entry.own.length - OWN_WRITES_KEPT);
184
289
  }
185
290
  /**
186
- * EC-647 — THE lift: the base a write authored at `base` is SENT at. Only across this
187
- * persister's own writes (see the module doc): a run of consecutive own revisions
188
- * right after the base, or an own write that covered every position `paths` touch and
189
- * was sent at or below the base. Never across a revision anyone else wrote.
291
+ * EC-647 → EC-681 — THE lift: the base a write authored at `base` is SENT at. It crosses
292
+ * a revision only where the store's refusal on it would be a refusal of OUR content —
293
+ * never a change by anyone else of a position the write touches. Three rules, applied
294
+ * until none moves it:
295
+ * 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
297
+ * 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;
299
+ * 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.
190
306
  */
191
- function lift(entry, base, paths) {
307
+ function lift(entry, base, paths, log) {
192
308
  let lifted = base;
193
309
  for (;;) {
194
310
  if (entry.own.some((write) => write.revision === lifted + 1)) {
@@ -202,11 +318,38 @@ export function createCmsPersister(options) {
202
318
  write.sentBase <= lifted &&
203
319
  paths.every((path) => write.paths.some((covered) => isValuePathPrefix(covered, path))))
204
320
  : undefined;
205
- if (!covering)
206
- return lifted;
207
- lifted = covering.revision;
321
+ if (covering) {
322
+ lifted = covering.revision;
323
+ continue;
324
+ }
325
+ const foreign = paths ? log?.get(lifted + 1) : undefined;
326
+ if (foreign && !touchedPathsOfEntry(foreign).some((logged) => paths.some((path) => mayOverlap(logged, path)))) {
327
+ lifted += 1;
328
+ continue;
329
+ }
330
+ return lifted;
208
331
  }
209
332
  }
333
+ /**
334
+ * EC-681 — the patch log {@link lift} rule 3 reads for a queued patch: only when an own
335
+ * write the store would refuse it on lies beyond its lift (an own write that may overlap
336
+ * its positions, past a revision someone else wrote), read after that lift. `undefined`
337
+ * when not needed or not readable — then no lift, a conflict at worst, never a borrow.
338
+ */
339
+ async function liftLog(entry, operations, queued) {
340
+ const paths = touchedPaths(queued.patch);
341
+ const lifted = lift(entry, queued.base, paths);
342
+ const blocking = entry.own.some((write) => write.revision > lifted &&
343
+ (write.paths === undefined || write.paths.some((own) => paths.some((path) => mayOverlap(own, path)))));
344
+ if (!blocking)
345
+ return undefined;
346
+ const read = await operations.documents.listPatches({
347
+ projectId: options.projectId,
348
+ ref: entry.ref,
349
+ afterRevision: lifted,
350
+ });
351
+ return read.ok ? new Map(read.value.map((row) => [row.revision, row])) : undefined;
352
+ }
210
353
  /**
211
354
  * EC-647 — the base a write is QUEUED with: the revision it was authored against, or
212
355
  * the earlier base of an own unacknowledged write on the same positions (the view
@@ -327,6 +470,24 @@ export function createCmsPersister(options) {
327
470
  // The actor is decoration on a conflict the user can already resolve.
328
471
  }
329
472
  }
473
+ /**
474
+ * EC-674 — a write that failed without a per-field conflict: KEPT when the store did not
475
+ * judge it ({@link keepsFailedWrite}) — back at the head of the queue, still shown,
476
+ * re-sent by the next commit or forced flush at its own authoring base (a retry never
477
+ * borrows a newer revision) — or DROPPED on the store's verdict, and the view re-derived
478
+ * 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.
481
+ */
482
+ function failedWrite(entry, writes, error) {
483
+ const kept = keepsFailedWrite(error);
484
+ if (kept)
485
+ entry.queue.unshift(...writes);
486
+ else
487
+ emitDocument(entry);
488
+ options.onError(error);
489
+ return kept;
490
+ }
330
491
  /** Send one queued whole-document write at the revision its snapshot was authored against. `false` → stop draining. */
331
492
  async function sendReplace(entry, operations, write) {
332
493
  const precondition = write.base !== undefined
@@ -360,14 +521,13 @@ export function createCmsPersister(options) {
360
521
  });
361
522
  adopt(entry, result.value);
362
523
  }
363
- else {
364
- if (result.error.code === 'conflict' && 'current' in result.error && result.error.current) {
365
- adopt(entry, result.error.current);
366
- }
367
- else
368
- emitDocument(entry);
524
+ else if (result.error.code === 'conflict' && 'current' in result.error && result.error.current) {
525
+ adopt(entry, result.error.current);
369
526
  options.onError(result.error);
370
527
  }
528
+ else if (failedWrite(entry, [write], result.error)) {
529
+ return false;
530
+ }
371
531
  }
372
532
  finally {
373
533
  entry.inFlightReplace = undefined;
@@ -378,7 +538,11 @@ export function createCmsPersister(options) {
378
538
  }
379
539
  /** Send the queue — runs of equal (lifted) base, whole-document writes in their place — until it is empty or a write fails. */
380
540
  async function drain(entry) {
381
- while (entry.queue.length > 0 && !entry.deleted) {
541
+ while (entry.queue.length > 0) {
542
+ if (entry.deleted) {
543
+ refuseQueued(entry);
544
+ return;
545
+ }
382
546
  const operations = await options.getOperations();
383
547
  if (!(await baseOnRead(entry, operations)))
384
548
  return;
@@ -386,10 +550,15 @@ export function createCmsPersister(options) {
386
550
  if (!head)
387
551
  return;
388
552
  if (head.kind === 'replace') {
389
- await sendReplace(entry, operations, head);
553
+ if (!(await sendReplace(entry, operations, head)))
554
+ return;
390
555
  continue;
391
556
  }
392
- const sendBase = (queued) => lift(entry, queued.base, touchedPaths(queued.patch));
557
+ const log = await liftLog(entry, operations, head);
558
+ // The queue may have moved while the log was read (a superseding edit, a delete): start over.
559
+ if (entry.queue[0] !== head)
560
+ continue;
561
+ const sendBase = (queued) => lift(entry, queued.base, touchedPaths(queued.patch), log);
393
562
  const first = sendBase(head);
394
563
  let end = 1;
395
564
  while (end < entry.queue.length) {
@@ -461,14 +630,15 @@ export function createCmsPersister(options) {
461
630
  settleAll(run, { status: 'failed', error });
462
631
  return;
463
632
  }
464
- // A transport / authorization failure: the patches stay local (shown, unsent) and
465
- // the error is visible; the next commit or forced flush retries them. The commits
466
- // waiting on them learn of the failure now.
633
+ // EC-674 — any other failure: the commits waiting on the run learn of it now. One the
634
+ // 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.
467
637
  settleAll(run, { status: 'failed', error });
468
- entry.queue.unshift(...run.map((queued) => ({ ...queued, settle: [] })));
638
+ const kept = failedWrite(entry, run.map((queued) => ({ ...queued, settle: [] })), error);
469
639
  adoptHeld(entry);
470
- options.onError(error);
471
- return;
640
+ if (kept)
641
+ return;
472
642
  }
473
643
  }
474
644
  function upsertConflict(entry, refused, revision, base) {
@@ -503,7 +673,7 @@ export function createCmsPersister(options) {
503
673
  return {
504
674
  track(row) {
505
675
  const entry = entryOf(row.document);
506
- shown.set(row.document, row.revision);
676
+ registerRow(row);
507
677
  if (!entry.base || row.revision > entry.base.revision)
508
678
  entry.base = row;
509
679
  },
@@ -512,7 +682,10 @@ export function createCmsPersister(options) {
512
682
  const entry = entries.get(documentKey(ref));
513
683
  return entry ? display(entry) : undefined;
514
684
  },
515
- revisionOfView: (view) => shown.get(view),
685
+ revisionOfView: (view, path) => {
686
+ const provenance = shown.get(view);
687
+ return provenance ? provenanceAt(provenance, path) : undefined;
688
+ },
516
689
  adoptRecord(record) {
517
690
  const entry = entryOf(record.document);
518
691
  if (entry.deleted)
@@ -522,11 +695,14 @@ export function createCmsPersister(options) {
522
695
  },
523
696
  commit(ref, patches, bases, view) {
524
697
  const entry = entryOf(ref);
525
- if (patches.length === 0 || entry.deleted) {
526
- return Promise.resolve({
527
- status: 'failed',
528
- error: { code: 'not-found', message: 'The document is gone.', entity: 'cms-document', key: documentKey(ref) },
529
- });
698
+ if (patches.length === 0)
699
+ return Promise.resolve({ status: 'failed', error: goneError(ref) });
700
+ if (entry.deleted) {
701
+ // EC-673: a write that cannot be sent is reported, not only settled — the editor's
702
+ // committer and the transfer dialog do not read the outcome.
703
+ const error = goneError(ref);
704
+ options.onError(error);
705
+ return Promise.resolve({ status: 'failed', error });
530
706
  }
531
707
  if (!Array.isArray(bases)) {
532
708
  // EC-647: a writer that names no authoring revision at all — never given the tracked one.
@@ -605,7 +781,17 @@ export function createCmsPersister(options) {
605
781
  create(document) {
606
782
  const doc = canonical(document);
607
783
  const entry = entryOf(doc);
608
- // A fresh document: nothing of an earlier incarnation of this ref carries over.
784
+ // A fresh document: nothing of an earlier incarnation of this ref carries over —
785
+ // EC-673: its queued writes (refused and reported, never sent into this one) and its
786
+ // open conflicts (a colleague's delete keeps them; their "mine" is not laid over this one).
787
+ if (entry.deleted) {
788
+ refuseQueued(entry);
789
+ if (entry.conflicts.length > 0) {
790
+ entry.conflicts = [];
791
+ entry.conflictBases.clear();
792
+ emitConflicts(entry);
793
+ }
794
+ }
609
795
  entry.deleted = false;
610
796
  entry.base = undefined;
611
797
  entry.own = [];
@@ -648,6 +834,13 @@ export function createCmsPersister(options) {
648
834
  replace(document, revision) {
649
835
  const doc = canonical(document);
650
836
  const entry = entryOf(doc);
837
+ if (entry.deleted) {
838
+ // EC-673: a document this session no longer holds has nothing to replace — refused
839
+ // and reported here, never queued for a drain that skips it (or a re-create of the
840
+ // id that would send it). A new document of the id is a `create`.
841
+ options.onError(goneError(entry.ref));
842
+ return;
843
+ }
651
844
  if (!entry.base && !entry.provisional)
652
845
  entry.provisional = doc;
653
846
  // Queued in its place: the patches committed before it are sent first (the snapshot
@@ -660,10 +853,7 @@ export function createCmsPersister(options) {
660
853
  },
661
854
  deleteDocument(ref) {
662
855
  const entry = entryOf(ref);
663
- dropQueued(entry, {
664
- status: 'failed',
665
- error: { code: 'not-found', message: 'The document is gone.', entity: 'cms-document', key: documentKey(ref) },
666
- });
856
+ dropQueued(entry, { status: 'failed', error: goneError(ref) });
667
857
  entry.conflicts = [];
668
858
  entry.conflictBases.clear();
669
859
  entry.deleted = true;