@elytracms/studio 0.0.97 → 0.0.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/convex/assets.ts +16 -2
- package/convex/ingest-attempt.test.ts +161 -0
- package/convex/ingest.test.ts +185 -9
- package/convex/ingest.ts +136 -24
- package/convex/prototype-key.test.ts +92 -0
- package/convex/records-zod-refinements.convex.test.ts +94 -0
- package/dist/components/cms/cms-store.d.ts +2 -4
- package/dist/components/cms/cms-store.js +4 -22
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/field-control.d.ts +12 -1
- package/dist/components/cms/field-control.js +16 -1
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/import-batch.d.ts +23 -0
- package/dist/components/cms/import-batch.js +43 -0
- package/dist/components/cms/import-batch.js.map +1 -0
- package/dist/components/cms/rich-text/editor.js +13 -10
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/value.d.ts +9 -0
- package/dist/components/cms/rich-text/value.js +13 -0
- package/dist/components/cms/rich-text/value.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +35 -2
- package/dist/components/cms/use-document-draft.js +42 -15
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/records/record-lens.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 +3 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +3 -0
- package/dist/lib/i18n/messages.en.js +3 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +9 -4
- package/dist/lib/operations/cms-persistence.js +132 -29
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persona/labels.js +1 -0
- package/dist/lib/persona/labels.js.map +1 -1
- package/package.json +4 -3
|
@@ -42,7 +42,9 @@ import type { OperationError, OperationsClient } from './operations-package.js';
|
|
|
42
42
|
* pending whole-document write its snapshot's authoring revision, and over that each
|
|
43
43
|
* own unacknowledged write shown (queued, in flight, an open conflict's "mine") with
|
|
44
44
|
* the base it was authored at. `revisionOfView(view, path)` is the revision the content
|
|
45
|
-
* at `path` was authored against
|
|
45
|
+
* at `path` was authored against: the earliest of the floor and of the shown writes that
|
|
46
|
+
* OVERLAP `path` — at it, at an ancestor, or at a descendant, whose content the value at
|
|
47
|
+
* `path` contains (EC-681, Codex F23); a sibling's write does not lower it.
|
|
46
48
|
* `revisionOfView(view)` — a whole-document writer — the EARLIEST of all. The record is
|
|
47
49
|
* immutable, so it outlives the pending write: an edit started on a view that showed a
|
|
48
50
|
* snapshot keeps the snapshot's revision after the snapshot is refused or fails
|
|
@@ -59,9 +61,12 @@ import type { OperationError, OperationsClient } from './operations-package.js';
|
|
|
59
61
|
* after it (nothing foreign happened in between), or to an own write's revision when
|
|
60
62
|
* the patch only touches positions that write covered and the write was sent at or
|
|
61
63
|
* below the patch's base (the store refused every foreign change of those positions in
|
|
62
|
-
* between, or the write would not have applied)
|
|
63
|
-
*
|
|
64
|
-
*
|
|
64
|
+
* between, or the write would not have applied) — or, EC-681, across a revision someone
|
|
65
|
+
* else wrote that the patch log shows touched none of the patch's positions (the store
|
|
66
|
+
* would not refuse the patch on it), read only when an own write beyond it would
|
|
67
|
+
* otherwise refuse the patch. Without it, the session's second edit of a position, made
|
|
68
|
+
* at the revision its first was authored against, would be refused by its own first
|
|
69
|
+
* write — and an edit of a parent, authored over an own child write, by that child's.
|
|
65
70
|
* - **The provisional view** (`undefined` authoring revision): a document whose view
|
|
66
71
|
* has no revision yet — our own create in flight, or a document the session never
|
|
67
72
|
* tracked (a staged transfer). Writes on it are based on the revision our create
|
|
@@ -1,23 +1,46 @@
|
|
|
1
|
-
import { applyDocumentPatches, documentKey, formatValuePath, isValuePathPrefix, readValueAt, selfTypeDocument, semanticValueEqual, touchedPaths, valuePathsOverlap, } from '@elytracms/core/cms-core';
|
|
1
|
+
import { applyDocumentPatches, documentKey, formatValuePath, isValuePathPrefix, readValueAt, selfTypeDocument, semanticValueEqual, touchedPaths, touchedPathsOfEntry, valuePathsOverlap, } from '@elytracms/core/cms-core';
|
|
2
2
|
import { createPersistQueue } from './persist-queue.js';
|
|
3
3
|
/** How many own writes a document's ledger keeps (an older base just stops lifting — a conflict, never a borrow). */
|
|
4
4
|
const OWN_WRITES_KEPT = 256;
|
|
5
5
|
/**
|
|
6
|
-
* EC-667 — the revision the content a view shows at `path` was authored against:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* EC-667 → EC-681 — the revision the content a view shows at `path` was authored against:
|
|
7
|
+
* a token describes the WHOLE value a writer of `path` may replace. It is the earliest of
|
|
8
|
+
* the floor and of every shown write whose positions OVERLAP `path` structurally — a write
|
|
9
|
+
* at an ancestor of `path`, at `path`, or at a DESCENDANT of it (the value at `path`
|
|
10
|
+
* contains that write's content). A write at a sibling position does not lower it.
|
|
11
|
+
* `undefined` path — a whole-document writer — the earliest of all.
|
|
12
|
+
*
|
|
13
|
+
* Minted when the view is shown, never when the edit commits (Codex F18, F23): a
|
|
14
|
+
* descendant write may be refused and its conflict resolved before an edit of `path`
|
|
15
|
+
* started on this view commits, and that edit still carries the descendant's old content.
|
|
16
|
+
* The own descendant write that then APPLIES is lifted across at send time ({@link lift}).
|
|
11
17
|
*/
|
|
12
18
|
function provenanceAt(provenance, path) {
|
|
13
19
|
let at = provenance.floor;
|
|
14
20
|
for (const write of provenance.writes) {
|
|
15
|
-
if (path === undefined || write.paths.some((
|
|
21
|
+
if (path === undefined || write.paths.some((written) => valuePathsOverlap(written, path))) {
|
|
16
22
|
at = earlierBase(at, write.base);
|
|
17
23
|
}
|
|
18
24
|
}
|
|
19
25
|
return at;
|
|
20
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* EC-681 — whether a write at `a` may overlap one at `b` as the STORE judges it: THE
|
|
29
|
+
* conflict relation (`valuePathsOverlap`) unless BOTH name a component node — then any two
|
|
30
|
+
* in overlapping field positions. The store compares node paths EXPANDED by their ancestry
|
|
31
|
+
* (`value-path.ts`); the persister holds the submitted form, so it never claims two node
|
|
32
|
+
* paths of one field apart. Conservative by construction: a "may" that is wrong costs a
|
|
33
|
+
* lift (a conflict at worst), never a borrow.
|
|
34
|
+
*/
|
|
35
|
+
function mayOverlap(a, b) {
|
|
36
|
+
const fieldOf = (path) => {
|
|
37
|
+
const node = path.findIndex((segment) => segment.kind === 'node');
|
|
38
|
+
return node < 0 ? undefined : path.slice(0, node);
|
|
39
|
+
};
|
|
40
|
+
const fieldA = fieldOf(a);
|
|
41
|
+
const fieldB = fieldOf(b);
|
|
42
|
+
return fieldA && fieldB ? valuePathsOverlap(fieldA, fieldB) : valuePathsOverlap(a, b);
|
|
43
|
+
}
|
|
21
44
|
/** A patch's identity within one call: op + target path (no two value writes of one path share a run). */
|
|
22
45
|
function targetKey(op, path) {
|
|
23
46
|
return `${op} ${formatValuePath(path)}`;
|
|
@@ -63,6 +86,25 @@ export function importWrite(persister, document, shown) {
|
|
|
63
86
|
else
|
|
64
87
|
persister.create(document);
|
|
65
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* EC-674 — THE classification of a write that failed without a per-field conflict (one
|
|
91
|
+
* declaration, read by the whole-document and the patch path): `true` = KEPT — put back,
|
|
92
|
+
* still shown, re-sent by the next commit or forced flush — for every failure the store did
|
|
93
|
+
* not JUDGE: `backend-unavailable` and `unknown`, whatever the message. `false` = DROPPED
|
|
94
|
+
* for the store's verdicts, `validation`, `unauthorized` and `not-found`: the same write is
|
|
95
|
+
* refused the same way again, so it is reported and the writes behind it drain.
|
|
96
|
+
*
|
|
97
|
+
* Why every `unknown` (measured, EC-674): no adapter emits `backend-unavailable`, and a
|
|
98
|
+
* network failure, a gateway's 5xx, an expired session and a crash all arrive as `unknown`
|
|
99
|
+
* with whatever text the transport carried — no message set can name every transient (an
|
|
100
|
+
* OCC exhaustion, a timeout, a new runtime's wording). Dropping one loses the edit; keeping
|
|
101
|
+
* a deterministic `unknown` (an undeclared field type's `canonicalise` crash) loses nothing:
|
|
102
|
+
* its error stays visible, each commit or flush re-sends it once, and the document's later
|
|
103
|
+
* writes wait behind it. Loss ranks above a wedge (the v1 bar).
|
|
104
|
+
*/
|
|
105
|
+
function keepsFailedWrite(error) {
|
|
106
|
+
return error.code === 'backend-unavailable' || error.code === 'unknown';
|
|
107
|
+
}
|
|
66
108
|
/** A write against a document this session no longer holds (deleted here or elsewhere). */
|
|
67
109
|
function goneError(ref) {
|
|
68
110
|
return { code: 'not-found', message: 'The document is gone.', entity: 'cms-document', key: documentKey(ref) };
|
|
@@ -246,12 +288,23 @@ export function createCmsPersister(options) {
|
|
|
246
288
|
entry.own.splice(0, entry.own.length - OWN_WRITES_KEPT);
|
|
247
289
|
}
|
|
248
290
|
/**
|
|
249
|
-
* EC-647 — THE lift: the base a write authored at `base` is SENT at.
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
291
|
+
* EC-647 → EC-681 — THE lift: the base a write authored at `base` is SENT at. It crosses
|
|
292
|
+
* a revision only where the store's refusal on it would be a refusal of OUR content —
|
|
293
|
+
* never a change by anyone else of a position the write touches. Three rules, applied
|
|
294
|
+
* until none moves it:
|
|
295
|
+
* 1. the next revision is one of this persister's own writes (a run of own writes);
|
|
296
|
+
* 2. an own write that covered every position `paths` touch (each at or under one of its
|
|
297
|
+
* paths) and was sent at or below the lifted base: its revision — the store refused
|
|
298
|
+
* every foreign change of those positions in between, or it would not have applied;
|
|
299
|
+
* 3. (EC-681, `log` given) the next revision is someone else's and touched none of
|
|
300
|
+
* `paths` ({@link mayOverlap}, the store's own row reading `touchedPathsOfEntry`): the
|
|
301
|
+
* store would not refuse the write on it, so crossing it moves nothing — it only lets
|
|
302
|
+
* rule 1 reach an own write beyond it. That is the case rule 2 cannot prove: an
|
|
303
|
+
* ancestor's write over an own DESCENDANT write (whose acceptance says nothing of the
|
|
304
|
+
* ancestor's other descendants), past an unrelated colleague write in between.
|
|
305
|
+
* A whole-document write (`paths` undefined) takes rule 1 only.
|
|
253
306
|
*/
|
|
254
|
-
function lift(entry, base, paths) {
|
|
307
|
+
function lift(entry, base, paths, log) {
|
|
255
308
|
let lifted = base;
|
|
256
309
|
for (;;) {
|
|
257
310
|
if (entry.own.some((write) => write.revision === lifted + 1)) {
|
|
@@ -265,11 +318,38 @@ export function createCmsPersister(options) {
|
|
|
265
318
|
write.sentBase <= lifted &&
|
|
266
319
|
paths.every((path) => write.paths.some((covered) => isValuePathPrefix(covered, path))))
|
|
267
320
|
: undefined;
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
|
|
321
|
+
if (covering) {
|
|
322
|
+
lifted = covering.revision;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const foreign = paths ? log?.get(lifted + 1) : undefined;
|
|
326
|
+
if (foreign && !touchedPathsOfEntry(foreign).some((logged) => paths.some((path) => mayOverlap(logged, path)))) {
|
|
327
|
+
lifted += 1;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
return lifted;
|
|
271
331
|
}
|
|
272
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* EC-681 — the patch log {@link lift} rule 3 reads for a queued patch: only when an own
|
|
335
|
+
* write the store would refuse it on lies beyond its lift (an own write that may overlap
|
|
336
|
+
* its positions, past a revision someone else wrote), read after that lift. `undefined`
|
|
337
|
+
* when not needed or not readable — then no lift, a conflict at worst, never a borrow.
|
|
338
|
+
*/
|
|
339
|
+
async function liftLog(entry, operations, queued) {
|
|
340
|
+
const paths = touchedPaths(queued.patch);
|
|
341
|
+
const lifted = lift(entry, queued.base, paths);
|
|
342
|
+
const blocking = entry.own.some((write) => write.revision > lifted &&
|
|
343
|
+
(write.paths === undefined || write.paths.some((own) => paths.some((path) => mayOverlap(own, path)))));
|
|
344
|
+
if (!blocking)
|
|
345
|
+
return undefined;
|
|
346
|
+
const read = await operations.documents.listPatches({
|
|
347
|
+
projectId: options.projectId,
|
|
348
|
+
ref: entry.ref,
|
|
349
|
+
afterRevision: lifted,
|
|
350
|
+
});
|
|
351
|
+
return read.ok ? new Map(read.value.map((row) => [row.revision, row])) : undefined;
|
|
352
|
+
}
|
|
273
353
|
/**
|
|
274
354
|
* EC-647 — the base a write is QUEUED with: the revision it was authored against, or
|
|
275
355
|
* the earlier base of an own unacknowledged write on the same positions (the view
|
|
@@ -390,6 +470,24 @@ export function createCmsPersister(options) {
|
|
|
390
470
|
// The actor is decoration on a conflict the user can already resolve.
|
|
391
471
|
}
|
|
392
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* EC-674 — a write that failed without a per-field conflict: KEPT when the store did not
|
|
475
|
+
* judge it ({@link keepsFailedWrite}) — back at the head of the queue, still shown,
|
|
476
|
+
* re-sent by the next commit or forced flush at its own authoring base (a retry never
|
|
477
|
+
* borrows a newer revision) — or DROPPED on the store's verdict, and the view re-derived
|
|
478
|
+
* without it. Reported either way. `true` when kept: the drain stops there, so nothing
|
|
479
|
+
* committed after it is sent ahead of it. The one path for both the whole-document write
|
|
480
|
+
* and the patch run.
|
|
481
|
+
*/
|
|
482
|
+
function failedWrite(entry, writes, error) {
|
|
483
|
+
const kept = keepsFailedWrite(error);
|
|
484
|
+
if (kept)
|
|
485
|
+
entry.queue.unshift(...writes);
|
|
486
|
+
else
|
|
487
|
+
emitDocument(entry);
|
|
488
|
+
options.onError(error);
|
|
489
|
+
return kept;
|
|
490
|
+
}
|
|
393
491
|
/** Send one queued whole-document write at the revision its snapshot was authored against. `false` → stop draining. */
|
|
394
492
|
async function sendReplace(entry, operations, write) {
|
|
395
493
|
const precondition = write.base !== undefined
|
|
@@ -423,14 +521,13 @@ export function createCmsPersister(options) {
|
|
|
423
521
|
});
|
|
424
522
|
adopt(entry, result.value);
|
|
425
523
|
}
|
|
426
|
-
else {
|
|
427
|
-
|
|
428
|
-
adopt(entry, result.error.current);
|
|
429
|
-
}
|
|
430
|
-
else
|
|
431
|
-
emitDocument(entry);
|
|
524
|
+
else if (result.error.code === 'conflict' && 'current' in result.error && result.error.current) {
|
|
525
|
+
adopt(entry, result.error.current);
|
|
432
526
|
options.onError(result.error);
|
|
433
527
|
}
|
|
528
|
+
else if (failedWrite(entry, [write], result.error)) {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
434
531
|
}
|
|
435
532
|
finally {
|
|
436
533
|
entry.inFlightReplace = undefined;
|
|
@@ -453,10 +550,15 @@ export function createCmsPersister(options) {
|
|
|
453
550
|
if (!head)
|
|
454
551
|
return;
|
|
455
552
|
if (head.kind === 'replace') {
|
|
456
|
-
await sendReplace(entry, operations, head)
|
|
553
|
+
if (!(await sendReplace(entry, operations, head)))
|
|
554
|
+
return;
|
|
457
555
|
continue;
|
|
458
556
|
}
|
|
459
|
-
const
|
|
557
|
+
const log = await liftLog(entry, operations, head);
|
|
558
|
+
// The queue may have moved while the log was read (a superseding edit, a delete): start over.
|
|
559
|
+
if (entry.queue[0] !== head)
|
|
560
|
+
continue;
|
|
561
|
+
const sendBase = (queued) => lift(entry, queued.base, touchedPaths(queued.patch), log);
|
|
460
562
|
const first = sendBase(head);
|
|
461
563
|
let end = 1;
|
|
462
564
|
while (end < entry.queue.length) {
|
|
@@ -528,14 +630,15 @@ export function createCmsPersister(options) {
|
|
|
528
630
|
settleAll(run, { status: 'failed', error });
|
|
529
631
|
return;
|
|
530
632
|
}
|
|
531
|
-
//
|
|
532
|
-
// the
|
|
533
|
-
//
|
|
633
|
+
// EC-674 — any other failure: the commits waiting on the run learn of it now. One the
|
|
634
|
+
// store did not judge keeps the patches (shown, unsent; the next commit or forced flush
|
|
635
|
+
// re-sends them); a verdict (validation, unauthorized, not-found) drops them, and the
|
|
636
|
+
// writes behind it drain — put back, it would be refused in front of every later write.
|
|
534
637
|
settleAll(run, { status: 'failed', error });
|
|
535
|
-
entry
|
|
638
|
+
const kept = failedWrite(entry, run.map((queued) => ({ ...queued, settle: [] })), error);
|
|
536
639
|
adoptHeld(entry);
|
|
537
|
-
|
|
538
|
-
|
|
640
|
+
if (kept)
|
|
641
|
+
return;
|
|
539
642
|
}
|
|
540
643
|
}
|
|
541
644
|
function upsertConflict(entry, refused, revision, base) {
|