@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.
- package/convex/cms.ts +153 -13
- package/convex/delivery-folded-drift.test.ts +104 -0
- package/convex/delivery.ts +24 -8
- package/convex/depth-codec-lookalike.test.ts +192 -0
- package/convex/fixture-accounts.test.ts +305 -0
- package/convex/fixtureAccounts.ts +298 -0
- package/convex/incarnation.test.ts +149 -0
- package/convex/ingest.test.ts +145 -3
- package/convex/ingest.ts +123 -17
- package/convex/receipt-races.test.ts +184 -0
- package/convex/records-zod-copy.test.ts +76 -0
- package/convex/schema.ts +20 -1
- package/convex/strict-delete.test.ts +129 -0
- package/dist/components/cms/cms-store.d.ts +4 -1
- package/dist/components/cms/cms-store.js +11 -13
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +41 -2
- package/dist/components/cms/use-document-draft.js +60 -18
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +5 -2
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +2 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +2 -0
- package/dist/lib/i18n/messages.en.js +2 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +41 -2
- package/dist/lib/operations/cms-persistence.js +111 -24
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +27 -1
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persona/labels.js +1 -0
- package/dist/lib/persona/labels.js.map +1 -1
- 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
|
-
/**
|
|
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 →
|
|
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
|
|
88
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
526
|
-
return Promise.resolve({
|
|
527
|
-
|
|
528
|
-
|
|
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;
|