@elytracms/studio 0.0.96 → 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 (37) hide show
  1. package/convex/cms.ts +153 -13
  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/dist/components/cms/cms-store.d.ts +4 -1
  15. package/dist/components/cms/cms-store.js +11 -13
  16. package/dist/components/cms/cms-store.js.map +1 -1
  17. package/dist/components/cms/use-document-draft.d.ts +41 -2
  18. package/dist/components/cms/use-document-draft.js +60 -18
  19. package/dist/components/cms/use-document-draft.js.map +1 -1
  20. package/dist/lib/collection-view/collection-view-source.d.ts +5 -2
  21. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  22. package/dist/lib/engine-version.d.ts +1 -1
  23. package/dist/lib/engine-version.js +1 -1
  24. package/dist/lib/engine-version.js.map +1 -1
  25. package/dist/lib/i18n/messages.de.js +2 -0
  26. package/dist/lib/i18n/messages.de.js.map +1 -1
  27. package/dist/lib/i18n/messages.en.d.ts +2 -0
  28. package/dist/lib/i18n/messages.en.js +2 -0
  29. package/dist/lib/i18n/messages.en.js.map +1 -1
  30. package/dist/lib/operations/cms-persistence.d.ts +41 -2
  31. package/dist/lib/operations/cms-persistence.js +111 -24
  32. package/dist/lib/operations/cms-persistence.js.map +1 -1
  33. package/dist/lib/persistence/convex-adapter.js +27 -1
  34. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  35. package/dist/lib/persona/labels.js +1 -0
  36. package/dist/lib/persona/labels.js.map +1 -1
  37. package/package.json +4 -3
@@ -36,6 +36,19 @@ 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 writes covering it);
46
+ * `revisionOfView(view)` — a whole-document writer — the EARLIEST of all. The record is
47
+ * immutable, so it outlives the pending write: an edit started on a view that showed a
48
+ * snapshot keeps the snapshot's revision after the snapshot is refused or fails
49
+ * (before EC-667 the composite was named by the base's revision — a colleague's change
50
+ * adopted underneath a pending snapshot lent the edit its revision, and the edit,
51
+ * authored on the old content, overwrote it).
39
52
  * - **A position under one of this session's own unacknowledged writes** (queued, in
40
53
  * flight, or an open conflict's "mine") was authored against THAT write's base: the
41
54
  * view showed mine there, not a colleague's change that may sit underneath it. The
@@ -102,7 +115,13 @@ export type CommitOutcome = {
102
115
  export interface CmsPersister {
103
116
  /** Adopt a server row as this session's base for its document (hydration, a create result). */
104
117
  track(row: TrackedRow): void;
105
- /** The revision the session's view of a document is at now (`undefined` = untracked). */
118
+ /**
119
+ * The revision of the server row the session last adopted for a document (`undefined` =
120
+ * untracked). A document deleted since still answers its removed lifetime's last
121
+ * revision (EC-673): a publish conditioned on it is refused, never sent unconditionally
122
+ * into a re-creation this session never showed — so whether to create or replace is
123
+ * never decided on it alone ({@link importWrite} decides on the row the session shows).
124
+ */
106
125
  revisionOf(ref: DocumentRef): number | undefined;
107
126
  /** The session's view of a tracked document (base ⊕ local), or `undefined`. */
108
127
  documentOf(ref: DocumentRef): CmsDocument | undefined;
@@ -113,8 +132,14 @@ export interface CmsPersister {
113
132
  * the pair cannot drift apart. `undefined` for a value the persister never showed (a
114
133
  * copy, a fixture), or a provisional view (our own create in flight, an untracked
115
134
  * document).
135
+ *
136
+ * EC-667 — per position: `path` given, the revision the content the view shows AT
137
+ * `path` was authored against (an editor's first keystroke there); without it, the
138
+ * EARLIEST authoring revision of any position the view shows — the token of a
139
+ * whole-document writer (a replace, an import). A view showing a pending write is never
140
+ * named by the base's revision alone (see the module doc).
116
141
  */
117
- revisionOfView(view: CmsDocument): number | undefined;
142
+ revisionOfView(view: CmsDocument, path?: ValuePath): number | undefined;
118
143
  /**
119
144
  * Commit patches a writer made and flush them. The session's view updates
120
145
  * synchronously (via `onDocument`); the patches persist in order through
@@ -185,4 +210,18 @@ export interface CmsPersisterOptions {
185
210
  }
186
211
  /** What the session hydrated for a document: its value and the revision it is at. */
187
212
  export type TrackedRow = Pick<CmsDocumentRecord, 'document' | 'revision'>;
213
+ /**
214
+ * An import's whole-document write (EC-595 A2 → EC-647 → EC-673): a row the session SHOWS
215
+ * and holds a revision for is REPLACED at the revision of the row it showed (the one the
216
+ * import was judged against); any other row is CREATED (insert-if-absent).
217
+ *
218
+ * EC-673: the decision is the SHOWN row's, never the tracked revision alone. A document
219
+ * deleted earlier in the session is not shown, but its entry can still answer a
220
+ * revision — the editor's Delete and a colleague's delete keep the deleted lifetime's
221
+ * base, and the list's bulk Delete never tells the persister at all. Deciding on that
222
+ * revision sent the import down `replace`: queued on a deleted entry and skipped, or
223
+ * refused as unauthored with the deleted lifetime's values shown again. A replace with no
224
+ * shown row could never be right anyway — it names no revision it was authored against.
225
+ */
226
+ export declare function importWrite(persister: Pick<CmsPersister, 'revisionOf' | 'revisionOfView' | 'replace' | 'create'>, document: CmsDocument, shown: CmsDocument | undefined): void;
188
227
  export declare function createCmsPersister(options: CmsPersisterOptions): CmsPersister;
@@ -2,6 +2,22 @@ import { applyDocumentPatches, documentKey, formatValuePath, isValuePathPrefix,
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 — 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
+ }
5
21
  /** A patch's identity within one call: op + target path (no two value writes of one path share a run). */
6
22
  function targetKey(op, path) {
7
23
  return `${op} ${formatValuePath(path)}`;
@@ -27,6 +43,30 @@ function samePath(a, b) {
27
43
  function earlierBase(a, b) {
28
44
  return a === undefined || b === undefined ? undefined : Math.min(a, b);
29
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
+ }
30
70
  /** The refusal of a write that names no revision it was authored against (EC-647). */
31
71
  function unauthoredError(ref) {
32
72
  return {
@@ -44,8 +84,10 @@ export function createCmsPersister(options) {
44
84
  });
45
85
  });
46
86
  const entries = new Map();
47
- /** EC-647: every document value this persister showed or tracked → the revision it is at. */
87
+ /** EC-647 / EC-667: every document value this persister showed or tracked → what it was authored against. */
48
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: [] });
49
91
  function entryOf(ref) {
50
92
  const key = documentKey(ref);
51
93
  let entry = entries.get(key);
@@ -84,15 +126,21 @@ export function createCmsPersister(options) {
84
126
  }
85
127
  /**
86
128
  * 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.
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.
89
132
  */
90
133
  function display(entry) {
91
134
  const baseDocument = entry.base?.document ?? entry.provisional;
92
135
  if (entry.deleted || !baseDocument)
93
136
  return undefined;
94
137
  let document = baseDocument;
138
+ let floor = entry.base?.revision;
95
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
+ }));
96
144
  const pending = [
97
145
  ...entry.inFlight,
98
146
  ...(entry.inFlightReplace ? [entry.inFlightReplace] : []),
@@ -100,17 +148,22 @@ export function createCmsPersister(options) {
100
148
  ];
101
149
  for (const queued of pending) {
102
150
  if (queued.kind === 'replace') {
103
- // The snapshot was authored over everything before it (the editor's draft).
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).
104
153
  patches = [];
154
+ writes = [];
105
155
  document = queued.document;
156
+ floor = queued.base;
106
157
  }
107
- else
158
+ else {
108
159
  patches.push(queued.patch);
160
+ writes.push({ paths: touchedPaths(queued.patch), base: queued.base });
161
+ }
109
162
  }
110
163
  if (patches.length > 0)
111
164
  document = applyDocumentPatches({ document, patches, since: [] }).document;
112
165
  if (entry.base)
113
- shown.set(document, entry.base.revision);
166
+ shown.set(document, { floor, writes });
114
167
  return document;
115
168
  }
116
169
  function emitDocument(entry) {
@@ -128,7 +181,7 @@ export function createCmsPersister(options) {
128
181
  if (entry.base && record.revision < entry.base.revision)
129
182
  return;
130
183
  entry.base = record;
131
- shown.set(record.document, record.revision);
184
+ registerRow(record);
132
185
  options.onRecord?.(record);
133
186
  emitDocument(entry);
134
187
  }
@@ -160,12 +213,22 @@ export function createCmsPersister(options) {
160
213
  if (entry.deleted)
161
214
  return;
162
215
  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
- });
216
+ dropQueued(entry, { status: 'failed', error: goneError(entry.ref) });
167
217
  options.onDocument?.(entry.ref, undefined);
168
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
+ }
169
232
  function schedule(entry) {
170
233
  if (entry.scheduled)
171
234
  return;
@@ -378,7 +441,11 @@ export function createCmsPersister(options) {
378
441
  }
379
442
  /** Send the queue — runs of equal (lifted) base, whole-document writes in their place — until it is empty or a write fails. */
380
443
  async function drain(entry) {
381
- while (entry.queue.length > 0 && !entry.deleted) {
444
+ while (entry.queue.length > 0) {
445
+ if (entry.deleted) {
446
+ refuseQueued(entry);
447
+ return;
448
+ }
382
449
  const operations = await options.getOperations();
383
450
  if (!(await baseOnRead(entry, operations)))
384
451
  return;
@@ -503,7 +570,7 @@ export function createCmsPersister(options) {
503
570
  return {
504
571
  track(row) {
505
572
  const entry = entryOf(row.document);
506
- shown.set(row.document, row.revision);
573
+ registerRow(row);
507
574
  if (!entry.base || row.revision > entry.base.revision)
508
575
  entry.base = row;
509
576
  },
@@ -512,7 +579,10 @@ export function createCmsPersister(options) {
512
579
  const entry = entries.get(documentKey(ref));
513
580
  return entry ? display(entry) : undefined;
514
581
  },
515
- revisionOfView: (view) => shown.get(view),
582
+ revisionOfView: (view, path) => {
583
+ const provenance = shown.get(view);
584
+ return provenance ? provenanceAt(provenance, path) : undefined;
585
+ },
516
586
  adoptRecord(record) {
517
587
  const entry = entryOf(record.document);
518
588
  if (entry.deleted)
@@ -522,11 +592,14 @@ export function createCmsPersister(options) {
522
592
  },
523
593
  commit(ref, patches, bases, view) {
524
594
  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
- });
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 });
530
603
  }
531
604
  if (!Array.isArray(bases)) {
532
605
  // EC-647: a writer that names no authoring revision at all — never given the tracked one.
@@ -605,7 +678,17 @@ export function createCmsPersister(options) {
605
678
  create(document) {
606
679
  const doc = canonical(document);
607
680
  const entry = entryOf(doc);
608
- // A fresh document: nothing of an earlier incarnation of this ref carries over.
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
+ }
609
692
  entry.deleted = false;
610
693
  entry.base = undefined;
611
694
  entry.own = [];
@@ -648,6 +731,13 @@ export function createCmsPersister(options) {
648
731
  replace(document, revision) {
649
732
  const doc = canonical(document);
650
733
  const entry = entryOf(doc);
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
+ }
651
741
  if (!entry.base && !entry.provisional)
652
742
  entry.provisional = doc;
653
743
  // Queued in its place: the patches committed before it are sent first (the snapshot
@@ -660,10 +750,7 @@ export function createCmsPersister(options) {
660
750
  },
661
751
  deleteDocument(ref) {
662
752
  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
- });
753
+ dropQueued(entry, { status: 'failed', error: goneError(ref) });
667
754
  entry.conflicts = [];
668
755
  entry.conflictBases.clear();
669
756
  entry.deleted = true;