@elytracms/studio 0.0.98 → 0.0.99
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/ingest.test.ts +84 -7
- package/convex/ingest.ts +110 -31
- package/convex/path-segment-refusal.test.ts +201 -0
- package/convex/records-column-transform.convex.test.ts +147 -0
- package/convex/schema.ts +1 -1
- package/dist/addon.d.ts +5 -3
- package/dist/addon.js +4 -3
- package/dist/addon.js.map +1 -1
- package/dist/components/app-views/default-modules.d.ts +10 -3
- package/dist/components/app-views/default-modules.js +5 -1
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/module.d.ts +15 -8
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/builder/reuse-context.d.ts +29 -1
- package/dist/components/builder/reuse-context.js +58 -12
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +3 -2
- package/dist/components/cms/cms-store.js +8 -2
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/field-control.js +15 -5
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +4 -1
- package/dist/components/cms/use-document-draft.js +4 -1
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/cms/use-publish-controller.d.ts +7 -0
- package/dist/components/cms/use-publish-controller.js +17 -3
- package/dist/components/cms/use-publish-controller.js.map +1 -1
- package/dist/components/cms/versions-pane.js +8 -5
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/delivery-health-banner.d.ts +34 -16
- package/dist/components/delivery-health-banner.js +59 -27
- package/dist/components/delivery-health-banner.js.map +1 -1
- package/dist/components/issue-severity.d.ts +3 -2
- package/dist/components/issue-severity.js.map +1 -1
- package/dist/components/nav-issues-registry.d.ts +39 -24
- package/dist/components/nav-issues-registry.js +50 -21
- package/dist/components/nav-issues-registry.js.map +1 -1
- package/dist/components/nav-issues.d.ts +23 -50
- package/dist/components/nav-issues.js +31 -59
- package/dist/components/nav-issues.js.map +1 -1
- package/dist/components/notifications/notice-center.d.ts +61 -0
- package/dist/components/notifications/notice-center.js +119 -0
- package/dist/components/notifications/notice-center.js.map +1 -0
- package/dist/components/notifications/notice-model.d.ts +137 -0
- package/dist/components/notifications/notice-model.js +107 -0
- package/dist/components/notifications/notice-model.js.map +1 -0
- package/dist/components/notifications/outcome-notice.d.ts +16 -0
- package/dist/components/notifications/outcome-notice.js +34 -0
- package/dist/components/notifications/outcome-notice.js.map +1 -0
- package/dist/components/notifications/shell-findings.d.ts +36 -0
- package/dist/components/notifications/shell-findings.js +140 -0
- package/dist/components/notifications/shell-findings.js.map +1 -0
- package/dist/components/rail-view-source.js +5 -0
- package/dist/components/rail-view-source.js.map +1 -1
- package/dist/components/records/record-rail.d.ts +10 -2
- package/dist/components/records/record-rail.js +74 -4
- package/dist/components/records/record-rail.js.map +1 -1
- package/dist/components/section-nav.d.ts +15 -3
- package/dist/components/section-nav.js +39 -147
- package/dist/components/section-nav.js.map +1 -1
- package/dist/components/studio-project-screen.js.map +1 -1
- package/dist/components/studio-shell.js +8 -3
- package/dist/components/studio-shell.js.map +1 -1
- package/dist/components/use-narrow-viewport.d.ts +11 -0
- package/dist/components/use-narrow-viewport.js +25 -0
- package/dist/components/use-narrow-viewport.js.map +1 -0
- package/dist/components/users/role-policy-drift-issues.d.ts +14 -7
- package/dist/components/users/role-policy-drift-issues.js +17 -8
- package/dist/components/users/role-policy-drift-issues.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +4 -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 +21 -3
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +19 -2
- package/dist/lib/i18n/messages.en.js +24 -4
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/notices/toast-store.d.ts +61 -0
- package/dist/lib/notices/toast-store.js +116 -0
- package/dist/lib/notices/toast-store.js.map +1 -0
- package/dist/lib/operations/cms-persistence.d.ts +24 -4
- package/dist/lib/operations/cms-persistence.js +417 -80
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +61 -30
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/ui/toast.d.ts +24 -16
- package/dist/ui/toast.js +23 -32
- package/dist/ui/toast.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { applyDocumentPatches, documentKey, formatValuePath, isValuePathPrefix, readValueAt, selfTypeDocument, semanticValueEqual, touchedPaths, touchedPathsOfEntry, valuePathsOverlap, } from '@elytracms/core/cms-core';
|
|
1
|
+
import { applyDocumentPatches, documentKey, documentValueBuckets, expandValuePath, fieldPath, formatValuePath, isValuePathPrefix, readValueAt, selfTypeDocument, semanticValueEqual, touchedPaths, touchedPathsOfEntry, valuePathsOverlap, } from '@elytracms/core/cms-core';
|
|
2
2
|
import { createPersistQueue } from './persist-queue.js';
|
|
3
|
+
/** EC-686 — a decomposed alias is re-sent at most this often (each time a colleague wrote the document in between). */
|
|
4
|
+
const ALIAS_ATTEMPTS = 3;
|
|
3
5
|
/** How many own writes a document's ledger keeps (an older base just stops lifting — a conflict, never a borrow). */
|
|
4
6
|
const OWN_WRITES_KEPT = 256;
|
|
5
7
|
/**
|
|
@@ -8,7 +10,8 @@ const OWN_WRITES_KEPT = 256;
|
|
|
8
10
|
* the floor and of every shown write whose positions OVERLAP `path` structurally — a write
|
|
9
11
|
* at an ancestor of `path`, at `path`, or at a DESCENDANT of it (the value at `path`
|
|
10
12
|
* 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.
|
|
13
|
+
* `undefined` path — a whole-document writer — the earliest of all. `path` and the writes'
|
|
14
|
+
* positions are in the view's structural form (EC-693: node ancestry included).
|
|
12
15
|
*
|
|
13
16
|
* Minted when the view is shown, never when the edit commits (Codex F18, F23): a
|
|
14
17
|
* descendant write may be refused and its conflict resolved before an edit of `path`
|
|
@@ -28,9 +31,10 @@ function provenanceAt(provenance, path) {
|
|
|
28
31
|
* EC-681 — whether a write at `a` may overlap one at `b` as the STORE judges it: THE
|
|
29
32
|
* conflict relation (`valuePathsOverlap`) unless BOTH name a component node — then any two
|
|
30
33
|
* in overlapping field positions. The store compares node paths EXPANDED by their ancestry
|
|
31
|
-
* (`value-path.ts`)
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
+
* (`value-path.ts`) against ITS document, which may hold a colleague's move this session
|
|
35
|
+
* has not adopted, so the persister never claims two node paths of one field apart — not
|
|
36
|
+
* even in their expanded form (EC-693 kept this conservative on purpose). Conservative by
|
|
37
|
+
* construction: a "may" that is wrong costs a lift (a conflict at worst), never a borrow.
|
|
34
38
|
*/
|
|
35
39
|
function mayOverlap(a, b) {
|
|
36
40
|
const fieldOf = (path) => {
|
|
@@ -41,6 +45,40 @@ function mayOverlap(a, b) {
|
|
|
41
45
|
const fieldB = fieldOf(b);
|
|
42
46
|
return fieldA && fieldB ? valuePathsOverlap(fieldA, fieldB) : valuePathsOverlap(a, b);
|
|
43
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* EC-693 — THE containment fact the persister reads (Codex F28): the positions each patch
|
|
50
|
+
* touches as THE merge reports them — `applyDocumentPatches(...).applied`, the patches
|
|
51
|
+
* applied in order over `document`, each expanded against the document it applied to
|
|
52
|
+
* (node ancestry included). `undefined` for a patch the merge does not apply there (its
|
|
53
|
+
* node is not in that document): it has no position in it. Never a second expansion.
|
|
54
|
+
*
|
|
55
|
+
* The merge puts every input patch in exactly one of `applied` (in input order) and
|
|
56
|
+
* `refused` (the input object itself); a fresh object per input keeps a patch object that
|
|
57
|
+
* occurs twice apart.
|
|
58
|
+
*/
|
|
59
|
+
function mergedPositions(document, patches) {
|
|
60
|
+
if (patches.length === 0)
|
|
61
|
+
return { document, positions: [] };
|
|
62
|
+
const inputs = patches.map((patch) => ({ ...patch }));
|
|
63
|
+
const merged = applyDocumentPatches({ document, patches: inputs, since: [] });
|
|
64
|
+
const refused = new Set(merged.refused.map((refusal) => refusal.patch));
|
|
65
|
+
let next = 0;
|
|
66
|
+
return {
|
|
67
|
+
document: merged.document,
|
|
68
|
+
positions: inputs.map((input) => (refused.has(input) ? undefined : touchedPaths(merged.applied[next++]))),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* EC-693 — the positions an applied run touched at the store: {@link mergedPositions} over
|
|
73
|
+
* the row the run was sent from, in order. The store applied exactly these patches, in this
|
|
74
|
+
* order, and would have refused one whose node (or an ancestor) a change since its base had
|
|
75
|
+
* moved — so the ancestry each landed under is the row's. The submitted form where the merge
|
|
76
|
+
* has none.
|
|
77
|
+
*/
|
|
78
|
+
function footprint(row, patches) {
|
|
79
|
+
const merged = row ? mergedPositions(row, patches).positions : [];
|
|
80
|
+
return patches.flatMap((patch, index) => merged[index] ?? touchedPaths(patch));
|
|
81
|
+
}
|
|
44
82
|
/** A patch's identity within one call: op + target path (no two value writes of one path share a run). */
|
|
45
83
|
function targetKey(op, path) {
|
|
46
84
|
return `${op} ${formatValuePath(path)}`;
|
|
@@ -51,9 +89,32 @@ function patchTarget(patch) {
|
|
|
51
89
|
function conflictId(ref, path) {
|
|
52
90
|
return `${documentKey(ref)}|${formatValuePath(path)}`;
|
|
53
91
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
92
|
+
function pathsOverlap(a, b) {
|
|
93
|
+
return a.some((p) => b.some((q) => valuePathsOverlap(p, q)));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Whether two own writes may touch a common position (either touches a prefix of the
|
|
97
|
+
* other's): as submitted, or — EC-693 — where both touch the view (node ancestry
|
|
98
|
+
* included: a write of a parent node and one of its child's prop). Either is enough; a
|
|
99
|
+
* "may" lowers a base (a conflict at worst), never borrows one.
|
|
100
|
+
*/
|
|
101
|
+
function patchesOverlap(a, b, positions) {
|
|
102
|
+
if (pathsOverlap(touchedPaths(a), touchedPaths(b)))
|
|
103
|
+
return true;
|
|
104
|
+
const atA = positions?.get(a);
|
|
105
|
+
const atB = positions?.get(b);
|
|
106
|
+
return atA !== undefined && atB !== undefined && pathsOverlap(atA, atB);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* EC-693 — two value writes of ONE position (the open conflict a later edit replaces): the
|
|
110
|
+
* same submitted path, or the same position in the view.
|
|
111
|
+
*/
|
|
112
|
+
function samePosition(a, b, positions) {
|
|
113
|
+
if (samePath(a.path, b.path))
|
|
114
|
+
return true;
|
|
115
|
+
const atA = positions.get(a)?.[0];
|
|
116
|
+
const atB = positions.get(b)?.[0];
|
|
117
|
+
return atA !== undefined && atB !== undefined && samePath(atA, atB);
|
|
57
118
|
}
|
|
58
119
|
/** A `set`/`unset` on exactly one path — the shape a later edit of the same field supersedes. */
|
|
59
120
|
function isValueWrite(patch) {
|
|
@@ -81,29 +142,56 @@ function earlierBase(a, b) {
|
|
|
81
142
|
*/
|
|
82
143
|
export function importWrite(persister, document, shown) {
|
|
83
144
|
if (shown && persister.revisionOf(document) !== undefined) {
|
|
84
|
-
|
|
145
|
+
// EC-686: with the row it was judged against — what a refusal decomposes it against.
|
|
146
|
+
persister.replace(document, persister.revisionOfView(shown), shown);
|
|
85
147
|
}
|
|
86
148
|
else
|
|
87
149
|
persister.create(document);
|
|
88
150
|
}
|
|
89
151
|
/**
|
|
90
|
-
* EC-674 — THE classification of a write that failed without a per-field conflict
|
|
91
|
-
* declaration, read by the whole-document and the patch path): `true` = KEPT — put
|
|
92
|
-
* still shown, re-sent by the next commit or forced flush — for
|
|
93
|
-
* not JUDGE
|
|
94
|
-
*
|
|
95
|
-
*
|
|
152
|
+
* EC-674 → EC-686 — THE classification of a write that failed without a per-field conflict
|
|
153
|
+
* (one declaration, read by the whole-document and the patch path): `true` = KEPT — put
|
|
154
|
+
* back, still shown, re-sent by the next commit or forced flush — for the one failure the
|
|
155
|
+
* store did not JUDGE, `backend-unavailable`, whatever the message. `false` = DROPPED for
|
|
156
|
+
* the store's verdicts, `validation`, `unauthorized` and `not-found`, and for `unknown`: the
|
|
157
|
+
* same write fails the same way again, so it is reported and the writes behind it drain.
|
|
96
158
|
*
|
|
97
|
-
* Why
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* OCC exhaustion
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
159
|
+
* Why `unknown` is now a verdict (EC-686, classify by WHO failed — the binding rule): the
|
|
160
|
+
* Convex adapter makes every failure out of a backend call that is not a mapped engine
|
|
161
|
+
* error `backend-unavailable` (a network failure, a gateway's 5xx, an expired session, an
|
|
162
|
+
* OCC exhaustion or a timeout, keeping the backend's message), so an `unknown` is a failure
|
|
163
|
+
* of the engine in THIS process — an undeclared field type's `canonicalise` crash — which
|
|
164
|
+
* the same input repeats. Kept, it waited in front of every later write of the document
|
|
165
|
+
* (EC-674's accepted cost); dropped, it is visible and nothing behind it waits.
|
|
104
166
|
*/
|
|
105
167
|
function keepsFailedWrite(error) {
|
|
106
|
-
return error.code === 'backend-unavailable'
|
|
168
|
+
return error.code === 'backend-unavailable';
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* EC-686 — a dropped verdict on a run as a whole that may be one patch's alone: `validation`
|
|
172
|
+
* (an input or value issue), `unauthorized` (an authorization hook may judge by the
|
|
173
|
+
* patches), an in-process `unknown` (one patch's crash). Not `not-found` — the document is
|
|
174
|
+
* gone for every patch — and never a kept failure.
|
|
175
|
+
*/
|
|
176
|
+
function isolates(error) {
|
|
177
|
+
return error.code === 'validation' || error.code === 'unauthorized' || error.code === 'unknown';
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* EC-686 — the patches a verdict on a whole run names (`validation` issues located at
|
|
181
|
+
* `['patches', i, …]`, i in the run): `undefined` when any issue is not located there (or
|
|
182
|
+
* the verdict carries none) — then no patch is known innocent.
|
|
183
|
+
*/
|
|
184
|
+
function locatedCulprits(error, size) {
|
|
185
|
+
if (error.code !== 'validation' || !error.issues?.length)
|
|
186
|
+
return undefined;
|
|
187
|
+
const culprits = new Set();
|
|
188
|
+
for (const issue of error.issues) {
|
|
189
|
+
const index = issue.path?.[0] === 'patches' ? issue.path[1] : undefined;
|
|
190
|
+
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0 || index >= size)
|
|
191
|
+
return undefined;
|
|
192
|
+
culprits.add(index);
|
|
193
|
+
}
|
|
194
|
+
return culprits;
|
|
107
195
|
}
|
|
108
196
|
/** A write against a document this session no longer holds (deleted here or elsewhere). */
|
|
109
197
|
function goneError(ref) {
|
|
@@ -118,6 +206,48 @@ function unauthoredError(ref) {
|
|
|
118
206
|
messageRef: { key: 'cms.conflict.unauthored' },
|
|
119
207
|
};
|
|
120
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* EC-686 — a slot change someone else's slot change came before: the alias has no position
|
|
211
|
+
* to hold an ordinary conflict on, so theirs is kept, and the author is told (the table
|
|
212
|
+
* cell's precedent, `cms.conflict.cellRefused`).
|
|
213
|
+
*/
|
|
214
|
+
function aliasRefusedError(ref) {
|
|
215
|
+
return {
|
|
216
|
+
code: 'conflict',
|
|
217
|
+
key: documentKey(ref),
|
|
218
|
+
message: 'The slot was changed by someone else since you chose it — theirs is kept. Choose it again to change it.',
|
|
219
|
+
messageRef: { key: 'cms.conflict.aliasRefused' },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/** `document` with its slot alias set (or removed) — a fresh object. */
|
|
223
|
+
function withAlias(document, alias) {
|
|
224
|
+
const next = { ...document };
|
|
225
|
+
if (alias === undefined)
|
|
226
|
+
delete next.alias;
|
|
227
|
+
else
|
|
228
|
+
next.alias = alias;
|
|
229
|
+
return next;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* EC-686 item 1 — what a whole-document snapshot changed against the view it was made from,
|
|
233
|
+
* position by position: a `set` (or an `unset`, the value gone) of every field position —
|
|
234
|
+
* `values[f]`, `localized[l][f]` — whose value differs by THE equality
|
|
235
|
+
* (`documentValueBuckets` + `semanticValueEqual`, the document diff's own reading). Every
|
|
236
|
+
* value change of a document is one of these; its slot `alias` is the one key that is not.
|
|
237
|
+
*/
|
|
238
|
+
function positionPatches(view, snapshot) {
|
|
239
|
+
const patches = [];
|
|
240
|
+
for (const bucket of documentValueBuckets(view, snapshot)) {
|
|
241
|
+
for (const key of new Set([...Object.keys(bucket.before), ...Object.keys(bucket.after)])) {
|
|
242
|
+
if (semanticValueEqual(bucket.before[key], bucket.after[key]))
|
|
243
|
+
continue;
|
|
244
|
+
const path = fieldPath(key, bucket.locale);
|
|
245
|
+
const value = bucket.after[key];
|
|
246
|
+
patches.push(value === undefined ? { op: 'unset', path } : { op: 'set', path, value });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return patches;
|
|
250
|
+
}
|
|
121
251
|
export function createCmsPersister(options) {
|
|
122
252
|
const queue = createPersistQueue((error) => {
|
|
123
253
|
options.onError({
|
|
@@ -130,6 +260,28 @@ export function createCmsPersister(options) {
|
|
|
130
260
|
const shown = new WeakMap();
|
|
131
261
|
/** A server row's document: every position at its revision. */
|
|
132
262
|
const registerRow = (row) => shown.set(row.document, { floor: row.revision, writes: [] });
|
|
263
|
+
/** EC-693: {@link serverPositions}, per server row and patch (a row and a queued patch never change). */
|
|
264
|
+
const atRow = new WeakMap();
|
|
265
|
+
/**
|
|
266
|
+
* EC-693 — where a patch lands in the server row the session holds (`entry.base`, at or
|
|
267
|
+
* after every write the own-write ledger records): its positions as THE merge reports
|
|
268
|
+
* them on that row alone ({@link mergedPositions}), the submitted form where the merge does
|
|
269
|
+
* not apply it there. What {@link lift} and the actor lookup read — never the view's
|
|
270
|
+
* evolving frame: an own write still queued ahead of the patch (a move of its node) has
|
|
271
|
+
* not happened at the store.
|
|
272
|
+
*/
|
|
273
|
+
function serverPositions(entry, patch) {
|
|
274
|
+
const row = entry.base?.document;
|
|
275
|
+
if (!row)
|
|
276
|
+
return touchedPaths(patch);
|
|
277
|
+
let byPatch = atRow.get(row);
|
|
278
|
+
if (!byPatch)
|
|
279
|
+
atRow.set(row, (byPatch = new WeakMap()));
|
|
280
|
+
let positions = byPatch.get(patch);
|
|
281
|
+
if (!positions)
|
|
282
|
+
byPatch.set(patch, (positions = mergedPositions(row, [patch]).positions[0] ?? touchedPaths(patch)));
|
|
283
|
+
return positions;
|
|
284
|
+
}
|
|
133
285
|
function entryOf(ref) {
|
|
134
286
|
const key = documentKey(ref);
|
|
135
287
|
let entry = entries.get(key);
|
|
@@ -168,45 +320,73 @@ export function createCmsPersister(options) {
|
|
|
168
320
|
}
|
|
169
321
|
/**
|
|
170
322
|
* The session's view: base ⊕ unresolved mine ⊕ in-flight ⊕ queued (the pure merge, no
|
|
171
|
-
* log; a whole-document write resets it to its snapshot)
|
|
172
|
-
*
|
|
173
|
-
* pending snapshot's, each shown write's base)
|
|
323
|
+
* log; a whole-document write resets it to its snapshot) — and, EC-693, `extra` (a
|
|
324
|
+
* commit's patches) over it. With what each shown position was authored against (EC-647
|
|
325
|
+
* → EC-667: the base's revision, a pending snapshot's, each shown write's base) and where
|
|
326
|
+
* each shown write — and each of `extra` — touches the view ({@link mergedPositions}):
|
|
327
|
+
* computed on every call, never frozen when a write is queued, so a colleague's move
|
|
328
|
+
* adopted underneath moves the pending write's position with its node.
|
|
174
329
|
*/
|
|
175
|
-
function
|
|
330
|
+
function compose(entry, extra = []) {
|
|
176
331
|
const baseDocument = entry.base?.document ?? entry.provisional;
|
|
177
332
|
if (entry.deleted || !baseDocument)
|
|
178
333
|
return undefined;
|
|
179
334
|
let document = baseDocument;
|
|
180
335
|
let floor = entry.base?.revision;
|
|
181
|
-
let
|
|
182
|
-
let writes = entry.conflicts.map((conflict) => ({
|
|
183
|
-
paths: touchedPaths(conflict.patch),
|
|
184
|
-
base: entry.conflictBases.get(conflict.id),
|
|
185
|
-
}));
|
|
336
|
+
let shownWrites = entry.conflicts.map((conflict) => ({ patch: conflict.patch, base: entry.conflictBases.get(conflict.id) }));
|
|
186
337
|
const pending = [
|
|
187
338
|
...entry.inFlight,
|
|
188
339
|
...(entry.inFlightReplace ? [entry.inFlightReplace] : []),
|
|
189
340
|
...entry.queue,
|
|
190
341
|
];
|
|
342
|
+
// EC-686: a pending decomposed alias shows over everything before it (the newest wins).
|
|
343
|
+
let alias;
|
|
191
344
|
for (const queued of pending) {
|
|
192
345
|
if (queued.kind === 'replace') {
|
|
193
346
|
// The snapshot was authored over everything before it (the editor's draft): every
|
|
194
347
|
// position now shows content authored at the SNAPSHOT's revision (EC-667).
|
|
195
|
-
|
|
196
|
-
writes = [];
|
|
348
|
+
shownWrites = [];
|
|
197
349
|
document = queued.document;
|
|
198
350
|
floor = queued.base;
|
|
351
|
+
alias = undefined;
|
|
352
|
+
}
|
|
353
|
+
else if (queued.kind === 'alias') {
|
|
354
|
+
alias = { value: queued.alias };
|
|
199
355
|
}
|
|
200
356
|
else {
|
|
201
|
-
|
|
202
|
-
writes.push({ paths: touchedPaths(queued.patch), base: queued.base });
|
|
357
|
+
shownWrites.push({ patch: queued.patch, base: queued.base });
|
|
203
358
|
}
|
|
204
359
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
360
|
+
const merged = mergedPositions(document, [...shownWrites.map((write) => write.patch), ...extra]);
|
|
361
|
+
// A write the merge did not apply in this view shows nothing: its submitted form keeps
|
|
362
|
+
// the tokens it may concern conservative.
|
|
363
|
+
const writes = shownWrites.map((write, index) => ({
|
|
364
|
+
patch: write.patch,
|
|
365
|
+
paths: merged.positions[index] ?? touchedPaths(write.patch),
|
|
366
|
+
base: write.base,
|
|
367
|
+
}));
|
|
368
|
+
const positions = new Map();
|
|
369
|
+
for (const write of writes)
|
|
370
|
+
positions.set(write.patch, write.paths);
|
|
371
|
+
extra.forEach((patch, index) => {
|
|
372
|
+
const at = merged.positions[shownWrites.length + index];
|
|
373
|
+
if (at)
|
|
374
|
+
positions.set(patch, at);
|
|
375
|
+
});
|
|
376
|
+
return { document: alias ? withAlias(merged.document, alias.value) : merged.document, floor, writes, positions: positions };
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The session's view ({@link compose}), registered with what it was authored against,
|
|
380
|
+
* position by position; a provisional view has none.
|
|
381
|
+
*/
|
|
382
|
+
function display(entry) {
|
|
383
|
+
const view = compose(entry);
|
|
384
|
+
if (!view)
|
|
385
|
+
return undefined;
|
|
386
|
+
if (entry.base) {
|
|
387
|
+
shown.set(view.document, { floor: view.floor, writes: view.writes.map(({ paths, base }) => ({ paths, base })) });
|
|
388
|
+
}
|
|
389
|
+
return view.document;
|
|
210
390
|
}
|
|
211
391
|
function emitDocument(entry) {
|
|
212
392
|
if (entry.deleted)
|
|
@@ -293,18 +473,23 @@ export function createCmsPersister(options) {
|
|
|
293
473
|
* never a change by anyone else of a position the write touches. Three rules, applied
|
|
294
474
|
* until none moves it:
|
|
295
475
|
* 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 `
|
|
476
|
+
* 2. an own write that covered every position `patch` touches (each at or under one of its
|
|
297
477
|
* 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
|
|
478
|
+
* every foreign change of those positions in between, or it would not have applied.
|
|
479
|
+
* EC-693: both read where they land at the store — the own write's {@link footprint},
|
|
480
|
+
* the patch's {@link serverPositions} — node ancestry included: a submitted path names
|
|
481
|
+
* a node's ancestry as the writer believed it, the store reads it from the document;
|
|
299
482
|
* 3. (EC-681, `log` given) the next revision is someone else's and touched none of
|
|
300
|
-
*
|
|
301
|
-
* store would not refuse the write on it, so crossing it
|
|
302
|
-
* rule 1 reach an own write beyond it. That is the case
|
|
303
|
-
* ancestor's write over an own DESCENDANT write (whose
|
|
304
|
-
* ancestor's other descendants), past an unrelated
|
|
305
|
-
*
|
|
483
|
+
* the patch's positions ({@link mayOverlap}, the store's own row reading
|
|
484
|
+
* `touchedPathsOfEntry`): the store would not refuse the write on it, so crossing it
|
|
485
|
+
* moves nothing — it only lets rule 1 reach an own write beyond it. That is the case
|
|
486
|
+
* rule 2 cannot prove: an ancestor's write over an own DESCENDANT write (whose
|
|
487
|
+
* acceptance says nothing of the ancestor's other descendants), past an unrelated
|
|
488
|
+
* colleague write in between.
|
|
489
|
+
* A whole-document write (`patch` undefined) takes rule 1 only.
|
|
306
490
|
*/
|
|
307
|
-
function lift(entry, base,
|
|
491
|
+
function lift(entry, base, patch, log) {
|
|
492
|
+
const paths = patch ? () => serverPositions(entry, patch) : undefined;
|
|
308
493
|
let lifted = base;
|
|
309
494
|
for (;;) {
|
|
310
495
|
if (entry.own.some((write) => write.revision === lifted + 1)) {
|
|
@@ -316,14 +501,14 @@ export function createCmsPersister(options) {
|
|
|
316
501
|
write.paths !== undefined &&
|
|
317
502
|
write.sentBase !== undefined &&
|
|
318
503
|
write.sentBase <= lifted &&
|
|
319
|
-
paths.every((path) => write.paths.some((covered) => isValuePathPrefix(covered, path))))
|
|
504
|
+
paths().every((path) => write.paths.some((covered) => isValuePathPrefix(covered, path))))
|
|
320
505
|
: undefined;
|
|
321
506
|
if (covering) {
|
|
322
507
|
lifted = covering.revision;
|
|
323
508
|
continue;
|
|
324
509
|
}
|
|
325
510
|
const foreign = paths ? log?.get(lifted + 1) : undefined;
|
|
326
|
-
if (foreign && !touchedPathsOfEntry(foreign).some((logged) => paths.some((path) => mayOverlap(logged, path)))) {
|
|
511
|
+
if (foreign && !touchedPathsOfEntry(foreign).some((logged) => paths().some((path) => mayOverlap(logged, path)))) {
|
|
327
512
|
lifted += 1;
|
|
328
513
|
continue;
|
|
329
514
|
}
|
|
@@ -337,10 +522,10 @@ export function createCmsPersister(options) {
|
|
|
337
522
|
* when not needed or not readable — then no lift, a conflict at worst, never a borrow.
|
|
338
523
|
*/
|
|
339
524
|
async function liftLog(entry, operations, queued) {
|
|
340
|
-
const
|
|
341
|
-
const lifted = lift(entry, queued.base, paths);
|
|
525
|
+
const lifted = lift(entry, queued.base, queued.patch);
|
|
342
526
|
const blocking = entry.own.some((write) => write.revision > lifted &&
|
|
343
|
-
(write.paths === undefined ||
|
|
527
|
+
(write.paths === undefined ||
|
|
528
|
+
write.paths.some((own) => serverPositions(entry, queued.patch).some((path) => mayOverlap(own, path)))));
|
|
344
529
|
if (!blocking)
|
|
345
530
|
return undefined;
|
|
346
531
|
const read = await operations.documents.listPatches({
|
|
@@ -353,12 +538,16 @@ export function createCmsPersister(options) {
|
|
|
353
538
|
/**
|
|
354
539
|
* EC-647 — the base a write is QUEUED with: the revision it was authored against, or
|
|
355
540
|
* the earlier base of an own unacknowledged write on the same positions (the view
|
|
356
|
-
* showed mine there, not whatever may sit underneath it)
|
|
541
|
+
* showed mine there, not whatever may sit underneath it) — EC-693: as submitted, or in
|
|
542
|
+
* the view (`positions`).
|
|
357
543
|
*/
|
|
358
|
-
function authoredBase(entry, patch, authored) {
|
|
544
|
+
function authoredBase(entry, patch, authored, positions) {
|
|
359
545
|
let base = authored;
|
|
360
|
-
const overlaps = (other) => patch === undefined || patchesOverlap(other, patch);
|
|
546
|
+
const overlaps = (other) => patch === undefined || patchesOverlap(other, patch, positions);
|
|
361
547
|
for (const queued of [...entry.inFlight, ...entry.queue]) {
|
|
548
|
+
// A decomposed alias names no value position (and is sent at the row it then finds).
|
|
549
|
+
if (queued.kind === 'alias')
|
|
550
|
+
continue;
|
|
362
551
|
if (queued.kind === 'replace' || overlaps(queued.patch))
|
|
363
552
|
base = earlierBase(base, queued.base);
|
|
364
553
|
}
|
|
@@ -379,7 +568,7 @@ export function createCmsPersister(options) {
|
|
|
379
568
|
if (entry.created === undefined)
|
|
380
569
|
return;
|
|
381
570
|
for (const queued of entry.queue)
|
|
382
|
-
if (queued.base === undefined)
|
|
571
|
+
if (queued.kind !== 'alias' && queued.base === undefined)
|
|
383
572
|
queued.base = entry.created;
|
|
384
573
|
}
|
|
385
574
|
/**
|
|
@@ -405,6 +594,9 @@ export function createCmsPersister(options) {
|
|
|
405
594
|
}
|
|
406
595
|
const read = await operations.documents.get({ projectId: options.projectId, ref: entry.ref });
|
|
407
596
|
if (!read.ok) {
|
|
597
|
+
// The writes stay queued for the next flush; their commits learn why now (EC-686).
|
|
598
|
+
if (!entry.scheduled)
|
|
599
|
+
settleWaiting(entry, { status: 'failed', error: read.error });
|
|
408
600
|
options.onError(read.error);
|
|
409
601
|
return false;
|
|
410
602
|
}
|
|
@@ -439,7 +631,12 @@ export function createCmsPersister(options) {
|
|
|
439
631
|
adopt(entry, record);
|
|
440
632
|
return true;
|
|
441
633
|
}
|
|
442
|
-
/**
|
|
634
|
+
/**
|
|
635
|
+
* Who changed a refused path — the newest patch-log row in (base, current] touching it.
|
|
636
|
+
* Best-effort. The log stores its paths expanded (node ancestry included), so the refused
|
|
637
|
+
* patch is read where it lands in the server row ({@link serverPositions}, EC-693) — a
|
|
638
|
+
* child prop's conflict names its parent there, as the colleague's logged write does.
|
|
639
|
+
*/
|
|
443
640
|
async function resolveActors(entry, sentBase, refused) {
|
|
444
641
|
try {
|
|
445
642
|
const operations = await options.getOperations();
|
|
@@ -454,10 +651,10 @@ export function createCmsPersister(options) {
|
|
|
454
651
|
entry.conflicts = entry.conflicts.map((conflict) => {
|
|
455
652
|
if (conflict.actor !== undefined || !refused.some((r) => r.id === conflict.id))
|
|
456
653
|
return conflict;
|
|
654
|
+
const at = serverPositions(entry, conflict.patch);
|
|
457
655
|
const row = [...log.value]
|
|
458
656
|
.reverse()
|
|
459
|
-
.find((candidate) => candidate.kind === 'replace' ||
|
|
460
|
-
candidate.patches.some((patch) => touchedPaths(patch).some((path) => valuePathsOverlap(path, conflict.path))));
|
|
657
|
+
.find((candidate) => candidate.kind === 'replace' || candidate.patches.some((patch) => pathsOverlap(touchedPaths(patch), at)));
|
|
461
658
|
if (!row?.actor)
|
|
462
659
|
return conflict;
|
|
463
660
|
changed = true;
|
|
@@ -476,18 +673,124 @@ export function createCmsPersister(options) {
|
|
|
476
673
|
* re-sent by the next commit or forced flush at its own authoring base (a retry never
|
|
477
674
|
* borrows a newer revision) — or DROPPED on the store's verdict, and the view re-derived
|
|
478
675
|
* without it. Reported either way. `true` when kept: the drain stops there, so nothing
|
|
479
|
-
* committed after it is sent ahead of it
|
|
480
|
-
*
|
|
676
|
+
* committed after it is sent ahead of it — and, EC-686, every commit still waiting on a
|
|
677
|
+
* write queued behind it settles now ({@link settleWaiting}). The one path for both the
|
|
678
|
+
* whole-document write and the patch run.
|
|
481
679
|
*/
|
|
482
680
|
function failedWrite(entry, writes, error) {
|
|
483
681
|
const kept = keepsFailedWrite(error);
|
|
484
|
-
if (kept)
|
|
682
|
+
if (kept) {
|
|
485
683
|
entry.queue.unshift(...writes);
|
|
684
|
+
// A drain already scheduled (a commit made meanwhile) re-sends them at once: they
|
|
685
|
+
// settle with its outcome instead.
|
|
686
|
+
if (!entry.scheduled)
|
|
687
|
+
settleWaiting(entry, { status: 'failed', error });
|
|
688
|
+
}
|
|
486
689
|
else
|
|
487
690
|
emitDocument(entry);
|
|
488
691
|
options.onError(error);
|
|
489
692
|
return kept;
|
|
490
693
|
}
|
|
694
|
+
/**
|
|
695
|
+
* EC-686 — the drain stopped with writes still queued (kept, shown, re-sent by the next
|
|
696
|
+
* commit or forced flush): the commits waiting on them settle with why, never left
|
|
697
|
+
* hanging until a later flush lands them (an awaiting caller — a table cell, bulk
|
|
698
|
+
* set-field — would wait with them). Each settles once; the write stays queued. Not
|
|
699
|
+
* when a drain is already scheduled: it re-sends them at once, and they settle with it.
|
|
700
|
+
*/
|
|
701
|
+
function settleWaiting(entry, outcome) {
|
|
702
|
+
entry.queue = entry.queue.map((queued) => {
|
|
703
|
+
if (queued.kind !== 'patch' || queued.settle.length === 0)
|
|
704
|
+
return queued;
|
|
705
|
+
settleAll([queued], outcome);
|
|
706
|
+
return { ...queued, settle: [] };
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* EC-686 — a verdict on a run of several patches as a whole ({@link isolates}): its
|
|
711
|
+
* innocents are not dropped with its culprit. A `validation` that
|
|
712
|
+
* locates every culprit ({@link locatedCulprits}) drops those, and the rest go back to the
|
|
713
|
+
* head of the queue to be re-sent once as a run (`'resent'`); otherwise each patch is
|
|
714
|
+
* re-sent once alone (`'alone'`), so the refusal finds its culprit. The verdict is
|
|
715
|
+
* reported once; a culprit's commit settles `failed` now, an innocent's after its
|
|
716
|
+
* re-send. The re-sent patches keep their authoring bases (lift rule 1 crosses each
|
|
717
|
+
* own revision a re-send lands at), and are never isolated again.
|
|
718
|
+
*/
|
|
719
|
+
function isolate(entry, run, error) {
|
|
720
|
+
const culprits = locatedCulprits(error, run.length);
|
|
721
|
+
const resend = culprits
|
|
722
|
+
? run.filter((_, index) => !culprits.has(index)).map((queued) => ({ ...queued, isolated: 'resent' }))
|
|
723
|
+
: run.map((queued) => ({ ...queued, isolated: 'alone' }));
|
|
724
|
+
if (culprits)
|
|
725
|
+
settleAll(run.filter((_, index) => culprits.has(index)), { status: 'failed', error });
|
|
726
|
+
entry.queue.unshift(...resend);
|
|
727
|
+
emitDocument(entry);
|
|
728
|
+
options.onError(error);
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* EC-686 item 1 (the owner's ruling, 2026-10-28: per-position) — a whole-document write the
|
|
732
|
+
* store refused, in the writes that re-send it: what its author changed against the view
|
|
733
|
+
* the snapshot was made from ({@link positionPatches}), each authored where that view had
|
|
734
|
+
* it (its token at the position — EC-667 — else the snapshot's revision). Sent next, each
|
|
735
|
+
* lands on the current row, or — a position someone else changed since — becomes that
|
|
736
|
+
* field's ordinary conflict, the authored value still shown. Never the snapshot again: a
|
|
737
|
+
* whole-document keep-mine would re-send it over every colleague change since.
|
|
738
|
+
*
|
|
739
|
+
* The slot alias, the key the grammar has no position for, follows them alone
|
|
740
|
+
* ({@link QueuedAlias}) when the row still carries the alias the author saw; when someone
|
|
741
|
+
* else changed it too, there is no position for a conflict — theirs is kept and the author
|
|
742
|
+
* is told (`aliasRefused`). `undefined` when nothing can be judged per position: no view,
|
|
743
|
+
* or no revision (an insert-if-absent).
|
|
744
|
+
*/
|
|
745
|
+
function decompose(entry, write, current) {
|
|
746
|
+
const base = write.base ?? entry.created;
|
|
747
|
+
const view = write.authoredOn;
|
|
748
|
+
if (base === undefined || !view)
|
|
749
|
+
return undefined;
|
|
750
|
+
const provenance = shown.get(view);
|
|
751
|
+
const writes = positionPatches(view, write.document).map((patch) => ({
|
|
752
|
+
kind: 'patch',
|
|
753
|
+
patch,
|
|
754
|
+
base: (provenance ? provenanceAt(provenance, touchedPaths(patch)[0]) : undefined) ?? base,
|
|
755
|
+
settle: [],
|
|
756
|
+
}));
|
|
757
|
+
let aliasRefused = false;
|
|
758
|
+
const alias = write.document.alias;
|
|
759
|
+
if (alias !== view.alias && alias !== current.alias) {
|
|
760
|
+
if (current.alias === view.alias)
|
|
761
|
+
writes.push({ kind: 'alias', alias, authored: view.alias, attempt: write.aliasAttempt ?? 0 });
|
|
762
|
+
else
|
|
763
|
+
aliasRefused = true;
|
|
764
|
+
}
|
|
765
|
+
return { writes, aliasRefused };
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* EC-686 — the queue's head is a decomposed alias: it becomes the whole-document write that
|
|
769
|
+
* carries it — the server row the session holds, unchanged, with the alias, at that row's
|
|
770
|
+
* revision (so it changes the alias and nothing else; a write since refuses it, and it is
|
|
771
|
+
* decomposed again, at most {@link ALIAS_ATTEMPTS} times) — or it is done: the row already
|
|
772
|
+
* carries it, or someone else changed the slot since the author's view (theirs kept, told).
|
|
773
|
+
*/
|
|
774
|
+
function resolveAlias(entry, write) {
|
|
775
|
+
entry.queue.shift();
|
|
776
|
+
const row = entry.base;
|
|
777
|
+
if (!row || row.document.alias === write.alias) {
|
|
778
|
+
emitDocument(entry);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (row.document.alias !== write.authored || write.attempt >= ALIAS_ATTEMPTS) {
|
|
782
|
+
emitDocument(entry);
|
|
783
|
+
options.onError(aliasRefusedError(entry.ref));
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
entry.queue.unshift({
|
|
787
|
+
kind: 'replace',
|
|
788
|
+
document: withAlias(row.document, write.alias),
|
|
789
|
+
base: row.revision,
|
|
790
|
+
authoredOn: row.document,
|
|
791
|
+
aliasAttempt: write.attempt + 1,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
491
794
|
/** Send one queued whole-document write at the revision its snapshot was authored against. `false` → stop draining. */
|
|
492
795
|
async function sendReplace(entry, operations, write) {
|
|
493
796
|
const precondition = write.base !== undefined
|
|
@@ -522,8 +825,15 @@ export function createCmsPersister(options) {
|
|
|
522
825
|
adopt(entry, result.value);
|
|
523
826
|
}
|
|
524
827
|
else if (result.error.code === 'conflict' && 'current' in result.error && result.error.current) {
|
|
828
|
+
// EC-686 item 1: refused as a whole — re-sent per position, in its place in the queue.
|
|
829
|
+
const parts = decompose(entry, write, result.error.current.document);
|
|
830
|
+
if (parts)
|
|
831
|
+
entry.queue.unshift(...parts.writes);
|
|
525
832
|
adopt(entry, result.error.current);
|
|
526
|
-
|
|
833
|
+
if (!parts)
|
|
834
|
+
options.onError(result.error);
|
|
835
|
+
else if (parts.aliasRefused)
|
|
836
|
+
options.onError(aliasRefusedError(entry.ref));
|
|
527
837
|
}
|
|
528
838
|
else if (failedWrite(entry, [write], result.error)) {
|
|
529
839
|
return false;
|
|
@@ -554,21 +864,29 @@ export function createCmsPersister(options) {
|
|
|
554
864
|
return;
|
|
555
865
|
continue;
|
|
556
866
|
}
|
|
867
|
+
if (head.kind === 'alias') {
|
|
868
|
+
resolveAlias(entry, head);
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
557
871
|
const log = await liftLog(entry, operations, head);
|
|
558
872
|
// The queue may have moved while the log was read (a superseding edit, a delete): start over.
|
|
559
873
|
if (entry.queue[0] !== head)
|
|
560
874
|
continue;
|
|
561
|
-
const sendBase = (queued) => lift(entry, queued.base,
|
|
875
|
+
const sendBase = (queued) => lift(entry, queued.base, queued.patch, log);
|
|
562
876
|
const first = sendBase(head);
|
|
563
877
|
let end = 1;
|
|
564
|
-
|
|
878
|
+
// EC-686: a patch re-sent after its run was refused travels only with the run's other
|
|
879
|
+
// innocents — or alone ({@link isolate}).
|
|
880
|
+
while (head.isolated !== 'alone' && end < entry.queue.length) {
|
|
565
881
|
const next = entry.queue[end];
|
|
566
|
-
if (next.kind !== 'patch' || sendBase(next) !== first)
|
|
882
|
+
if (next.kind !== 'patch' || next.isolated !== head.isolated || sendBase(next) !== first)
|
|
567
883
|
break;
|
|
568
884
|
end += 1;
|
|
569
885
|
}
|
|
570
886
|
const run = entry.queue.splice(0, end);
|
|
571
887
|
entry.inFlight = run;
|
|
888
|
+
// EC-693: the row the run is sent from — where its applied patches land ({@link footprint}).
|
|
889
|
+
const sentFrom = entry.base?.document;
|
|
572
890
|
const result = await operations.documents.patch({
|
|
573
891
|
projectId: options.projectId,
|
|
574
892
|
ref: entry.ref,
|
|
@@ -580,7 +898,7 @@ export function createCmsPersister(options) {
|
|
|
580
898
|
recordOwn(entry, {
|
|
581
899
|
revision: result.value.revision,
|
|
582
900
|
sentBase: first,
|
|
583
|
-
paths: run.
|
|
901
|
+
paths: footprint(sentFrom, run.map((queued) => queued.patch)),
|
|
584
902
|
});
|
|
585
903
|
adopt(entry, result.value);
|
|
586
904
|
adoptHeld(entry);
|
|
@@ -613,7 +931,7 @@ export function createCmsPersister(options) {
|
|
|
613
931
|
recordOwn(entry, {
|
|
614
932
|
revision: current.revision,
|
|
615
933
|
sentBase: first,
|
|
616
|
-
paths: applied.
|
|
934
|
+
paths: footprint(sentFrom, applied.map((queued) => queued.patch)),
|
|
617
935
|
});
|
|
618
936
|
}
|
|
619
937
|
adopt(entry, current);
|
|
@@ -630,10 +948,19 @@ export function createCmsPersister(options) {
|
|
|
630
948
|
settleAll(run, { status: 'failed', error });
|
|
631
949
|
return;
|
|
632
950
|
}
|
|
951
|
+
// EC-686 — a verdict on a run of several patches, not yet isolated: re-sent once
|
|
952
|
+
// without its culprits. A `not-found` is the document's and names none; a kept
|
|
953
|
+
// failure is not a verdict.
|
|
954
|
+
if (run.length > 1 && head.isolated === undefined && isolates(error)) {
|
|
955
|
+
isolate(entry, run, error);
|
|
956
|
+
adoptHeld(entry);
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
633
959
|
// EC-674 — any other failure: the commits waiting on the run learn of it now. One the
|
|
634
960
|
// 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
|
|
636
|
-
// writes behind it drain — put back, it would be refused
|
|
961
|
+
// re-sends them); a verdict (validation, unauthorized, not-found, an in-process
|
|
962
|
+
// `unknown`) drops them, and the writes behind it drain — put back, it would be refused
|
|
963
|
+
// in front of every later write.
|
|
637
964
|
settleAll(run, { status: 'failed', error });
|
|
638
965
|
const kept = failedWrite(entry, run.map((queued) => ({ ...queued, settle: [] })), error);
|
|
639
966
|
adoptHeld(entry);
|
|
@@ -684,7 +1011,10 @@ export function createCmsPersister(options) {
|
|
|
684
1011
|
},
|
|
685
1012
|
revisionOfView: (view, path) => {
|
|
686
1013
|
const provenance = shown.get(view);
|
|
687
|
-
|
|
1014
|
+
if (!provenance)
|
|
1015
|
+
return undefined;
|
|
1016
|
+
// EC-693: the asked position in the view's structural form, as its shown writes are.
|
|
1017
|
+
return provenanceAt(provenance, path && (expandValuePath(view, path) ?? path));
|
|
688
1018
|
},
|
|
689
1019
|
adoptRecord(record) {
|
|
690
1020
|
const entry = entryOf(record.document);
|
|
@@ -712,6 +1042,8 @@ export function createCmsPersister(options) {
|
|
|
712
1042
|
}
|
|
713
1043
|
if (!entry.base && !entry.provisional && view)
|
|
714
1044
|
entry.provisional = view;
|
|
1045
|
+
// EC-693: where the shown own writes and these patches touch the view they were authored on.
|
|
1046
|
+
const positions = compose(entry, patches)?.positions ?? new Map();
|
|
715
1047
|
let conflictsChanged = false;
|
|
716
1048
|
let unauthored = false;
|
|
717
1049
|
const waits = [];
|
|
@@ -728,15 +1060,19 @@ export function createCmsPersister(options) {
|
|
|
728
1060
|
}
|
|
729
1061
|
// A field with an unresolved conflict: the new value replaces MINE, unsent —
|
|
730
1062
|
// the user decides (keep mine sends it), never an implicit overwrite of theirs.
|
|
731
|
-
|
|
732
|
-
|
|
1063
|
+
// EC-693: the conflict at THIS position, as submitted or in the view — never merely
|
|
1064
|
+
// the first overlapping one (an open conflict at an ancestor would hide it).
|
|
1065
|
+
const open = isValueWrite(patch)
|
|
1066
|
+
? entry.conflicts.find((conflict) => isValueWrite(conflict.patch) && samePosition(conflict.patch, patch, positions))
|
|
1067
|
+
: undefined;
|
|
1068
|
+
if (open) {
|
|
733
1069
|
const replaced = { ...open, patch };
|
|
734
1070
|
entry.conflicts = entry.conflicts.map((c) => (c === open ? replaced : c));
|
|
735
1071
|
conflictsChanged = true;
|
|
736
1072
|
settle({ status: 'refused', conflicts: [replaced] });
|
|
737
1073
|
continue;
|
|
738
1074
|
}
|
|
739
|
-
const base = authoredBase(entry, patch, authored);
|
|
1075
|
+
const base = authoredBase(entry, patch, authored, positions);
|
|
740
1076
|
// A later value write of the same field supersedes an unsent one (keeping the
|
|
741
1077
|
// EARLIER base — the user never saw a foreign value of a field they were typing
|
|
742
1078
|
// in). Only after the last queued whole-document write: one before it is sent
|
|
@@ -819,7 +1155,7 @@ export function createCmsPersister(options) {
|
|
|
819
1155
|
options.onError(result.error);
|
|
820
1156
|
// EC-647: the writes authored on the document we meant to create have no
|
|
821
1157
|
// revision to be based on — never the revision of a row someone else wrote.
|
|
822
|
-
dropQueued(entry, { status: 'failed', error: result.error }, (queued) => queued.base === undefined);
|
|
1158
|
+
dropQueued(entry, { status: 'failed', error: result.error }, (queued) => queued.kind !== 'alias' && queued.base === undefined);
|
|
823
1159
|
}
|
|
824
1160
|
}
|
|
825
1161
|
finally {
|
|
@@ -831,7 +1167,7 @@ export function createCmsPersister(options) {
|
|
|
831
1167
|
await drain(entry);
|
|
832
1168
|
});
|
|
833
1169
|
},
|
|
834
|
-
replace(document, revision) {
|
|
1170
|
+
replace(document, revision, authoredOn) {
|
|
835
1171
|
const doc = canonical(document);
|
|
836
1172
|
const entry = entryOf(doc);
|
|
837
1173
|
if (entry.deleted) {
|
|
@@ -847,7 +1183,8 @@ export function createCmsPersister(options) {
|
|
|
847
1183
|
// holds them), the ones committed after it are sent after it (it does not). EC-647
|
|
848
1184
|
// (Codex F10): one with no authoring revision on a document this session tracks and
|
|
849
1185
|
// did not create is refused there, in order — never sent at the tracked revision.
|
|
850
|
-
|
|
1186
|
+
// EC-686: with the document it was made from — what a refusal decomposes it against.
|
|
1187
|
+
entry.queue.push({ kind: 'replace', document: doc, base: authoredBase(entry, undefined, revision), ...(authoredOn ? { authoredOn } : {}) });
|
|
851
1188
|
emitDocument(entry);
|
|
852
1189
|
schedule(entry);
|
|
853
1190
|
},
|