@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.
Files changed (38) hide show
  1. package/convex/assets.ts +16 -2
  2. package/convex/ingest-attempt.test.ts +161 -0
  3. package/convex/ingest.test.ts +185 -9
  4. package/convex/ingest.ts +136 -24
  5. package/convex/prototype-key.test.ts +92 -0
  6. package/convex/records-zod-refinements.convex.test.ts +94 -0
  7. package/dist/components/cms/cms-store.d.ts +2 -4
  8. package/dist/components/cms/cms-store.js +4 -22
  9. package/dist/components/cms/cms-store.js.map +1 -1
  10. package/dist/components/cms/field-control.d.ts +12 -1
  11. package/dist/components/cms/field-control.js +16 -1
  12. package/dist/components/cms/field-control.js.map +1 -1
  13. package/dist/components/cms/import-batch.d.ts +23 -0
  14. package/dist/components/cms/import-batch.js +43 -0
  15. package/dist/components/cms/import-batch.js.map +1 -0
  16. package/dist/components/cms/rich-text/editor.js +13 -10
  17. package/dist/components/cms/rich-text/editor.js.map +1 -1
  18. package/dist/components/cms/rich-text/value.d.ts +9 -0
  19. package/dist/components/cms/rich-text/value.js +13 -0
  20. package/dist/components/cms/rich-text/value.js.map +1 -1
  21. package/dist/components/cms/use-document-draft.d.ts +35 -2
  22. package/dist/components/cms/use-document-draft.js +42 -15
  23. package/dist/components/cms/use-document-draft.js.map +1 -1
  24. package/dist/components/records/record-lens.js.map +1 -1
  25. package/dist/lib/engine-version.d.ts +1 -1
  26. package/dist/lib/engine-version.js +1 -1
  27. package/dist/lib/engine-version.js.map +1 -1
  28. package/dist/lib/i18n/messages.de.js +3 -0
  29. package/dist/lib/i18n/messages.de.js.map +1 -1
  30. package/dist/lib/i18n/messages.en.d.ts +3 -0
  31. package/dist/lib/i18n/messages.en.js +3 -0
  32. package/dist/lib/i18n/messages.en.js.map +1 -1
  33. package/dist/lib/operations/cms-persistence.d.ts +9 -4
  34. package/dist/lib/operations/cms-persistence.js +132 -29
  35. package/dist/lib/operations/cms-persistence.js.map +1 -1
  36. package/dist/lib/persona/labels.js +1 -0
  37. package/dist/lib/persona/labels.js.map +1 -1
  38. 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 (the earliest of the writes covering it);
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). Without it, the session's second
63
- * edit of a position, made at the revision its first was authored against, would be
64
- * refused by its own first write.
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: 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.
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((covered) => isValuePathPrefix(covered, path))) {
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. 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.
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 (!covering)
269
- return lifted;
270
- lifted = covering.revision;
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
- if (result.error.code === 'conflict' && 'current' in result.error && result.error.current) {
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 sendBase = (queued) => lift(entry, queued.base, touchedPaths(queued.patch));
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
- // A transport / authorization failure: the patches stay local (shown, unsent) and
532
- // the error is visible; the next commit or forced flush retries them. The commits
533
- // waiting on them learn of the failure now.
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.queue.unshift(...run.map((queued) => ({ ...queued, settle: [] })));
638
+ const kept = failedWrite(entry, run.map((queued) => ({ ...queued, settle: [] })), error);
536
639
  adoptHeld(entry);
537
- options.onError(error);
538
- return;
640
+ if (kept)
641
+ return;
539
642
  }
540
643
  }
541
644
  function upsertConflict(entry, refused, revision, base) {