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