@elytracms/studio 0.0.95 → 0.0.97

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 (104) hide show
  1. package/convex/cms.ts +272 -16
  2. package/convex/delivery-folded-drift.test.ts +104 -0
  3. package/convex/delivery.ts +24 -8
  4. package/convex/depth-codec-lookalike.test.ts +192 -0
  5. package/convex/fixture-accounts.test.ts +305 -0
  6. package/convex/fixtureAccounts.ts +298 -0
  7. package/convex/incarnation.test.ts +149 -0
  8. package/convex/ingest.test.ts +145 -3
  9. package/convex/ingest.ts +123 -17
  10. package/convex/receipt-races.test.ts +184 -0
  11. package/convex/records-zod-copy.test.ts +76 -0
  12. package/convex/schema.ts +20 -1
  13. package/convex/strict-delete.test.ts +129 -0
  14. package/convex/strict-reference-race.test.ts +139 -0
  15. package/dist/components/ai/ai-chat-panel.js +15 -2
  16. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  17. package/dist/components/ai/translate-document-action.d.ts +6 -0
  18. package/dist/components/ai/translate-document-action.js.map +1 -1
  19. package/dist/components/ai/translate-document-dialog.d.ts +1 -1
  20. package/dist/components/ai/translate-document-dialog.js +5 -5
  21. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  22. package/dist/components/assets/asset-detail.js +5 -2
  23. package/dist/components/assets/asset-detail.js.map +1 -1
  24. package/dist/components/assets/asset-locale.d.ts +7 -0
  25. package/dist/components/assets/asset-locale.js +16 -2
  26. package/dist/components/assets/asset-locale.js.map +1 -1
  27. package/dist/components/assets/asset-meta-drift-notice.d.ts +13 -0
  28. package/dist/components/assets/asset-meta-drift-notice.js +22 -0
  29. package/dist/components/assets/asset-meta-drift-notice.js.map +1 -0
  30. package/dist/components/assets/asset-meta-label.d.ts +2 -1
  31. package/dist/components/assets/asset-meta-label.js +4 -2
  32. package/dist/components/assets/asset-meta-label.js.map +1 -1
  33. package/dist/components/assets/asset-metadata.d.ts +33 -13
  34. package/dist/components/assets/asset-metadata.js +46 -22
  35. package/dist/components/assets/asset-metadata.js.map +1 -1
  36. package/dist/components/assets/asset-search.d.ts +6 -2
  37. package/dist/components/assets/asset-search.js +15 -5
  38. package/dist/components/assets/asset-search.js.map +1 -1
  39. package/dist/components/assets/assets-collection-view-source.d.ts +5 -3
  40. package/dist/components/assets/assets-collection-view-source.js +7 -5
  41. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  42. package/dist/components/builder/canvas-rebase.d.ts +36 -12
  43. package/dist/components/builder/canvas-rebase.js +13 -3
  44. package/dist/components/builder/canvas-rebase.js.map +1 -1
  45. package/dist/components/builder/canvas-render-context.js +5 -4
  46. package/dist/components/builder/canvas-render-context.js.map +1 -1
  47. package/dist/components/builder/composition-field-editor.d.ts +12 -6
  48. package/dist/components/builder/composition-field-editor.js +6 -5
  49. package/dist/components/builder/composition-field-editor.js.map +1 -1
  50. package/dist/components/cms/asset-picker-core.d.ts +2 -2
  51. package/dist/components/cms/asset-picker-core.js +2 -2
  52. package/dist/components/cms/asset-picker-core.js.map +1 -1
  53. package/dist/components/cms/asset-picker.js +3 -1
  54. package/dist/components/cms/asset-picker.js.map +1 -1
  55. package/dist/components/cms/canvas-commit.d.ts +6 -0
  56. package/dist/components/cms/canvas-commit.js +5 -1
  57. package/dist/components/cms/canvas-commit.js.map +1 -1
  58. package/dist/components/cms/cms-store.d.ts +24 -8
  59. package/dist/components/cms/cms-store.js +21 -14
  60. package/dist/components/cms/cms-store.js.map +1 -1
  61. package/dist/components/cms/collection-table.js +4 -11
  62. package/dist/components/cms/collection-table.js.map +1 -1
  63. package/dist/components/cms/detail-editor.js +2 -2
  64. package/dist/components/cms/detail-editor.js.map +1 -1
  65. package/dist/components/cms/draft-commit.d.ts +16 -3
  66. package/dist/components/cms/draft-commit.js +3 -2
  67. package/dist/components/cms/draft-commit.js.map +1 -1
  68. package/dist/components/cms/use-document-draft.d.ts +107 -10
  69. package/dist/components/cms/use-document-draft.js +157 -42
  70. package/dist/components/cms/use-document-draft.js.map +1 -1
  71. package/dist/components/collection-view/cms-collection-view-source.js +6 -4
  72. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  73. package/dist/components/components/transfer-dialog.js +6 -4
  74. package/dist/components/components/transfer-dialog.js.map +1 -1
  75. package/dist/lib/ai/chat-state.d.ts +7 -0
  76. package/dist/lib/ai/chat-state.js.map +1 -1
  77. package/dist/lib/ai/tools.d.ts +8 -0
  78. package/dist/lib/ai/tools.js +4 -0
  79. package/dist/lib/ai/tools.js.map +1 -1
  80. package/dist/lib/cms-views/view-model.d.ts +15 -8
  81. package/dist/lib/cms-views/view-model.js +19 -5
  82. package/dist/lib/cms-views/view-model.js.map +1 -1
  83. package/dist/lib/collection-view/__fixtures__/document-writes.js +1 -0
  84. package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -1
  85. package/dist/lib/collection-view/collection-view-source.d.ts +18 -7
  86. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  87. package/dist/lib/data/collection-content.js +5 -0
  88. package/dist/lib/data/collection-content.js.map +1 -1
  89. package/dist/lib/engine-version.d.ts +1 -1
  90. package/dist/lib/engine-version.js +1 -1
  91. package/dist/lib/engine-version.js.map +1 -1
  92. package/dist/lib/i18n/messages.de.js +15 -1
  93. package/dist/lib/i18n/messages.de.js.map +1 -1
  94. package/dist/lib/i18n/messages.en.d.ts +13 -0
  95. package/dist/lib/i18n/messages.en.js +16 -1
  96. package/dist/lib/i18n/messages.en.js.map +1 -1
  97. package/dist/lib/operations/cms-persistence.d.ts +119 -35
  98. package/dist/lib/operations/cms-persistence.js +441 -91
  99. package/dist/lib/operations/cms-persistence.js.map +1 -1
  100. package/dist/lib/persistence/convex-adapter.js +80 -8
  101. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  102. package/dist/lib/persona/labels.js +2 -0
  103. package/dist/lib/persona/labels.js.map +1 -1
  104. package/package.json +4 -3
@@ -1,5 +1,23 @@
1
- import { applyDocumentPatches, documentKey, formatValuePath, selfTypeDocument, touchedPaths, valuePathsOverlap, } from '@elytracms/core/cms-core';
1
+ import { applyDocumentPatches, documentKey, formatValuePath, isValuePathPrefix, readValueAt, selfTypeDocument, semanticValueEqual, touchedPaths, valuePathsOverlap, } from '@elytracms/core/cms-core';
2
2
  import { createPersistQueue } from './persist-queue.js';
3
+ /** How many own writes a document's ledger keeps (an older base just stops lifting — a conflict, never a borrow). */
4
+ const OWN_WRITES_KEPT = 256;
5
+ /**
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.
11
+ */
12
+ function provenanceAt(provenance, path) {
13
+ let at = provenance.floor;
14
+ for (const write of provenance.writes) {
15
+ if (path === undefined || write.paths.some((covered) => isValuePathPrefix(covered, path))) {
16
+ at = earlierBase(at, write.base);
17
+ }
18
+ }
19
+ return at;
20
+ }
3
21
  /** A patch's identity within one call: op + target path (no two value writes of one path share a run). */
4
22
  function targetKey(op, path) {
5
23
  return `${op} ${formatValuePath(path)}`;
@@ -21,6 +39,43 @@ function isValueWrite(patch) {
21
39
  function samePath(a, b) {
22
40
  return a.length === b.length && valuePathsOverlap(a, b);
23
41
  }
42
+ /** The earlier of two authoring bases (`undefined` — the provisional view — is the earliest of all). */
43
+ function earlierBase(a, b) {
44
+ return a === undefined || b === undefined ? undefined : Math.min(a, b);
45
+ }
46
+ /**
47
+ * An import's whole-document write (EC-595 A2 → EC-647 → EC-673): a row the session SHOWS
48
+ * and holds a revision for is REPLACED at the revision of the row it showed (the one the
49
+ * import was judged against); any other row is CREATED (insert-if-absent).
50
+ *
51
+ * EC-673: the decision is the SHOWN row's, never the tracked revision alone. A document
52
+ * deleted earlier in the session is not shown, but its entry can still answer a
53
+ * revision — the editor's Delete and a colleague's delete keep the deleted lifetime's
54
+ * base, and the list's bulk Delete never tells the persister at all. Deciding on that
55
+ * revision sent the import down `replace`: queued on a deleted entry and skipped, or
56
+ * refused as unauthored with the deleted lifetime's values shown again. A replace with no
57
+ * shown row could never be right anyway — it names no revision it was authored against.
58
+ */
59
+ export function importWrite(persister, document, shown) {
60
+ if (shown && persister.revisionOf(document) !== undefined) {
61
+ persister.replace(document, persister.revisionOfView(shown));
62
+ }
63
+ else
64
+ persister.create(document);
65
+ }
66
+ /** A write against a document this session no longer holds (deleted here or elsewhere). */
67
+ function goneError(ref) {
68
+ return { code: 'not-found', message: 'The document is gone.', entity: 'cms-document', key: documentKey(ref) };
69
+ }
70
+ /** The refusal of a write that names no revision it was authored against (EC-647). */
71
+ function unauthoredError(ref) {
72
+ return {
73
+ code: 'conflict',
74
+ key: documentKey(ref),
75
+ message: 'A change was not saved: it does not say which revision of the document it was made on, so it could overwrite a change made since (EC-647).',
76
+ messageRef: { key: 'cms.conflict.unauthored' },
77
+ };
78
+ }
24
79
  export function createCmsPersister(options) {
25
80
  const queue = createPersistQueue((error) => {
26
81
  options.onError({
@@ -29,6 +84,10 @@ export function createCmsPersister(options) {
29
84
  });
30
85
  });
31
86
  const entries = new Map();
87
+ /** EC-647 / EC-667: every document value this persister showed or tracked → what it was authored against. */
88
+ const shown = new WeakMap();
89
+ /** A server row's document: every position at its revision. */
90
+ const registerRow = (row) => shown.set(row.document, { floor: row.revision, writes: [] });
32
91
  function entryOf(ref) {
33
92
  const key = documentKey(ref);
34
93
  let entry = entries.get(key);
@@ -39,11 +98,16 @@ export function createCmsPersister(options) {
39
98
  provisional: undefined,
40
99
  queue: [],
41
100
  inFlight: [],
101
+ inFlightReplace: undefined,
42
102
  busy: false,
43
103
  conflicts: [],
104
+ conflictBases: new Map(),
44
105
  held: undefined,
45
106
  scheduled: false,
46
107
  deleted: false,
108
+ creating: false,
109
+ created: undefined,
110
+ own: [],
47
111
  };
48
112
  entries.set(key, entry);
49
113
  }
@@ -56,26 +120,58 @@ export function createCmsPersister(options) {
56
120
  return doc;
57
121
  return selfTypeDocument(doc, collection, options.lookupManifest ? { lookupManifest: options.lookupManifest } : {});
58
122
  }
59
- /** The session's view: base ⊕ unresolved mine ⊕ in-flight ⊕ queued (the pure merge, no log). */
123
+ /** The queued patches (not whole-document writes). */
124
+ function queuedPatches(entry) {
125
+ return entry.queue.filter((queued) => queued.kind === 'patch');
126
+ }
127
+ /**
128
+ * 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.
132
+ */
60
133
  function display(entry) {
61
134
  const baseDocument = entry.base?.document ?? entry.provisional;
62
135
  if (entry.deleted || !baseDocument)
63
136
  return undefined;
64
- const patches = [
65
- ...entry.conflicts.map((conflict) => conflict.patch),
66
- ...entry.inFlight.map((queued) => queued.patch),
67
- ...entry.queue.map((queued) => queued.patch),
137
+ let document = baseDocument;
138
+ 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
+ }));
144
+ const pending = [
145
+ ...entry.inFlight,
146
+ ...(entry.inFlightReplace ? [entry.inFlightReplace] : []),
147
+ ...entry.queue,
68
148
  ];
69
- if (patches.length === 0)
70
- return baseDocument;
71
- return applyDocumentPatches({ document: baseDocument, patches, since: [] }).document;
149
+ for (const queued of pending) {
150
+ if (queued.kind === 'replace') {
151
+ // The snapshot was authored over everything before it (the editor's draft): every
152
+ // position now shows content authored at the SNAPSHOT's revision (EC-667).
153
+ patches = [];
154
+ writes = [];
155
+ document = queued.document;
156
+ floor = queued.base;
157
+ }
158
+ else {
159
+ patches.push(queued.patch);
160
+ writes.push({ paths: touchedPaths(queued.patch), base: queued.base });
161
+ }
162
+ }
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;
72
168
  }
73
169
  function emitDocument(entry) {
74
170
  if (entry.deleted)
75
171
  return;
76
- const shown = display(entry);
77
- if (shown)
78
- options.onDocument?.(entry.ref, shown);
172
+ const view = display(entry);
173
+ if (view)
174
+ options.onDocument?.(entry.ref, view);
79
175
  }
80
176
  function emitConflicts(entry) {
81
177
  options.onConflicts?.(entry.ref, [...entry.conflicts]);
@@ -85,6 +181,7 @@ export function createCmsPersister(options) {
85
181
  if (entry.base && record.revision < entry.base.revision)
86
182
  return;
87
183
  entry.base = record;
184
+ registerRow(record);
88
185
  options.onRecord?.(record);
89
186
  emitDocument(entry);
90
187
  }
@@ -101,13 +198,37 @@ export function createCmsPersister(options) {
101
198
  if (!entry.base || held.revision > entry.base.revision)
102
199
  adopt(entry, held);
103
200
  }
201
+ function settleAll(queued, outcome) {
202
+ for (const item of queued)
203
+ for (const settle of item.settle)
204
+ settle(outcome);
205
+ }
206
+ /** Drop every queued write, settling its waiters (the document is gone, or the writes cannot be based). */
207
+ function dropQueued(entry, outcome, which = () => true) {
208
+ const dropped = entry.queue.filter(which);
209
+ entry.queue = entry.queue.filter((queued) => !which(queued));
210
+ settleAll(dropped.filter((queued) => queued.kind === 'patch'), outcome);
211
+ }
104
212
  function markGone(entry) {
105
213
  if (entry.deleted)
106
214
  return;
107
215
  entry.deleted = true;
108
- entry.queue = [];
216
+ dropQueued(entry, { status: 'failed', error: goneError(entry.ref) });
109
217
  options.onDocument?.(entry.ref, undefined);
110
218
  }
219
+ /**
220
+ * EC-673 — writes queued on a document this session no longer holds (one put back by a
221
+ * failed send after a delete, a "keep mine" on a document that went away): refused and
222
+ * REPORTED, never left in the queue for the drain to skip — nor sent into a later
223
+ * re-create of the id, whose first revision the own-write ledger would lift them across.
224
+ */
225
+ function refuseQueued(entry) {
226
+ if (entry.queue.length === 0)
227
+ return;
228
+ const error = goneError(entry.ref);
229
+ dropQueued(entry, { status: 'failed', error });
230
+ options.onError(error);
231
+ }
111
232
  function schedule(entry) {
112
233
  if (entry.scheduled)
113
234
  return;
@@ -118,6 +239,126 @@ export function createCmsPersister(options) {
118
239
  adoptHeld(entry);
119
240
  });
120
241
  }
242
+ /** EC-647: record one of this persister's acknowledged writes (the own-write ledger). */
243
+ function recordOwn(entry, write) {
244
+ entry.own.push(write);
245
+ if (entry.own.length > OWN_WRITES_KEPT)
246
+ entry.own.splice(0, entry.own.length - OWN_WRITES_KEPT);
247
+ }
248
+ /**
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.
253
+ */
254
+ function lift(entry, base, paths) {
255
+ let lifted = base;
256
+ for (;;) {
257
+ if (entry.own.some((write) => write.revision === lifted + 1)) {
258
+ lifted += 1;
259
+ continue;
260
+ }
261
+ const covering = paths
262
+ ? entry.own.find((write) => write.revision > lifted &&
263
+ write.paths !== undefined &&
264
+ write.sentBase !== undefined &&
265
+ write.sentBase <= lifted &&
266
+ paths.every((path) => write.paths.some((covered) => isValuePathPrefix(covered, path))))
267
+ : undefined;
268
+ if (!covering)
269
+ return lifted;
270
+ lifted = covering.revision;
271
+ }
272
+ }
273
+ /**
274
+ * EC-647 — the base a write is QUEUED with: the revision it was authored against, or
275
+ * the earlier base of an own unacknowledged write on the same positions (the view
276
+ * showed mine there, not whatever may sit underneath it).
277
+ */
278
+ function authoredBase(entry, patch, authored) {
279
+ let base = authored;
280
+ const overlaps = (other) => patch === undefined || patchesOverlap(other, patch);
281
+ for (const queued of [...entry.inFlight, ...entry.queue]) {
282
+ if (queued.kind === 'replace' || overlaps(queued.patch))
283
+ base = earlierBase(base, queued.base);
284
+ }
285
+ if (entry.inFlightReplace)
286
+ base = earlierBase(base, entry.inFlightReplace.base);
287
+ for (const conflict of entry.conflicts) {
288
+ if (overlaps(conflict.patch))
289
+ base = earlierBase(base, entry.conflictBases.get(conflict.id));
290
+ }
291
+ return base;
292
+ }
293
+ /** Whether a write may be authored on a provisional view (our own create, or an untracked document). */
294
+ function provisionalAllowed(entry) {
295
+ return entry.creating || entry.created !== undefined || !entry.base;
296
+ }
297
+ /** Our own create landed (or failed): base every write authored on its provisional view. */
298
+ function baseOnCreated(entry) {
299
+ if (entry.created === undefined)
300
+ return;
301
+ for (const queued of entry.queue)
302
+ if (queued.base === undefined)
303
+ queued.base = entry.created;
304
+ }
305
+ /**
306
+ * EC-647 — writes authored on the provisional view of a document this session holds no
307
+ * revision for (a staged transfer): read it; when it still EQUALS the view they were
308
+ * authored on, that read's revision is theirs — otherwise each is refused as a
309
+ * conflict on its position (theirs = the read). Never "whatever revision the read
310
+ * returns". `false` when the read failed (the writes stay queued, the error shows).
311
+ */
312
+ async function baseOnRead(entry, operations) {
313
+ if (!entry.queue.some((queued) => queued.kind === 'patch' && queued.base === undefined))
314
+ return true;
315
+ if (entry.created !== undefined) {
316
+ baseOnCreated(entry);
317
+ return true;
318
+ }
319
+ if (entry.base) {
320
+ // A tracked document this session did not create: commit already refused these.
321
+ const error = unauthoredError(entry.ref);
322
+ dropQueued(entry, { status: 'failed', error }, (queued) => queued.kind === 'patch' && queued.base === undefined);
323
+ options.onError(error);
324
+ return true;
325
+ }
326
+ const read = await operations.documents.get({ projectId: options.projectId, ref: entry.ref });
327
+ if (!read.ok) {
328
+ options.onError(read.error);
329
+ return false;
330
+ }
331
+ const record = read.value;
332
+ const authoredOn = entry.provisional;
333
+ if (authoredOn && semanticValueEqual(record.document, authoredOn)) {
334
+ for (const queued of entry.queue)
335
+ if (queued.kind === 'patch' && queued.base === undefined)
336
+ queued.base = record.revision;
337
+ }
338
+ else {
339
+ const refused = [];
340
+ for (const queued of queuedPatches(entry)) {
341
+ if (queued.base !== undefined)
342
+ continue;
343
+ const path = patchTarget(queued.patch);
344
+ const conflict = upsertConflict(entry, {
345
+ patch: queued.patch,
346
+ reason: 'conflict',
347
+ path,
348
+ current: readValueAt(record.document, path),
349
+ detail: 'the document changed since the view this edit was made on',
350
+ }, record.revision, record.revision);
351
+ refused.push(conflict);
352
+ for (const settle of queued.settle)
353
+ settle({ status: 'refused', conflicts: [conflict] });
354
+ }
355
+ entry.queue = entry.queue.filter((queued) => !(queued.kind === 'patch' && queued.base === undefined));
356
+ if (refused.length > 0)
357
+ emitConflicts(entry);
358
+ }
359
+ adopt(entry, record);
360
+ return true;
361
+ }
121
362
  /** Who changed a refused path — the newest patch-log row in (base, current] touching it. Best-effort. */
122
363
  async function resolveActors(entry, sentBase, refused) {
123
364
  try {
@@ -149,25 +390,81 @@ export function createCmsPersister(options) {
149
390
  // The actor is decoration on a conflict the user can already resolve.
150
391
  }
151
392
  }
152
- /** Send the queue, one run of equal base at a time, until it is empty or a write fails. */
393
+ /** Send one queued whole-document write at the revision its snapshot was authored against. `false` → stop draining. */
394
+ async function sendReplace(entry, operations, write) {
395
+ const precondition = write.base !== undefined
396
+ ? { revision: lift(entry, write.base, undefined) }
397
+ : entry.created !== undefined
398
+ ? { revision: lift(entry, entry.created, undefined) }
399
+ : !entry.base
400
+ ? { ifAbsent: true }
401
+ : undefined;
402
+ entry.queue.shift();
403
+ if (!precondition) {
404
+ // A tracked document this session did not create, written whole from a view with no revision.
405
+ options.onError(unauthoredError(entry.ref));
406
+ emitDocument(entry);
407
+ return true;
408
+ }
409
+ entry.inFlightReplace = write;
410
+ entry.busy = true;
411
+ try {
412
+ const result = await operations.documents.replace({
413
+ projectId: options.projectId,
414
+ document: write.document,
415
+ precondition,
416
+ });
417
+ entry.inFlightReplace = undefined;
418
+ if (result.ok) {
419
+ recordOwn(entry, {
420
+ revision: result.value.revision,
421
+ sentBase: 'revision' in precondition ? precondition.revision : undefined,
422
+ paths: undefined,
423
+ });
424
+ adopt(entry, result.value);
425
+ }
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);
433
+ }
434
+ }
435
+ finally {
436
+ entry.inFlightReplace = undefined;
437
+ entry.busy = false;
438
+ adoptHeld(entry);
439
+ }
440
+ return true;
441
+ }
442
+ /** Send the queue — runs of equal (lifted) base, whole-document writes in their place — until it is empty or a write fails. */
153
443
  async function drain(entry) {
154
- while (entry.queue.length > 0 && !entry.deleted) {
444
+ while (entry.queue.length > 0) {
445
+ if (entry.deleted) {
446
+ refuseQueued(entry);
447
+ return;
448
+ }
155
449
  const operations = await options.getOperations();
156
- if (!entry.base) {
157
- // Untracked (a document the session never hydrated a revision for): read it.
158
- // The user's view of it is the session's, so this is the narrowest window left.
159
- const read = await operations.documents.get({ projectId: options.projectId, ref: entry.ref });
160
- if (!read.ok) {
161
- options.onError(read.error);
162
- return;
163
- }
164
- entry.base = read.value;
450
+ if (!(await baseOnRead(entry, operations)))
451
+ return;
452
+ const head = entry.queue[0];
453
+ if (!head)
454
+ return;
455
+ if (head.kind === 'replace') {
456
+ await sendReplace(entry, operations, head);
457
+ continue;
165
458
  }
166
- const resolveBase = (queued) => queued.base ?? entry.base.revision;
167
- const first = resolveBase(entry.queue[0]);
459
+ const sendBase = (queued) => lift(entry, queued.base, touchedPaths(queued.patch));
460
+ const first = sendBase(head);
168
461
  let end = 1;
169
- while (end < entry.queue.length && resolveBase(entry.queue[end]) === first)
462
+ while (end < entry.queue.length) {
463
+ const next = entry.queue[end];
464
+ if (next.kind !== 'patch' || sendBase(next) !== first)
465
+ break;
170
466
  end += 1;
467
+ }
171
468
  const run = entry.queue.splice(0, end);
172
469
  entry.inFlight = run;
173
470
  const result = await operations.documents.patch({
@@ -178,7 +475,11 @@ export function createCmsPersister(options) {
178
475
  });
179
476
  entry.inFlight = [];
180
477
  if (result.ok) {
181
- rebaseCovered(entry, run, result.value.revision);
478
+ recordOwn(entry, {
479
+ revision: result.value.revision,
480
+ sentBase: first,
481
+ paths: run.flatMap((queued) => touchedPaths(queued.patch)),
482
+ });
182
483
  adopt(entry, result.value);
183
484
  adoptHeld(entry);
184
485
  settleAll(run, { status: 'applied', record: result.value });
@@ -199,12 +500,20 @@ export function createCmsPersister(options) {
199
500
  continue;
200
501
  }
201
502
  // MINE is what the user wrote (the queued patch), not the canonicalised echo.
202
- const conflict = upsertConflict(entry, { ...refused, patch: queued.patch }, current.revision);
503
+ const conflict = upsertConflict(entry, { ...refused, patch: queued.patch }, current.revision, queued.base);
203
504
  conflicts.push(conflict);
204
505
  for (const settle of queued.settle)
205
506
  settle({ status: 'refused', conflicts: [conflict] });
206
507
  }
207
- rebaseCovered(entry, applied, current.revision);
508
+ // A partially applied run IS a write of ours (the row it returns is after it);
509
+ // a wholly refused one wrote nothing — its row is someone else's revision.
510
+ if (applied.length > 0) {
511
+ recordOwn(entry, {
512
+ revision: current.revision,
513
+ sentBase: first,
514
+ paths: applied.flatMap((queued) => touchedPaths(queued.patch)),
515
+ });
516
+ }
208
517
  adopt(entry, current);
209
518
  adoptHeld(entry);
210
519
  emitConflicts(entry);
@@ -229,23 +538,7 @@ export function createCmsPersister(options) {
229
538
  return;
230
539
  }
231
540
  }
232
- function settleAll(queued, outcome) {
233
- for (const item of queued)
234
- for (const settle of item.settle)
235
- settle(outcome);
236
- }
237
- /** Queued patches on a path our own write just covered move their base to that write's revision. */
238
- function rebaseCovered(entry, written, revision) {
239
- if (written.length === 0)
240
- return;
241
- for (const queued of entry.queue) {
242
- if (queued.base !== undefined && queued.base >= revision)
243
- continue;
244
- if (written.some((done) => patchesOverlap(done.patch, queued.patch)))
245
- queued.base = revision;
246
- }
247
- }
248
- function upsertConflict(entry, refused, revision) {
541
+ function upsertConflict(entry, refused, revision, base) {
249
542
  const conflict = {
250
543
  id: conflictId(entry.ref, refused.path),
251
544
  ref: entry.ref,
@@ -256,6 +549,7 @@ export function createCmsPersister(options) {
256
549
  revision,
257
550
  };
258
551
  entry.conflicts = [...entry.conflicts.filter((c) => c.id !== conflict.id), conflict];
552
+ entry.conflictBases.set(conflict.id, base);
259
553
  return conflict;
260
554
  }
261
555
  function command(entry, run) {
@@ -276,6 +570,7 @@ export function createCmsPersister(options) {
276
570
  return {
277
571
  track(row) {
278
572
  const entry = entryOf(row.document);
573
+ registerRow(row);
279
574
  if (!entry.base || row.revision > entry.base.revision)
280
575
  entry.base = row;
281
576
  },
@@ -284,6 +579,10 @@ export function createCmsPersister(options) {
284
579
  const entry = entries.get(documentKey(ref));
285
580
  return entry ? display(entry) : undefined;
286
581
  },
582
+ revisionOfView: (view, path) => {
583
+ const provenance = shown.get(view);
584
+ return provenance ? provenanceAt(provenance, path) : undefined;
585
+ },
287
586
  adoptRecord(record) {
288
587
  const entry = entryOf(record.document);
289
588
  if (entry.deleted)
@@ -291,22 +590,39 @@ export function createCmsPersister(options) {
291
590
  if (!entry.base || record.revision > entry.base.revision)
292
591
  adopt(entry, record);
293
592
  },
294
- commit(ref, patches, view, bases) {
593
+ commit(ref, patches, bases, view) {
295
594
  const entry = entryOf(ref);
296
- if (patches.length === 0 || entry.deleted) {
297
- return Promise.resolve({
298
- status: 'failed',
299
- error: { code: 'not-found', message: 'The document is gone.', entity: 'cms-document', key: documentKey(ref) },
300
- });
595
+ if (patches.length === 0)
596
+ return Promise.resolve({ status: 'failed', error: goneError(ref) });
597
+ if (entry.deleted) {
598
+ // EC-673: a write that cannot be sent is reported, not only settled — the editor's
599
+ // committer and the transfer dialog do not read the outcome.
600
+ const error = goneError(ref);
601
+ options.onError(error);
602
+ return Promise.resolve({ status: 'failed', error });
603
+ }
604
+ if (!Array.isArray(bases)) {
605
+ // EC-647: a writer that names no authoring revision at all — never given the tracked one.
606
+ const error = unauthoredError(ref);
607
+ options.onError(error);
608
+ return Promise.resolve({ status: 'failed', error });
301
609
  }
302
610
  if (!entry.base && !entry.provisional && view)
303
611
  entry.provisional = view;
304
612
  let conflictsChanged = false;
613
+ let unauthored = false;
305
614
  const waits = [];
306
615
  for (const [index, patch] of patches.entries()) {
307
- const madeAt = bases?.[index] ?? entry.base?.revision;
616
+ const authored = bases[index];
308
617
  let settle;
309
618
  waits.push(new Promise((resolve) => (settle = resolve)));
619
+ if (authored === undefined && !provisionalAllowed(entry)) {
620
+ // EC-647: authored on a view with no revision, on a document this session
621
+ // tracks and did not create — it cannot be checked, so it is not sent.
622
+ unauthored = true;
623
+ settle({ status: 'failed', error: unauthoredError(ref) });
624
+ continue;
625
+ }
310
626
  // A field with an unresolved conflict: the new value replaces MINE, unsent —
311
627
  // the user decides (keep mine sends it), never an implicit overwrite of theirs.
312
628
  const open = entry.conflicts.find((conflict) => patchesOverlap(conflict.patch, patch));
@@ -317,18 +633,33 @@ export function createCmsPersister(options) {
317
633
  settle({ status: 'refused', conflicts: [replaced] });
318
634
  continue;
319
635
  }
636
+ const base = authoredBase(entry, patch, authored);
320
637
  // A later value write of the same field supersedes an unsent one (keeping the
321
- // EARLIER base — the user never saw a foreign value of a field they were typing in).
638
+ // EARLIER base — the user never saw a foreign value of a field they were typing
639
+ // in). Only after the last queued whole-document write: one before it is sent
640
+ // before the snapshot that was authored over it.
322
641
  if (isValueWrite(patch)) {
323
- const pending = entry.queue.findIndex((queued) => isValueWrite(queued.patch) && samePath(queued.patch.path, patch.path));
642
+ let after = 0;
643
+ entry.queue.forEach((queued, at) => {
644
+ if (queued.kind === 'replace')
645
+ after = at + 1;
646
+ });
647
+ const pending = entry.queue.findIndex((queued, at) => at >= after && queued.kind === 'patch' && isValueWrite(queued.patch) && samePath(queued.patch.path, patch.path));
324
648
  if (pending >= 0) {
325
649
  const superseded = entry.queue[pending];
326
- entry.queue[pending] = { patch, base: superseded.base, settle: [...superseded.settle, settle] };
650
+ entry.queue[pending] = {
651
+ kind: 'patch',
652
+ patch,
653
+ base: earlierBase(superseded.base, base),
654
+ settle: [...superseded.settle, settle],
655
+ };
327
656
  continue;
328
657
  }
329
658
  }
330
- entry.queue.push({ patch, base: madeAt, settle: [settle] });
659
+ entry.queue.push({ kind: 'patch', patch, base, settle: [settle] });
331
660
  }
661
+ if (unauthored)
662
+ options.onError(unauthoredError(ref));
332
663
  emitDocument(entry);
333
664
  if (conflictsChanged)
334
665
  emitConflicts(entry);
@@ -347,7 +678,22 @@ export function createCmsPersister(options) {
347
678
  create(document) {
348
679
  const doc = canonical(document);
349
680
  const entry = entryOf(doc);
681
+ // A fresh document: nothing of an earlier incarnation of this ref carries over —
682
+ // EC-673: its queued writes (refused and reported, never sent into this one) and its
683
+ // open conflicts (a colleague's delete keeps them; their "mine" is not laid over this one).
684
+ if (entry.deleted) {
685
+ refuseQueued(entry);
686
+ if (entry.conflicts.length > 0) {
687
+ entry.conflicts = [];
688
+ entry.conflictBases.clear();
689
+ emitConflicts(entry);
690
+ }
691
+ }
350
692
  entry.deleted = false;
693
+ entry.base = undefined;
694
+ entry.own = [];
695
+ entry.created = undefined;
696
+ entry.creating = true;
351
697
  // Optimistic: the session shows the document at once; the base is the created row.
352
698
  entry.provisional = doc;
353
699
  options.onDocument?.(entry.ref, doc);
@@ -360,12 +706,21 @@ export function createCmsPersister(options) {
360
706
  document: doc,
361
707
  precondition: { ifAbsent: true },
362
708
  });
363
- if (result.ok)
709
+ if (result.ok) {
710
+ entry.created = result.value.revision;
711
+ recordOwn(entry, { revision: result.value.revision, sentBase: undefined, paths: undefined });
712
+ baseOnCreated(entry);
364
713
  adopt(entry, result.value);
365
- else
714
+ }
715
+ else {
366
716
  options.onError(result.error);
717
+ // EC-647: the writes authored on the document we meant to create have no
718
+ // 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);
720
+ }
367
721
  }
368
722
  finally {
723
+ entry.creating = false;
369
724
  entry.busy = false;
370
725
  adoptHeld(entry);
371
726
  }
@@ -373,41 +728,31 @@ export function createCmsPersister(options) {
373
728
  await drain(entry);
374
729
  });
375
730
  },
376
- replace(document) {
731
+ replace(document, revision) {
377
732
  const doc = canonical(document);
378
733
  const entry = entryOf(doc);
379
- if (!entry.base)
734
+ if (entry.deleted) {
735
+ // EC-673: a document this session no longer holds has nothing to replace — refused
736
+ // and reported here, never queued for a drain that skips it (or a re-create of the
737
+ // id that would send it). A new document of the id is a `create`.
738
+ options.onError(goneError(entry.ref));
739
+ return;
740
+ }
741
+ if (!entry.base && !entry.provisional)
380
742
  entry.provisional = doc;
381
- options.onDocument?.(entry.ref, doc);
382
- queue.push(async () => {
383
- await drain(entry);
384
- const operations = await options.getOperations();
385
- entry.busy = true;
386
- try {
387
- const result = await operations.documents.replace({
388
- projectId: options.projectId,
389
- document: doc,
390
- precondition: entry.base ? { revision: entry.base.revision } : { ifAbsent: true },
391
- });
392
- if (result.ok)
393
- adopt(entry, result.value);
394
- else {
395
- if (result.error.code === 'conflict' && 'current' in result.error && result.error.current) {
396
- adopt(entry, result.error.current);
397
- }
398
- options.onError(result.error);
399
- }
400
- }
401
- finally {
402
- entry.busy = false;
403
- adoptHeld(entry);
404
- }
405
- });
743
+ // Queued in its place: the patches committed before it are sent first (the snapshot
744
+ // holds them), the ones committed after it are sent after it (it does not). EC-647
745
+ // (Codex F10): one with no authoring revision on a document this session tracks and
746
+ // 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) });
748
+ emitDocument(entry);
749
+ schedule(entry);
406
750
  },
407
751
  deleteDocument(ref) {
408
752
  const entry = entryOf(ref);
409
- entry.queue = [];
753
+ dropQueued(entry, { status: 'failed', error: goneError(ref) });
410
754
  entry.conflicts = [];
755
+ entry.conflictBases.clear();
411
756
  entry.deleted = true;
412
757
  emitConflicts(entry);
413
758
  command(entry, (operations) => operations.documents.delete({ projectId: options.projectId, ref }));
@@ -420,8 +765,10 @@ export function createCmsPersister(options) {
420
765
  entry.busy = true;
421
766
  try {
422
767
  const result = await operations.documents.unpublish({ projectId: options.projectId, ref });
423
- if (result.ok)
768
+ if (result.ok) {
769
+ recordOwn(entry, { revision: result.value.revision, sentBase: undefined, paths: [] });
424
770
  adopt(entry, result.value);
771
+ }
425
772
  else
426
773
  options.onError(result.error);
427
774
  }
@@ -458,9 +805,12 @@ export function createCmsPersister(options) {
458
805
  if (!conflict)
459
806
  return;
460
807
  entry.conflicts = entry.conflicts.filter((c) => c !== conflict);
808
+ entry.conflictBases.delete(conflict.id);
461
809
  if (choice === 'mine') {
462
- // Keep mine: re-send at the CURRENT base — a visible, explicit second write.
463
- entry.queue.push({ patch: conflict.patch, base: entry.base?.revision, settle: [] });
810
+ // Keep mine: re-send at the CURRENT base — a visible, explicit second write, and
811
+ // the one deliberate "at the current base" write (EC-647): the user chose it
812
+ // after seeing theirs.
813
+ entry.queue.push({ kind: 'patch', patch: conflict.patch, base: entry.base?.revision, settle: [] });
464
814
  schedule(entry);
465
815
  }
466
816
  emitDocument(entry);