@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.
- package/convex/assets.ts +16 -2
- 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-attempt.test.ts +161 -0
- package/convex/ingest.test.ts +327 -9
- package/convex/ingest.ts +242 -24
- package/convex/prototype-key.test.ts +92 -0
- package/convex/receipt-races.test.ts +184 -0
- package/convex/records-zod-copy.test.ts +76 -0
- package/convex/records-zod-refinements.convex.test.ts +94 -0
- package/convex/schema.ts +20 -1
- package/convex/strict-delete.test.ts +129 -0
- package/dist/components/cms/cms-store.d.ts +6 -5
- package/dist/components/cms/cms-store.js +14 -34
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/field-control.d.ts +12 -1
- package/dist/components/cms/field-control.js +16 -1
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/import-batch.d.ts +23 -0
- package/dist/components/cms/import-batch.js +43 -0
- package/dist/components/cms/import-batch.js.map +1 -0
- package/dist/components/cms/rich-text/editor.js +13 -10
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/value.d.ts +9 -0
- package/dist/components/cms/rich-text/value.js +13 -0
- package/dist/components/cms/rich-text/value.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +76 -4
- package/dist/components/cms/use-document-draft.js +94 -25
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/records/record-lens.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 +5 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +5 -0
- package/dist/lib/i18n/messages.en.js +5 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +49 -5
- package/dist/lib/operations/cms-persistence.js +237 -47
- 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 +2 -0
- package/dist/lib/persona/labels.js.map +1 -1
- 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)
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
/**
|
|
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 →
|
|
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
|
|
88
|
-
*
|
|
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,
|
|
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
|
-
|
|
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.
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
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 (
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
465
|
-
// the
|
|
466
|
-
//
|
|
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
|
|
638
|
+
const kept = failedWrite(entry, run.map((queued) => ({ ...queued, settle: [] })), error);
|
|
469
639
|
adoptHeld(entry);
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
526
|
-
return Promise.resolve({
|
|
527
|
-
|
|
528
|
-
|
|
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;
|