@elytracms/studio 0.0.95 → 0.0.96
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 +124 -8
- 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 +21 -8
- package/dist/components/cms/cms-store.js +19 -10
- 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 +67 -9
- package/dist/components/cms/use-document-draft.js +112 -39
- 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 +15 -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 +13 -1
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +11 -0
- package/dist/lib/i18n/messages.en.js +14 -1
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +80 -35
- package/dist/lib/operations/cms-persistence.js +348 -85
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +54 -8
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persona/labels.js +1 -0
- package/dist/lib/persona/labels.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,5 +1,7 @@
|
|
|
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;
|
|
3
5
|
/** A patch's identity within one call: op + target path (no two value writes of one path share a run). */
|
|
4
6
|
function targetKey(op, path) {
|
|
5
7
|
return `${op} ${formatValuePath(path)}`;
|
|
@@ -21,6 +23,19 @@ function isValueWrite(patch) {
|
|
|
21
23
|
function samePath(a, b) {
|
|
22
24
|
return a.length === b.length && valuePathsOverlap(a, b);
|
|
23
25
|
}
|
|
26
|
+
/** The earlier of two authoring bases (`undefined` — the provisional view — is the earliest of all). */
|
|
27
|
+
function earlierBase(a, b) {
|
|
28
|
+
return a === undefined || b === undefined ? undefined : Math.min(a, b);
|
|
29
|
+
}
|
|
30
|
+
/** The refusal of a write that names no revision it was authored against (EC-647). */
|
|
31
|
+
function unauthoredError(ref) {
|
|
32
|
+
return {
|
|
33
|
+
code: 'conflict',
|
|
34
|
+
key: documentKey(ref),
|
|
35
|
+
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).',
|
|
36
|
+
messageRef: { key: 'cms.conflict.unauthored' },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
24
39
|
export function createCmsPersister(options) {
|
|
25
40
|
const queue = createPersistQueue((error) => {
|
|
26
41
|
options.onError({
|
|
@@ -29,6 +44,8 @@ export function createCmsPersister(options) {
|
|
|
29
44
|
});
|
|
30
45
|
});
|
|
31
46
|
const entries = new Map();
|
|
47
|
+
/** EC-647: every document value this persister showed or tracked → the revision it is at. */
|
|
48
|
+
const shown = new WeakMap();
|
|
32
49
|
function entryOf(ref) {
|
|
33
50
|
const key = documentKey(ref);
|
|
34
51
|
let entry = entries.get(key);
|
|
@@ -39,11 +56,16 @@ export function createCmsPersister(options) {
|
|
|
39
56
|
provisional: undefined,
|
|
40
57
|
queue: [],
|
|
41
58
|
inFlight: [],
|
|
59
|
+
inFlightReplace: undefined,
|
|
42
60
|
busy: false,
|
|
43
61
|
conflicts: [],
|
|
62
|
+
conflictBases: new Map(),
|
|
44
63
|
held: undefined,
|
|
45
64
|
scheduled: false,
|
|
46
65
|
deleted: false,
|
|
66
|
+
creating: false,
|
|
67
|
+
created: undefined,
|
|
68
|
+
own: [],
|
|
47
69
|
};
|
|
48
70
|
entries.set(key, entry);
|
|
49
71
|
}
|
|
@@ -56,26 +78,47 @@ export function createCmsPersister(options) {
|
|
|
56
78
|
return doc;
|
|
57
79
|
return selfTypeDocument(doc, collection, options.lookupManifest ? { lookupManifest: options.lookupManifest } : {});
|
|
58
80
|
}
|
|
59
|
-
/** The
|
|
81
|
+
/** The queued patches (not whole-document writes). */
|
|
82
|
+
function queuedPatches(entry) {
|
|
83
|
+
return entry.queue.filter((queued) => queued.kind === 'patch');
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The session's view: base ⊕ unresolved mine ⊕ in-flight ⊕ queued (the pure merge, no
|
|
87
|
+
* log; a whole-document write resets it to its snapshot). Registered with the revision
|
|
88
|
+
* it is at (EC-647) — the base's; a provisional view has none.
|
|
89
|
+
*/
|
|
60
90
|
function display(entry) {
|
|
61
91
|
const baseDocument = entry.base?.document ?? entry.provisional;
|
|
62
92
|
if (entry.deleted || !baseDocument)
|
|
63
93
|
return undefined;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
...entry.
|
|
94
|
+
let document = baseDocument;
|
|
95
|
+
let patches = entry.conflicts.map((conflict) => conflict.patch);
|
|
96
|
+
const pending = [
|
|
97
|
+
...entry.inFlight,
|
|
98
|
+
...(entry.inFlightReplace ? [entry.inFlightReplace] : []),
|
|
99
|
+
...entry.queue,
|
|
68
100
|
];
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
101
|
+
for (const queued of pending) {
|
|
102
|
+
if (queued.kind === 'replace') {
|
|
103
|
+
// The snapshot was authored over everything before it (the editor's draft).
|
|
104
|
+
patches = [];
|
|
105
|
+
document = queued.document;
|
|
106
|
+
}
|
|
107
|
+
else
|
|
108
|
+
patches.push(queued.patch);
|
|
109
|
+
}
|
|
110
|
+
if (patches.length > 0)
|
|
111
|
+
document = applyDocumentPatches({ document, patches, since: [] }).document;
|
|
112
|
+
if (entry.base)
|
|
113
|
+
shown.set(document, entry.base.revision);
|
|
114
|
+
return document;
|
|
72
115
|
}
|
|
73
116
|
function emitDocument(entry) {
|
|
74
117
|
if (entry.deleted)
|
|
75
118
|
return;
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
options.onDocument?.(entry.ref,
|
|
119
|
+
const view = display(entry);
|
|
120
|
+
if (view)
|
|
121
|
+
options.onDocument?.(entry.ref, view);
|
|
79
122
|
}
|
|
80
123
|
function emitConflicts(entry) {
|
|
81
124
|
options.onConflicts?.(entry.ref, [...entry.conflicts]);
|
|
@@ -85,6 +128,7 @@ export function createCmsPersister(options) {
|
|
|
85
128
|
if (entry.base && record.revision < entry.base.revision)
|
|
86
129
|
return;
|
|
87
130
|
entry.base = record;
|
|
131
|
+
shown.set(record.document, record.revision);
|
|
88
132
|
options.onRecord?.(record);
|
|
89
133
|
emitDocument(entry);
|
|
90
134
|
}
|
|
@@ -101,11 +145,25 @@ export function createCmsPersister(options) {
|
|
|
101
145
|
if (!entry.base || held.revision > entry.base.revision)
|
|
102
146
|
adopt(entry, held);
|
|
103
147
|
}
|
|
148
|
+
function settleAll(queued, outcome) {
|
|
149
|
+
for (const item of queued)
|
|
150
|
+
for (const settle of item.settle)
|
|
151
|
+
settle(outcome);
|
|
152
|
+
}
|
|
153
|
+
/** Drop every queued write, settling its waiters (the document is gone, or the writes cannot be based). */
|
|
154
|
+
function dropQueued(entry, outcome, which = () => true) {
|
|
155
|
+
const dropped = entry.queue.filter(which);
|
|
156
|
+
entry.queue = entry.queue.filter((queued) => !which(queued));
|
|
157
|
+
settleAll(dropped.filter((queued) => queued.kind === 'patch'), outcome);
|
|
158
|
+
}
|
|
104
159
|
function markGone(entry) {
|
|
105
160
|
if (entry.deleted)
|
|
106
161
|
return;
|
|
107
162
|
entry.deleted = true;
|
|
108
|
-
entry
|
|
163
|
+
dropQueued(entry, {
|
|
164
|
+
status: 'failed',
|
|
165
|
+
error: { code: 'not-found', message: 'The document is gone.', entity: 'cms-document', key: documentKey(entry.ref) },
|
|
166
|
+
});
|
|
109
167
|
options.onDocument?.(entry.ref, undefined);
|
|
110
168
|
}
|
|
111
169
|
function schedule(entry) {
|
|
@@ -118,6 +176,126 @@ export function createCmsPersister(options) {
|
|
|
118
176
|
adoptHeld(entry);
|
|
119
177
|
});
|
|
120
178
|
}
|
|
179
|
+
/** EC-647: record one of this persister's acknowledged writes (the own-write ledger). */
|
|
180
|
+
function recordOwn(entry, write) {
|
|
181
|
+
entry.own.push(write);
|
|
182
|
+
if (entry.own.length > OWN_WRITES_KEPT)
|
|
183
|
+
entry.own.splice(0, entry.own.length - OWN_WRITES_KEPT);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* EC-647 — THE lift: the base a write authored at `base` is SENT at. Only across this
|
|
187
|
+
* persister's own writes (see the module doc): a run of consecutive own revisions
|
|
188
|
+
* right after the base, or an own write that covered every position `paths` touch and
|
|
189
|
+
* was sent at or below the base. Never across a revision anyone else wrote.
|
|
190
|
+
*/
|
|
191
|
+
function lift(entry, base, paths) {
|
|
192
|
+
let lifted = base;
|
|
193
|
+
for (;;) {
|
|
194
|
+
if (entry.own.some((write) => write.revision === lifted + 1)) {
|
|
195
|
+
lifted += 1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const covering = paths
|
|
199
|
+
? entry.own.find((write) => write.revision > lifted &&
|
|
200
|
+
write.paths !== undefined &&
|
|
201
|
+
write.sentBase !== undefined &&
|
|
202
|
+
write.sentBase <= lifted &&
|
|
203
|
+
paths.every((path) => write.paths.some((covered) => isValuePathPrefix(covered, path))))
|
|
204
|
+
: undefined;
|
|
205
|
+
if (!covering)
|
|
206
|
+
return lifted;
|
|
207
|
+
lifted = covering.revision;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* EC-647 — the base a write is QUEUED with: the revision it was authored against, or
|
|
212
|
+
* the earlier base of an own unacknowledged write on the same positions (the view
|
|
213
|
+
* showed mine there, not whatever may sit underneath it).
|
|
214
|
+
*/
|
|
215
|
+
function authoredBase(entry, patch, authored) {
|
|
216
|
+
let base = authored;
|
|
217
|
+
const overlaps = (other) => patch === undefined || patchesOverlap(other, patch);
|
|
218
|
+
for (const queued of [...entry.inFlight, ...entry.queue]) {
|
|
219
|
+
if (queued.kind === 'replace' || overlaps(queued.patch))
|
|
220
|
+
base = earlierBase(base, queued.base);
|
|
221
|
+
}
|
|
222
|
+
if (entry.inFlightReplace)
|
|
223
|
+
base = earlierBase(base, entry.inFlightReplace.base);
|
|
224
|
+
for (const conflict of entry.conflicts) {
|
|
225
|
+
if (overlaps(conflict.patch))
|
|
226
|
+
base = earlierBase(base, entry.conflictBases.get(conflict.id));
|
|
227
|
+
}
|
|
228
|
+
return base;
|
|
229
|
+
}
|
|
230
|
+
/** Whether a write may be authored on a provisional view (our own create, or an untracked document). */
|
|
231
|
+
function provisionalAllowed(entry) {
|
|
232
|
+
return entry.creating || entry.created !== undefined || !entry.base;
|
|
233
|
+
}
|
|
234
|
+
/** Our own create landed (or failed): base every write authored on its provisional view. */
|
|
235
|
+
function baseOnCreated(entry) {
|
|
236
|
+
if (entry.created === undefined)
|
|
237
|
+
return;
|
|
238
|
+
for (const queued of entry.queue)
|
|
239
|
+
if (queued.base === undefined)
|
|
240
|
+
queued.base = entry.created;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* EC-647 — writes authored on the provisional view of a document this session holds no
|
|
244
|
+
* revision for (a staged transfer): read it; when it still EQUALS the view they were
|
|
245
|
+
* authored on, that read's revision is theirs — otherwise each is refused as a
|
|
246
|
+
* conflict on its position (theirs = the read). Never "whatever revision the read
|
|
247
|
+
* returns". `false` when the read failed (the writes stay queued, the error shows).
|
|
248
|
+
*/
|
|
249
|
+
async function baseOnRead(entry, operations) {
|
|
250
|
+
if (!entry.queue.some((queued) => queued.kind === 'patch' && queued.base === undefined))
|
|
251
|
+
return true;
|
|
252
|
+
if (entry.created !== undefined) {
|
|
253
|
+
baseOnCreated(entry);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
if (entry.base) {
|
|
257
|
+
// A tracked document this session did not create: commit already refused these.
|
|
258
|
+
const error = unauthoredError(entry.ref);
|
|
259
|
+
dropQueued(entry, { status: 'failed', error }, (queued) => queued.kind === 'patch' && queued.base === undefined);
|
|
260
|
+
options.onError(error);
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
const read = await operations.documents.get({ projectId: options.projectId, ref: entry.ref });
|
|
264
|
+
if (!read.ok) {
|
|
265
|
+
options.onError(read.error);
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
const record = read.value;
|
|
269
|
+
const authoredOn = entry.provisional;
|
|
270
|
+
if (authoredOn && semanticValueEqual(record.document, authoredOn)) {
|
|
271
|
+
for (const queued of entry.queue)
|
|
272
|
+
if (queued.kind === 'patch' && queued.base === undefined)
|
|
273
|
+
queued.base = record.revision;
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
const refused = [];
|
|
277
|
+
for (const queued of queuedPatches(entry)) {
|
|
278
|
+
if (queued.base !== undefined)
|
|
279
|
+
continue;
|
|
280
|
+
const path = patchTarget(queued.patch);
|
|
281
|
+
const conflict = upsertConflict(entry, {
|
|
282
|
+
patch: queued.patch,
|
|
283
|
+
reason: 'conflict',
|
|
284
|
+
path,
|
|
285
|
+
current: readValueAt(record.document, path),
|
|
286
|
+
detail: 'the document changed since the view this edit was made on',
|
|
287
|
+
}, record.revision, record.revision);
|
|
288
|
+
refused.push(conflict);
|
|
289
|
+
for (const settle of queued.settle)
|
|
290
|
+
settle({ status: 'refused', conflicts: [conflict] });
|
|
291
|
+
}
|
|
292
|
+
entry.queue = entry.queue.filter((queued) => !(queued.kind === 'patch' && queued.base === undefined));
|
|
293
|
+
if (refused.length > 0)
|
|
294
|
+
emitConflicts(entry);
|
|
295
|
+
}
|
|
296
|
+
adopt(entry, record);
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
121
299
|
/** Who changed a refused path — the newest patch-log row in (base, current] touching it. Best-effort. */
|
|
122
300
|
async function resolveActors(entry, sentBase, refused) {
|
|
123
301
|
try {
|
|
@@ -149,25 +327,77 @@ export function createCmsPersister(options) {
|
|
|
149
327
|
// The actor is decoration on a conflict the user can already resolve.
|
|
150
328
|
}
|
|
151
329
|
}
|
|
152
|
-
/** Send
|
|
330
|
+
/** Send one queued whole-document write at the revision its snapshot was authored against. `false` → stop draining. */
|
|
331
|
+
async function sendReplace(entry, operations, write) {
|
|
332
|
+
const precondition = write.base !== undefined
|
|
333
|
+
? { revision: lift(entry, write.base, undefined) }
|
|
334
|
+
: entry.created !== undefined
|
|
335
|
+
? { revision: lift(entry, entry.created, undefined) }
|
|
336
|
+
: !entry.base
|
|
337
|
+
? { ifAbsent: true }
|
|
338
|
+
: undefined;
|
|
339
|
+
entry.queue.shift();
|
|
340
|
+
if (!precondition) {
|
|
341
|
+
// A tracked document this session did not create, written whole from a view with no revision.
|
|
342
|
+
options.onError(unauthoredError(entry.ref));
|
|
343
|
+
emitDocument(entry);
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
entry.inFlightReplace = write;
|
|
347
|
+
entry.busy = true;
|
|
348
|
+
try {
|
|
349
|
+
const result = await operations.documents.replace({
|
|
350
|
+
projectId: options.projectId,
|
|
351
|
+
document: write.document,
|
|
352
|
+
precondition,
|
|
353
|
+
});
|
|
354
|
+
entry.inFlightReplace = undefined;
|
|
355
|
+
if (result.ok) {
|
|
356
|
+
recordOwn(entry, {
|
|
357
|
+
revision: result.value.revision,
|
|
358
|
+
sentBase: 'revision' in precondition ? precondition.revision : undefined,
|
|
359
|
+
paths: undefined,
|
|
360
|
+
});
|
|
361
|
+
adopt(entry, result.value);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
if (result.error.code === 'conflict' && 'current' in result.error && result.error.current) {
|
|
365
|
+
adopt(entry, result.error.current);
|
|
366
|
+
}
|
|
367
|
+
else
|
|
368
|
+
emitDocument(entry);
|
|
369
|
+
options.onError(result.error);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
entry.inFlightReplace = undefined;
|
|
374
|
+
entry.busy = false;
|
|
375
|
+
adoptHeld(entry);
|
|
376
|
+
}
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
/** Send the queue — runs of equal (lifted) base, whole-document writes in their place — until it is empty or a write fails. */
|
|
153
380
|
async function drain(entry) {
|
|
154
381
|
while (entry.queue.length > 0 && !entry.deleted) {
|
|
155
382
|
const operations = await options.getOperations();
|
|
156
|
-
if (!entry
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
entry.base = read.value;
|
|
383
|
+
if (!(await baseOnRead(entry, operations)))
|
|
384
|
+
return;
|
|
385
|
+
const head = entry.queue[0];
|
|
386
|
+
if (!head)
|
|
387
|
+
return;
|
|
388
|
+
if (head.kind === 'replace') {
|
|
389
|
+
await sendReplace(entry, operations, head);
|
|
390
|
+
continue;
|
|
165
391
|
}
|
|
166
|
-
const
|
|
167
|
-
const first =
|
|
392
|
+
const sendBase = (queued) => lift(entry, queued.base, touchedPaths(queued.patch));
|
|
393
|
+
const first = sendBase(head);
|
|
168
394
|
let end = 1;
|
|
169
|
-
while (end < entry.queue.length
|
|
395
|
+
while (end < entry.queue.length) {
|
|
396
|
+
const next = entry.queue[end];
|
|
397
|
+
if (next.kind !== 'patch' || sendBase(next) !== first)
|
|
398
|
+
break;
|
|
170
399
|
end += 1;
|
|
400
|
+
}
|
|
171
401
|
const run = entry.queue.splice(0, end);
|
|
172
402
|
entry.inFlight = run;
|
|
173
403
|
const result = await operations.documents.patch({
|
|
@@ -178,7 +408,11 @@ export function createCmsPersister(options) {
|
|
|
178
408
|
});
|
|
179
409
|
entry.inFlight = [];
|
|
180
410
|
if (result.ok) {
|
|
181
|
-
|
|
411
|
+
recordOwn(entry, {
|
|
412
|
+
revision: result.value.revision,
|
|
413
|
+
sentBase: first,
|
|
414
|
+
paths: run.flatMap((queued) => touchedPaths(queued.patch)),
|
|
415
|
+
});
|
|
182
416
|
adopt(entry, result.value);
|
|
183
417
|
adoptHeld(entry);
|
|
184
418
|
settleAll(run, { status: 'applied', record: result.value });
|
|
@@ -199,12 +433,20 @@ export function createCmsPersister(options) {
|
|
|
199
433
|
continue;
|
|
200
434
|
}
|
|
201
435
|
// MINE is what the user wrote (the queued patch), not the canonicalised echo.
|
|
202
|
-
const conflict = upsertConflict(entry, { ...refused, patch: queued.patch }, current.revision);
|
|
436
|
+
const conflict = upsertConflict(entry, { ...refused, patch: queued.patch }, current.revision, queued.base);
|
|
203
437
|
conflicts.push(conflict);
|
|
204
438
|
for (const settle of queued.settle)
|
|
205
439
|
settle({ status: 'refused', conflicts: [conflict] });
|
|
206
440
|
}
|
|
207
|
-
|
|
441
|
+
// A partially applied run IS a write of ours (the row it returns is after it);
|
|
442
|
+
// a wholly refused one wrote nothing — its row is someone else's revision.
|
|
443
|
+
if (applied.length > 0) {
|
|
444
|
+
recordOwn(entry, {
|
|
445
|
+
revision: current.revision,
|
|
446
|
+
sentBase: first,
|
|
447
|
+
paths: applied.flatMap((queued) => touchedPaths(queued.patch)),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
208
450
|
adopt(entry, current);
|
|
209
451
|
adoptHeld(entry);
|
|
210
452
|
emitConflicts(entry);
|
|
@@ -229,23 +471,7 @@ export function createCmsPersister(options) {
|
|
|
229
471
|
return;
|
|
230
472
|
}
|
|
231
473
|
}
|
|
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) {
|
|
474
|
+
function upsertConflict(entry, refused, revision, base) {
|
|
249
475
|
const conflict = {
|
|
250
476
|
id: conflictId(entry.ref, refused.path),
|
|
251
477
|
ref: entry.ref,
|
|
@@ -256,6 +482,7 @@ export function createCmsPersister(options) {
|
|
|
256
482
|
revision,
|
|
257
483
|
};
|
|
258
484
|
entry.conflicts = [...entry.conflicts.filter((c) => c.id !== conflict.id), conflict];
|
|
485
|
+
entry.conflictBases.set(conflict.id, base);
|
|
259
486
|
return conflict;
|
|
260
487
|
}
|
|
261
488
|
function command(entry, run) {
|
|
@@ -276,6 +503,7 @@ export function createCmsPersister(options) {
|
|
|
276
503
|
return {
|
|
277
504
|
track(row) {
|
|
278
505
|
const entry = entryOf(row.document);
|
|
506
|
+
shown.set(row.document, row.revision);
|
|
279
507
|
if (!entry.base || row.revision > entry.base.revision)
|
|
280
508
|
entry.base = row;
|
|
281
509
|
},
|
|
@@ -284,6 +512,7 @@ export function createCmsPersister(options) {
|
|
|
284
512
|
const entry = entries.get(documentKey(ref));
|
|
285
513
|
return entry ? display(entry) : undefined;
|
|
286
514
|
},
|
|
515
|
+
revisionOfView: (view) => shown.get(view),
|
|
287
516
|
adoptRecord(record) {
|
|
288
517
|
const entry = entryOf(record.document);
|
|
289
518
|
if (entry.deleted)
|
|
@@ -291,7 +520,7 @@ export function createCmsPersister(options) {
|
|
|
291
520
|
if (!entry.base || record.revision > entry.base.revision)
|
|
292
521
|
adopt(entry, record);
|
|
293
522
|
},
|
|
294
|
-
commit(ref, patches,
|
|
523
|
+
commit(ref, patches, bases, view) {
|
|
295
524
|
const entry = entryOf(ref);
|
|
296
525
|
if (patches.length === 0 || entry.deleted) {
|
|
297
526
|
return Promise.resolve({
|
|
@@ -299,14 +528,28 @@ export function createCmsPersister(options) {
|
|
|
299
528
|
error: { code: 'not-found', message: 'The document is gone.', entity: 'cms-document', key: documentKey(ref) },
|
|
300
529
|
});
|
|
301
530
|
}
|
|
531
|
+
if (!Array.isArray(bases)) {
|
|
532
|
+
// EC-647: a writer that names no authoring revision at all — never given the tracked one.
|
|
533
|
+
const error = unauthoredError(ref);
|
|
534
|
+
options.onError(error);
|
|
535
|
+
return Promise.resolve({ status: 'failed', error });
|
|
536
|
+
}
|
|
302
537
|
if (!entry.base && !entry.provisional && view)
|
|
303
538
|
entry.provisional = view;
|
|
304
539
|
let conflictsChanged = false;
|
|
540
|
+
let unauthored = false;
|
|
305
541
|
const waits = [];
|
|
306
542
|
for (const [index, patch] of patches.entries()) {
|
|
307
|
-
const
|
|
543
|
+
const authored = bases[index];
|
|
308
544
|
let settle;
|
|
309
545
|
waits.push(new Promise((resolve) => (settle = resolve)));
|
|
546
|
+
if (authored === undefined && !provisionalAllowed(entry)) {
|
|
547
|
+
// EC-647: authored on a view with no revision, on a document this session
|
|
548
|
+
// tracks and did not create — it cannot be checked, so it is not sent.
|
|
549
|
+
unauthored = true;
|
|
550
|
+
settle({ status: 'failed', error: unauthoredError(ref) });
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
310
553
|
// A field with an unresolved conflict: the new value replaces MINE, unsent —
|
|
311
554
|
// the user decides (keep mine sends it), never an implicit overwrite of theirs.
|
|
312
555
|
const open = entry.conflicts.find((conflict) => patchesOverlap(conflict.patch, patch));
|
|
@@ -317,18 +560,33 @@ export function createCmsPersister(options) {
|
|
|
317
560
|
settle({ status: 'refused', conflicts: [replaced] });
|
|
318
561
|
continue;
|
|
319
562
|
}
|
|
563
|
+
const base = authoredBase(entry, patch, authored);
|
|
320
564
|
// 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
|
|
565
|
+
// EARLIER base — the user never saw a foreign value of a field they were typing
|
|
566
|
+
// in). Only after the last queued whole-document write: one before it is sent
|
|
567
|
+
// before the snapshot that was authored over it.
|
|
322
568
|
if (isValueWrite(patch)) {
|
|
323
|
-
|
|
569
|
+
let after = 0;
|
|
570
|
+
entry.queue.forEach((queued, at) => {
|
|
571
|
+
if (queued.kind === 'replace')
|
|
572
|
+
after = at + 1;
|
|
573
|
+
});
|
|
574
|
+
const pending = entry.queue.findIndex((queued, at) => at >= after && queued.kind === 'patch' && isValueWrite(queued.patch) && samePath(queued.patch.path, patch.path));
|
|
324
575
|
if (pending >= 0) {
|
|
325
576
|
const superseded = entry.queue[pending];
|
|
326
|
-
entry.queue[pending] = {
|
|
577
|
+
entry.queue[pending] = {
|
|
578
|
+
kind: 'patch',
|
|
579
|
+
patch,
|
|
580
|
+
base: earlierBase(superseded.base, base),
|
|
581
|
+
settle: [...superseded.settle, settle],
|
|
582
|
+
};
|
|
327
583
|
continue;
|
|
328
584
|
}
|
|
329
585
|
}
|
|
330
|
-
entry.queue.push({ patch, base
|
|
586
|
+
entry.queue.push({ kind: 'patch', patch, base, settle: [settle] });
|
|
331
587
|
}
|
|
588
|
+
if (unauthored)
|
|
589
|
+
options.onError(unauthoredError(ref));
|
|
332
590
|
emitDocument(entry);
|
|
333
591
|
if (conflictsChanged)
|
|
334
592
|
emitConflicts(entry);
|
|
@@ -347,7 +605,12 @@ export function createCmsPersister(options) {
|
|
|
347
605
|
create(document) {
|
|
348
606
|
const doc = canonical(document);
|
|
349
607
|
const entry = entryOf(doc);
|
|
608
|
+
// A fresh document: nothing of an earlier incarnation of this ref carries over.
|
|
350
609
|
entry.deleted = false;
|
|
610
|
+
entry.base = undefined;
|
|
611
|
+
entry.own = [];
|
|
612
|
+
entry.created = undefined;
|
|
613
|
+
entry.creating = true;
|
|
351
614
|
// Optimistic: the session shows the document at once; the base is the created row.
|
|
352
615
|
entry.provisional = doc;
|
|
353
616
|
options.onDocument?.(entry.ref, doc);
|
|
@@ -360,12 +623,21 @@ export function createCmsPersister(options) {
|
|
|
360
623
|
document: doc,
|
|
361
624
|
precondition: { ifAbsent: true },
|
|
362
625
|
});
|
|
363
|
-
if (result.ok)
|
|
626
|
+
if (result.ok) {
|
|
627
|
+
entry.created = result.value.revision;
|
|
628
|
+
recordOwn(entry, { revision: result.value.revision, sentBase: undefined, paths: undefined });
|
|
629
|
+
baseOnCreated(entry);
|
|
364
630
|
adopt(entry, result.value);
|
|
365
|
-
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
366
633
|
options.onError(result.error);
|
|
634
|
+
// EC-647: the writes authored on the document we meant to create have no
|
|
635
|
+
// revision to be based on — never the revision of a row someone else wrote.
|
|
636
|
+
dropQueued(entry, { status: 'failed', error: result.error }, (queued) => queued.base === undefined);
|
|
637
|
+
}
|
|
367
638
|
}
|
|
368
639
|
finally {
|
|
640
|
+
entry.creating = false;
|
|
369
641
|
entry.busy = false;
|
|
370
642
|
adoptHeld(entry);
|
|
371
643
|
}
|
|
@@ -373,41 +645,27 @@ export function createCmsPersister(options) {
|
|
|
373
645
|
await drain(entry);
|
|
374
646
|
});
|
|
375
647
|
},
|
|
376
|
-
replace(document) {
|
|
648
|
+
replace(document, revision) {
|
|
377
649
|
const doc = canonical(document);
|
|
378
650
|
const entry = entryOf(doc);
|
|
379
|
-
if (!entry.base)
|
|
651
|
+
if (!entry.base && !entry.provisional)
|
|
380
652
|
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
|
-
});
|
|
653
|
+
// Queued in its place: the patches committed before it are sent first (the snapshot
|
|
654
|
+
// holds them), the ones committed after it are sent after it (it does not). EC-647
|
|
655
|
+
// (Codex F10): one with no authoring revision on a document this session tracks and
|
|
656
|
+
// did not create is refused there, in order — never sent at the tracked revision.
|
|
657
|
+
entry.queue.push({ kind: 'replace', document: doc, base: authoredBase(entry, undefined, revision) });
|
|
658
|
+
emitDocument(entry);
|
|
659
|
+
schedule(entry);
|
|
406
660
|
},
|
|
407
661
|
deleteDocument(ref) {
|
|
408
662
|
const entry = entryOf(ref);
|
|
409
|
-
entry
|
|
663
|
+
dropQueued(entry, {
|
|
664
|
+
status: 'failed',
|
|
665
|
+
error: { code: 'not-found', message: 'The document is gone.', entity: 'cms-document', key: documentKey(ref) },
|
|
666
|
+
});
|
|
410
667
|
entry.conflicts = [];
|
|
668
|
+
entry.conflictBases.clear();
|
|
411
669
|
entry.deleted = true;
|
|
412
670
|
emitConflicts(entry);
|
|
413
671
|
command(entry, (operations) => operations.documents.delete({ projectId: options.projectId, ref }));
|
|
@@ -420,8 +678,10 @@ export function createCmsPersister(options) {
|
|
|
420
678
|
entry.busy = true;
|
|
421
679
|
try {
|
|
422
680
|
const result = await operations.documents.unpublish({ projectId: options.projectId, ref });
|
|
423
|
-
if (result.ok)
|
|
681
|
+
if (result.ok) {
|
|
682
|
+
recordOwn(entry, { revision: result.value.revision, sentBase: undefined, paths: [] });
|
|
424
683
|
adopt(entry, result.value);
|
|
684
|
+
}
|
|
425
685
|
else
|
|
426
686
|
options.onError(result.error);
|
|
427
687
|
}
|
|
@@ -458,9 +718,12 @@ export function createCmsPersister(options) {
|
|
|
458
718
|
if (!conflict)
|
|
459
719
|
return;
|
|
460
720
|
entry.conflicts = entry.conflicts.filter((c) => c !== conflict);
|
|
721
|
+
entry.conflictBases.delete(conflict.id);
|
|
461
722
|
if (choice === 'mine') {
|
|
462
|
-
// Keep mine: re-send at the CURRENT base — a visible, explicit second write
|
|
463
|
-
|
|
723
|
+
// Keep mine: re-send at the CURRENT base — a visible, explicit second write, and
|
|
724
|
+
// the one deliberate "at the current base" write (EC-647): the user chose it
|
|
725
|
+
// after seeing theirs.
|
|
726
|
+
entry.queue.push({ kind: 'patch', patch: conflict.patch, base: entry.base?.revision, settle: [] });
|
|
464
727
|
schedule(entry);
|
|
465
728
|
}
|
|
466
729
|
emitDocument(entry);
|