@elytracms/studio 0.0.97 → 0.0.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/convex/assets.ts +16 -2
  2. package/convex/ingest-attempt.test.ts +161 -0
  3. package/convex/ingest.test.ts +269 -16
  4. package/convex/ingest.ts +238 -47
  5. package/convex/path-segment-refusal.test.ts +201 -0
  6. package/convex/prototype-key.test.ts +92 -0
  7. package/convex/records-column-transform.convex.test.ts +147 -0
  8. package/convex/records-zod-refinements.convex.test.ts +94 -0
  9. package/convex/schema.ts +1 -1
  10. package/dist/addon.d.ts +5 -3
  11. package/dist/addon.js +4 -3
  12. package/dist/addon.js.map +1 -1
  13. package/dist/components/app-views/default-modules.d.ts +10 -3
  14. package/dist/components/app-views/default-modules.js +5 -1
  15. package/dist/components/app-views/default-modules.js.map +1 -1
  16. package/dist/components/app-views/module.d.ts +15 -8
  17. package/dist/components/app-views/module.js.map +1 -1
  18. package/dist/components/builder/reuse-context.d.ts +29 -1
  19. package/dist/components/builder/reuse-context.js +58 -12
  20. package/dist/components/builder/reuse-context.js.map +1 -1
  21. package/dist/components/cms/cms-store.d.ts +5 -6
  22. package/dist/components/cms/cms-store.js +12 -24
  23. package/dist/components/cms/cms-store.js.map +1 -1
  24. package/dist/components/cms/field-control.d.ts +12 -1
  25. package/dist/components/cms/field-control.js +31 -6
  26. package/dist/components/cms/field-control.js.map +1 -1
  27. package/dist/components/cms/import-batch.d.ts +23 -0
  28. package/dist/components/cms/import-batch.js +43 -0
  29. package/dist/components/cms/import-batch.js.map +1 -0
  30. package/dist/components/cms/rich-text/editor.js +13 -10
  31. package/dist/components/cms/rich-text/editor.js.map +1 -1
  32. package/dist/components/cms/rich-text/value.d.ts +9 -0
  33. package/dist/components/cms/rich-text/value.js +13 -0
  34. package/dist/components/cms/rich-text/value.js.map +1 -1
  35. package/dist/components/cms/use-document-draft.d.ts +39 -3
  36. package/dist/components/cms/use-document-draft.js +46 -16
  37. package/dist/components/cms/use-document-draft.js.map +1 -1
  38. package/dist/components/cms/use-publish-controller.d.ts +7 -0
  39. package/dist/components/cms/use-publish-controller.js +17 -3
  40. package/dist/components/cms/use-publish-controller.js.map +1 -1
  41. package/dist/components/cms/versions-pane.js +8 -5
  42. package/dist/components/cms/versions-pane.js.map +1 -1
  43. package/dist/components/delivery-health-banner.d.ts +34 -16
  44. package/dist/components/delivery-health-banner.js +59 -27
  45. package/dist/components/delivery-health-banner.js.map +1 -1
  46. package/dist/components/issue-severity.d.ts +3 -2
  47. package/dist/components/issue-severity.js.map +1 -1
  48. package/dist/components/nav-issues-registry.d.ts +39 -24
  49. package/dist/components/nav-issues-registry.js +50 -21
  50. package/dist/components/nav-issues-registry.js.map +1 -1
  51. package/dist/components/nav-issues.d.ts +23 -50
  52. package/dist/components/nav-issues.js +31 -59
  53. package/dist/components/nav-issues.js.map +1 -1
  54. package/dist/components/notifications/notice-center.d.ts +61 -0
  55. package/dist/components/notifications/notice-center.js +119 -0
  56. package/dist/components/notifications/notice-center.js.map +1 -0
  57. package/dist/components/notifications/notice-model.d.ts +137 -0
  58. package/dist/components/notifications/notice-model.js +107 -0
  59. package/dist/components/notifications/notice-model.js.map +1 -0
  60. package/dist/components/notifications/outcome-notice.d.ts +16 -0
  61. package/dist/components/notifications/outcome-notice.js +34 -0
  62. package/dist/components/notifications/outcome-notice.js.map +1 -0
  63. package/dist/components/notifications/shell-findings.d.ts +36 -0
  64. package/dist/components/notifications/shell-findings.js +140 -0
  65. package/dist/components/notifications/shell-findings.js.map +1 -0
  66. package/dist/components/rail-view-source.js +5 -0
  67. package/dist/components/rail-view-source.js.map +1 -1
  68. package/dist/components/records/record-lens.js.map +1 -1
  69. package/dist/components/records/record-rail.d.ts +10 -2
  70. package/dist/components/records/record-rail.js +74 -4
  71. package/dist/components/records/record-rail.js.map +1 -1
  72. package/dist/components/section-nav.d.ts +15 -3
  73. package/dist/components/section-nav.js +39 -147
  74. package/dist/components/section-nav.js.map +1 -1
  75. package/dist/components/studio-project-screen.js.map +1 -1
  76. package/dist/components/studio-shell.js +8 -3
  77. package/dist/components/studio-shell.js.map +1 -1
  78. package/dist/components/use-narrow-viewport.d.ts +11 -0
  79. package/dist/components/use-narrow-viewport.js +25 -0
  80. package/dist/components/use-narrow-viewport.js.map +1 -0
  81. package/dist/components/users/role-policy-drift-issues.d.ts +14 -7
  82. package/dist/components/users/role-policy-drift-issues.js +17 -8
  83. package/dist/components/users/role-policy-drift-issues.js.map +1 -1
  84. package/dist/lib/collection-view/collection-view-source.d.ts +4 -2
  85. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  86. package/dist/lib/engine-version.d.ts +1 -1
  87. package/dist/lib/engine-version.js +1 -1
  88. package/dist/lib/engine-version.js.map +1 -1
  89. package/dist/lib/i18n/messages.de.js +24 -3
  90. package/dist/lib/i18n/messages.de.js.map +1 -1
  91. package/dist/lib/i18n/messages.en.d.ts +22 -2
  92. package/dist/lib/i18n/messages.en.js +27 -4
  93. package/dist/lib/i18n/messages.en.js.map +1 -1
  94. package/dist/lib/notices/toast-store.d.ts +61 -0
  95. package/dist/lib/notices/toast-store.js +116 -0
  96. package/dist/lib/notices/toast-store.js.map +1 -0
  97. package/dist/lib/operations/cms-persistence.d.ts +32 -7
  98. package/dist/lib/operations/cms-persistence.js +511 -71
  99. package/dist/lib/operations/cms-persistence.js.map +1 -1
  100. package/dist/lib/persistence/convex-adapter.js +61 -30
  101. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  102. package/dist/lib/persona/labels.js +1 -0
  103. package/dist/lib/persona/labels.js.map +1 -1
  104. package/dist/ui/toast.d.ts +24 -16
  105. package/dist/ui/toast.js +23 -32
  106. package/dist/ui/toast.js.map +1 -1
  107. package/package.json +4 -3
@@ -0,0 +1,61 @@
1
+ /**
2
+ * EC-690 — the TRANSIENT channel of the studio's one notification-and-issue surface: an
3
+ * action's outcome (a publish that failed, a copy that was made). Persistent findings (a
4
+ * duplicate redirect, a config problem) live in the shell's findings registry instead
5
+ * (`components/nav-issues-registry.tsx`); both render in the one issues tray.
6
+ *
7
+ * The rules, owner-ratified 2026-10-28 ("As recommended", ruling (d)):
8
+ * - a SUCCESS expires on its own after {@link NOTICE_PEEK_MS};
9
+ * - an ERROR stays until it is dismissed — it is never lost to a timer;
10
+ * - every notice PEEKS for {@link NOTICE_PEEK_MS} when it is raised (bottom-right of the
11
+ * content area), and lives on in the tray for as long as it lives at all.
12
+ *
13
+ * A KEYED notice is an outcome its owner tracks (a failed save its store holds as
14
+ * `persistError` / `lastError`): the owner raises it while the error is set and retracts it
15
+ * when the error clears, and dismissing it in the tray calls the owner's `onDismiss` — so the
16
+ * inline banner and the tray are two views of ONE state, never two states.
17
+ *
18
+ * Pure and clock-injected ({@link createToastStore}): the module's default store binds the
19
+ * real clock, tests bind their own (AGENTS §4 determinism — no `Date.now()`/`setTimeout` in
20
+ * the logic under test).
21
+ */
22
+ export type ToastVariant = 'error' | 'success';
23
+ export interface ToastItem {
24
+ id: number;
25
+ message: string;
26
+ variant: ToastVariant;
27
+ /** When the notice leaves on its own (epoch ms); `null` = it stays until dismissed (every error). */
28
+ expiresAt: number | null;
29
+ /** Whether it is still in its first {@link NOTICE_PEEK_MS} (shown in the content area's corner). */
30
+ peeking: boolean;
31
+ /** The owner's key, for a notice its owner raises and retracts (absent for a plain `toast.*`). */
32
+ key?: string;
33
+ }
34
+ /** How long a notice peeks, and how long a success lives (EC-268's original 5 s). */
35
+ export declare const NOTICE_PEEK_MS = 5000;
36
+ /** The clock the store reads — injected so the logic is deterministic under test. */
37
+ export interface ToastClock {
38
+ now(): number;
39
+ /** Run `run` after `ms`; returns a cancel function. */
40
+ schedule(run: () => void, ms: number): () => void;
41
+ }
42
+ export interface KeyedToastInput {
43
+ message: string;
44
+ variant: ToastVariant;
45
+ /** Called when the reader dismisses it (e.g. `clearPersistError`) — not on {@link ToastStore.retract}. */
46
+ onDismiss?: () => void;
47
+ }
48
+ export interface ToastStore {
49
+ push(message: string, variant: ToastVariant): number;
50
+ /** Raise (or keep) the owner's notice under `key`. The same message again is a no-op; a new one replaces it. */
51
+ raise(key: string, input: KeyedToastInput): number;
52
+ /** The owner's state cleared: remove its notice WITHOUT calling `onDismiss`. */
53
+ retract(key: string): void;
54
+ /** The reader dismissed it: remove it and tell its owner. */
55
+ dismiss(id: number): void;
56
+ getSnapshot(): ToastItem[];
57
+ subscribe(listener: (items: ToastItem[]) => void): () => void;
58
+ }
59
+ export declare function createToastStore(clock: ToastClock): ToastStore;
60
+ /** The studio's one transient channel (`toast.*` writes it, the issues tray reads it). */
61
+ export declare const studioToasts: ToastStore;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * EC-690 — the TRANSIENT channel of the studio's one notification-and-issue surface: an
3
+ * action's outcome (a publish that failed, a copy that was made). Persistent findings (a
4
+ * duplicate redirect, a config problem) live in the shell's findings registry instead
5
+ * (`components/nav-issues-registry.tsx`); both render in the one issues tray.
6
+ *
7
+ * The rules, owner-ratified 2026-10-28 ("As recommended", ruling (d)):
8
+ * - a SUCCESS expires on its own after {@link NOTICE_PEEK_MS};
9
+ * - an ERROR stays until it is dismissed — it is never lost to a timer;
10
+ * - every notice PEEKS for {@link NOTICE_PEEK_MS} when it is raised (bottom-right of the
11
+ * content area), and lives on in the tray for as long as it lives at all.
12
+ *
13
+ * A KEYED notice is an outcome its owner tracks (a failed save its store holds as
14
+ * `persistError` / `lastError`): the owner raises it while the error is set and retracts it
15
+ * when the error clears, and dismissing it in the tray calls the owner's `onDismiss` — so the
16
+ * inline banner and the tray are two views of ONE state, never two states.
17
+ *
18
+ * Pure and clock-injected ({@link createToastStore}): the module's default store binds the
19
+ * real clock, tests bind their own (AGENTS §4 determinism — no `Date.now()`/`setTimeout` in
20
+ * the logic under test).
21
+ */
22
+ /** How long a notice peeks, and how long a success lives (EC-268's original 5 s). */
23
+ export const NOTICE_PEEK_MS = 5000;
24
+ export function createToastStore(clock) {
25
+ let items = [];
26
+ let seq = 0;
27
+ const listeners = new Set();
28
+ const cancels = new Map();
29
+ const onDismiss = new Map();
30
+ const emit = () => {
31
+ for (const listener of [...listeners])
32
+ listener(items);
33
+ };
34
+ const drop = (id) => {
35
+ const item = items.find((candidate) => candidate.id === id);
36
+ if (!item)
37
+ return undefined;
38
+ items = items.filter((candidate) => candidate.id !== id);
39
+ cancels.get(id)?.();
40
+ cancels.delete(id);
41
+ return item;
42
+ };
43
+ const add = (message, variant, key) => {
44
+ const id = ++seq;
45
+ const raisedAt = clock.now();
46
+ const expiresAt = variant === 'success' ? raisedAt + NOTICE_PEEK_MS : null;
47
+ items = [...items, { id, message, variant, expiresAt, peeking: true, ...(key !== undefined ? { key } : {}) }];
48
+ cancels.set(id, clock.schedule(() => {
49
+ cancels.delete(id);
50
+ if (variant === 'success') {
51
+ onDismiss.delete(id);
52
+ items = items.filter((candidate) => candidate.id !== id);
53
+ }
54
+ else {
55
+ items = items.map((candidate) => (candidate.id === id ? { ...candidate, peeking: false } : candidate));
56
+ }
57
+ emit();
58
+ }, NOTICE_PEEK_MS));
59
+ emit();
60
+ return id;
61
+ };
62
+ return {
63
+ push: (message, variant) => add(message, variant),
64
+ raise(key, input) {
65
+ const current = items.find((item) => item.key === key);
66
+ if (current && current.message === input.message && current.variant === input.variant) {
67
+ if (input.onDismiss)
68
+ onDismiss.set(current.id, input.onDismiss);
69
+ return current.id;
70
+ }
71
+ if (current) {
72
+ drop(current.id);
73
+ onDismiss.delete(current.id);
74
+ }
75
+ const id = add(input.message, input.variant, key);
76
+ if (input.onDismiss)
77
+ onDismiss.set(id, input.onDismiss);
78
+ return id;
79
+ },
80
+ retract(key) {
81
+ const current = items.find((item) => item.key === key);
82
+ if (!current)
83
+ return;
84
+ drop(current.id);
85
+ onDismiss.delete(current.id);
86
+ emit();
87
+ },
88
+ dismiss(id) {
89
+ const item = drop(id);
90
+ if (!item)
91
+ return;
92
+ const owner = onDismiss.get(id);
93
+ onDismiss.delete(id);
94
+ emit();
95
+ owner?.();
96
+ },
97
+ getSnapshot: () => items,
98
+ subscribe(listener) {
99
+ listeners.add(listener);
100
+ return () => {
101
+ listeners.delete(listener);
102
+ };
103
+ },
104
+ };
105
+ }
106
+ /** The real clock — the one place the studio's notices read wall time. */
107
+ const realClock = {
108
+ now: () => Date.now(),
109
+ schedule: (run, ms) => {
110
+ const handle = setTimeout(run, ms);
111
+ return () => clearTimeout(handle);
112
+ },
113
+ };
114
+ /** The studio's one transient channel (`toast.*` writes it, the issues tray reads it). */
115
+ export const studioToasts = createToastStore(realClock);
116
+ //# sourceMappingURL=toast-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"toast-store.js","sourceRoot":"","sources":["../../../src/lib/notices/toast-store.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * EC-690 — the TRANSIENT channel of the studio's one notification-and-issue surface: an\n * action's outcome (a publish that failed, a copy that was made). Persistent findings (a\n * duplicate redirect, a config problem) live in the shell's findings registry instead\n * (`components/nav-issues-registry.tsx`); both render in the one issues tray.\n *\n * The rules, owner-ratified 2026-10-28 (\"As recommended\", ruling (d)):\n * - a SUCCESS expires on its own after {@link NOTICE_PEEK_MS};\n * - an ERROR stays until it is dismissed — it is never lost to a timer;\n * - every notice PEEKS for {@link NOTICE_PEEK_MS} when it is raised (bottom-right of the\n * content area), and lives on in the tray for as long as it lives at all.\n *\n * A KEYED notice is an outcome its owner tracks (a failed save its store holds as\n * `persistError` / `lastError`): the owner raises it while the error is set and retracts it\n * when the error clears, and dismissing it in the tray calls the owner's `onDismiss` — so the\n * inline banner and the tray are two views of ONE state, never two states.\n *\n * Pure and clock-injected ({@link createToastStore}): the module's default store binds the\n * real clock, tests bind their own (AGENTS §4 determinism — no `Date.now()`/`setTimeout` in\n * the logic under test).\n */\n\nexport type ToastVariant = 'error' | 'success'\n\nexport interface ToastItem {\n id: number\n message: string\n variant: ToastVariant\n /** When the notice leaves on its own (epoch ms); `null` = it stays until dismissed (every error). */\n expiresAt: number | null\n /** Whether it is still in its first {@link NOTICE_PEEK_MS} (shown in the content area's corner). */\n peeking: boolean\n /** The owner's key, for a notice its owner raises and retracts (absent for a plain `toast.*`). */\n key?: string\n}\n\n/** How long a notice peeks, and how long a success lives (EC-268's original 5 s). */\nexport const NOTICE_PEEK_MS = 5000\n\n/** The clock the store reads — injected so the logic is deterministic under test. */\nexport interface ToastClock {\n now(): number\n /** Run `run` after `ms`; returns a cancel function. */\n schedule(run: () => void, ms: number): () => void\n}\n\nexport interface KeyedToastInput {\n message: string\n variant: ToastVariant\n /** Called when the reader dismisses it (e.g. `clearPersistError`) — not on {@link ToastStore.retract}. */\n onDismiss?: () => void\n}\n\nexport interface ToastStore {\n push(message: string, variant: ToastVariant): number\n /** Raise (or keep) the owner's notice under `key`. The same message again is a no-op; a new one replaces it. */\n raise(key: string, input: KeyedToastInput): number\n /** The owner's state cleared: remove its notice WITHOUT calling `onDismiss`. */\n retract(key: string): void\n /** The reader dismissed it: remove it and tell its owner. */\n dismiss(id: number): void\n getSnapshot(): ToastItem[]\n subscribe(listener: (items: ToastItem[]) => void): () => void\n}\n\nexport function createToastStore(clock: ToastClock): ToastStore {\n let items: ToastItem[] = []\n let seq = 0\n const listeners = new Set<(items: ToastItem[]) => void>()\n const cancels = new Map<number, () => void>()\n const onDismiss = new Map<number, () => void>()\n\n const emit = (): void => {\n for (const listener of [...listeners]) listener(items)\n }\n\n const drop = (id: number): ToastItem | undefined => {\n const item = items.find((candidate) => candidate.id === id)\n if (!item) return undefined\n items = items.filter((candidate) => candidate.id !== id)\n cancels.get(id)?.()\n cancels.delete(id)\n return item\n }\n\n const add = (message: string, variant: ToastVariant, key?: string): number => {\n const id = ++seq\n const raisedAt = clock.now()\n const expiresAt = variant === 'success' ? raisedAt + NOTICE_PEEK_MS : null\n items = [...items, { id, message, variant, expiresAt, peeking: true, ...(key !== undefined ? { key } : {}) }]\n cancels.set(\n id,\n clock.schedule(() => {\n cancels.delete(id)\n if (variant === 'success') {\n onDismiss.delete(id)\n items = items.filter((candidate) => candidate.id !== id)\n } else {\n items = items.map((candidate) => (candidate.id === id ? { ...candidate, peeking: false } : candidate))\n }\n emit()\n }, NOTICE_PEEK_MS),\n )\n emit()\n return id\n }\n\n return {\n push: (message, variant) => add(message, variant),\n raise(key, input) {\n const current = items.find((item) => item.key === key)\n if (current && current.message === input.message && current.variant === input.variant) {\n if (input.onDismiss) onDismiss.set(current.id, input.onDismiss)\n return current.id\n }\n if (current) {\n drop(current.id)\n onDismiss.delete(current.id)\n }\n const id = add(input.message, input.variant, key)\n if (input.onDismiss) onDismiss.set(id, input.onDismiss)\n return id\n },\n retract(key) {\n const current = items.find((item) => item.key === key)\n if (!current) return\n drop(current.id)\n onDismiss.delete(current.id)\n emit()\n },\n dismiss(id) {\n const item = drop(id)\n if (!item) return\n const owner = onDismiss.get(id)\n onDismiss.delete(id)\n emit()\n owner?.()\n },\n getSnapshot: () => items,\n subscribe(listener) {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n },\n }\n}\n\n/** The real clock — the one place the studio's notices read wall time. */\nconst realClock: ToastClock = {\n now: () => Date.now(),\n schedule: (run, ms) => {\n const handle = setTimeout(run, ms)\n return () => clearTimeout(handle)\n },\n}\n\n/** The studio's one transient channel (`toast.*` writes it, the issues tray reads it). */\nexport const studioToasts: ToastStore = createToastStore(realClock)\n"]}
@@ -42,7 +42,14 @@ 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. Positions
48
+ * are compared in their STRUCTURAL form, as the store compares them (EC-693, Codex F28):
49
+ * a node path carries its component ancestry — each shown write's as the merge applied
50
+ * it in that view, the asked path's expanded against the view — so a parent node's token
51
+ * sees a pending write of its child's prop, which a submitted `nodePropPath` names
52
+ * without the parent.
46
53
  * `revisionOfView(view)` — a whole-document writer — the EARLIEST of all. The record is
47
54
  * immutable, so it outlives the pending write: an edit started on a view that showed a
48
55
  * snapshot keeps the snapshot's revision after the snapshot is refused or fails
@@ -57,11 +64,15 @@ import type { OperationError, OperationsClient } from './operations-package.js';
57
64
  * - **The own-write ledger.** A base may be LIFTED, at send time, across revisions this
58
65
  * persister wrote — and only those: across a run of consecutive own revisions right
59
66
  * after it (nothing foreign happened in between), or to an own write's revision when
60
- * the patch only touches positions that write covered and the write was sent at or
67
+ * the patch only touches positions that write covered (both read where they land in the
68
+ * server row, node ancestry included — EC-693) and the write was sent at or
61
69
  * 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.
70
+ * between, or the write would not have applied) — or, EC-681, across a revision someone
71
+ * else wrote that the patch log shows touched none of the patch's positions (the store
72
+ * would not refuse the patch on it), read only when an own write beyond it would
73
+ * otherwise refuse the patch. Without it, the session's second edit of a position, made
74
+ * at the revision its first was authored against, would be refused by its own first
75
+ * write — and an edit of a parent, authored over an own child write, by that child's.
65
76
  * - **The provisional view** (`undefined` authoring revision): a document whose view
66
77
  * has no revision yet — our own create in flight, or a document the session never
67
78
  * tracked (a staged transfer). Writes on it are based on the revision our create
@@ -107,7 +118,13 @@ export type CommitOutcome = {
107
118
  readonly status: 'refused';
108
119
  readonly conflicts: readonly DocumentConflict[];
109
120
  }
110
- /** A transport / authorization failure, or a write that named no authoring revision (also reported through `onError`). */
121
+ /**
122
+ * A transport / authorization failure, or a write that named no authoring revision (also
123
+ * reported through `onError`). A write the backend did not judge is KEPT (EC-674): it
124
+ * stays shown and queued, and the next commit or forced flush re-sends it — its commit,
125
+ * and every commit queued behind it, settles `failed` with that error when the drain
126
+ * stops on it (EC-686), never left waiting.
127
+ */
111
128
  | {
112
129
  readonly status: 'failed';
113
130
  readonly error: OperationError;
@@ -165,8 +182,16 @@ export interface CmsPersister {
165
182
  * conflict, never overwritten. `revision` `undefined`: the snapshot was authored on a
166
183
  * provisional view (our own pending create; insert-if-absent when untracked). Sent in
167
184
  * its place among the session's queued patches.
185
+ *
186
+ * EC-686 (the owner's ruling, per-position): when the store refuses it, it is decomposed
187
+ * into what the author changed against `authoredOn` — the document the snapshot was made
188
+ * from — and each position is re-sent: it lands, or becomes that field's ordinary
189
+ * conflict. Never a whole-document keep-mine. Without `authoredOn` nothing can be judged
190
+ * per position: the refusal adopts the current row and is reported, as before — never a
191
+ * guess (the session's view at that moment may hold changes the snapshot never saw, which
192
+ * a diff against it would re-send as the author's).
168
193
  */
169
- replace(document: CmsDocument, revision: number | undefined): void;
194
+ replace(document: CmsDocument, revision: number | undefined, authoredOn?: CmsDocument): void;
170
195
  deleteDocument(ref: DocumentRef): void;
171
196
  unpublishDocument(ref: DocumentRef): void;
172
197
  /**