@elytracms/studio 0.0.95 → 0.0.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/convex/cms.ts +272 -16
  2. package/convex/delivery-folded-drift.test.ts +104 -0
  3. package/convex/delivery.ts +24 -8
  4. package/convex/depth-codec-lookalike.test.ts +192 -0
  5. package/convex/fixture-accounts.test.ts +305 -0
  6. package/convex/fixtureAccounts.ts +298 -0
  7. package/convex/incarnation.test.ts +149 -0
  8. package/convex/ingest.test.ts +145 -3
  9. package/convex/ingest.ts +123 -17
  10. package/convex/receipt-races.test.ts +184 -0
  11. package/convex/records-zod-copy.test.ts +76 -0
  12. package/convex/schema.ts +20 -1
  13. package/convex/strict-delete.test.ts +129 -0
  14. package/convex/strict-reference-race.test.ts +139 -0
  15. package/dist/components/ai/ai-chat-panel.js +15 -2
  16. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  17. package/dist/components/ai/translate-document-action.d.ts +6 -0
  18. package/dist/components/ai/translate-document-action.js.map +1 -1
  19. package/dist/components/ai/translate-document-dialog.d.ts +1 -1
  20. package/dist/components/ai/translate-document-dialog.js +5 -5
  21. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  22. package/dist/components/assets/asset-detail.js +5 -2
  23. package/dist/components/assets/asset-detail.js.map +1 -1
  24. package/dist/components/assets/asset-locale.d.ts +7 -0
  25. package/dist/components/assets/asset-locale.js +16 -2
  26. package/dist/components/assets/asset-locale.js.map +1 -1
  27. package/dist/components/assets/asset-meta-drift-notice.d.ts +13 -0
  28. package/dist/components/assets/asset-meta-drift-notice.js +22 -0
  29. package/dist/components/assets/asset-meta-drift-notice.js.map +1 -0
  30. package/dist/components/assets/asset-meta-label.d.ts +2 -1
  31. package/dist/components/assets/asset-meta-label.js +4 -2
  32. package/dist/components/assets/asset-meta-label.js.map +1 -1
  33. package/dist/components/assets/asset-metadata.d.ts +33 -13
  34. package/dist/components/assets/asset-metadata.js +46 -22
  35. package/dist/components/assets/asset-metadata.js.map +1 -1
  36. package/dist/components/assets/asset-search.d.ts +6 -2
  37. package/dist/components/assets/asset-search.js +15 -5
  38. package/dist/components/assets/asset-search.js.map +1 -1
  39. package/dist/components/assets/assets-collection-view-source.d.ts +5 -3
  40. package/dist/components/assets/assets-collection-view-source.js +7 -5
  41. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  42. package/dist/components/builder/canvas-rebase.d.ts +36 -12
  43. package/dist/components/builder/canvas-rebase.js +13 -3
  44. package/dist/components/builder/canvas-rebase.js.map +1 -1
  45. package/dist/components/builder/canvas-render-context.js +5 -4
  46. package/dist/components/builder/canvas-render-context.js.map +1 -1
  47. package/dist/components/builder/composition-field-editor.d.ts +12 -6
  48. package/dist/components/builder/composition-field-editor.js +6 -5
  49. package/dist/components/builder/composition-field-editor.js.map +1 -1
  50. package/dist/components/cms/asset-picker-core.d.ts +2 -2
  51. package/dist/components/cms/asset-picker-core.js +2 -2
  52. package/dist/components/cms/asset-picker-core.js.map +1 -1
  53. package/dist/components/cms/asset-picker.js +3 -1
  54. package/dist/components/cms/asset-picker.js.map +1 -1
  55. package/dist/components/cms/canvas-commit.d.ts +6 -0
  56. package/dist/components/cms/canvas-commit.js +5 -1
  57. package/dist/components/cms/canvas-commit.js.map +1 -1
  58. package/dist/components/cms/cms-store.d.ts +24 -8
  59. package/dist/components/cms/cms-store.js +21 -14
  60. package/dist/components/cms/cms-store.js.map +1 -1
  61. package/dist/components/cms/collection-table.js +4 -11
  62. package/dist/components/cms/collection-table.js.map +1 -1
  63. package/dist/components/cms/detail-editor.js +2 -2
  64. package/dist/components/cms/detail-editor.js.map +1 -1
  65. package/dist/components/cms/draft-commit.d.ts +16 -3
  66. package/dist/components/cms/draft-commit.js +3 -2
  67. package/dist/components/cms/draft-commit.js.map +1 -1
  68. package/dist/components/cms/use-document-draft.d.ts +107 -10
  69. package/dist/components/cms/use-document-draft.js +157 -42
  70. package/dist/components/cms/use-document-draft.js.map +1 -1
  71. package/dist/components/collection-view/cms-collection-view-source.js +6 -4
  72. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  73. package/dist/components/components/transfer-dialog.js +6 -4
  74. package/dist/components/components/transfer-dialog.js.map +1 -1
  75. package/dist/lib/ai/chat-state.d.ts +7 -0
  76. package/dist/lib/ai/chat-state.js.map +1 -1
  77. package/dist/lib/ai/tools.d.ts +8 -0
  78. package/dist/lib/ai/tools.js +4 -0
  79. package/dist/lib/ai/tools.js.map +1 -1
  80. package/dist/lib/cms-views/view-model.d.ts +15 -8
  81. package/dist/lib/cms-views/view-model.js +19 -5
  82. package/dist/lib/cms-views/view-model.js.map +1 -1
  83. package/dist/lib/collection-view/__fixtures__/document-writes.js +1 -0
  84. package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -1
  85. package/dist/lib/collection-view/collection-view-source.d.ts +18 -7
  86. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  87. package/dist/lib/data/collection-content.js +5 -0
  88. package/dist/lib/data/collection-content.js.map +1 -1
  89. package/dist/lib/engine-version.d.ts +1 -1
  90. package/dist/lib/engine-version.js +1 -1
  91. package/dist/lib/engine-version.js.map +1 -1
  92. package/dist/lib/i18n/messages.de.js +15 -1
  93. package/dist/lib/i18n/messages.de.js.map +1 -1
  94. package/dist/lib/i18n/messages.en.d.ts +13 -0
  95. package/dist/lib/i18n/messages.en.js +16 -1
  96. package/dist/lib/i18n/messages.en.js.map +1 -1
  97. package/dist/lib/operations/cms-persistence.d.ts +119 -35
  98. package/dist/lib/operations/cms-persistence.js +441 -91
  99. package/dist/lib/operations/cms-persistence.js.map +1 -1
  100. package/dist/lib/persistence/convex-adapter.js +80 -8
  101. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  102. package/dist/lib/persona/labels.js +2 -0
  103. package/dist/lib/persona/labels.js.map +1 -1
  104. package/package.json +4 -3
@@ -1 +1 @@
1
- {"version":3,"file":"draft-commit.js","sourceRoot":"","sources":["../../../src/components/cms/draft-commit.ts"],"names":[],"mappings":"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","sourcesContent":["import { formatValuePath } from '@elytracms/core/cms-core'\nimport type { ValuePath } from '@elytracms/core/cms-core'\nimport type { CommitPolicy } from './commit-policy'\nimport { IDLE_COMMIT_MS } from './commit-policy'\n\n/**\n * EC-595 A2 — the editor's COMMIT-ON-QUIESCENCE core (pure, clock injected; the house\n * pattern: no DOM, no React).\n *\n * The editor's draft changes per keystroke (live validation reads it); what is\n * COMMITTED — handed to the persister as patches — follows each field's\n * {@link CommitPolicy}. This core tracks the uncommitted positions and decides when\n * each is committed:\n *\n * - an edit of a `change`-policy field commits at once;\n * - an edit of the FOCUSED field waits for its blur, Enter (`blur-or-enter`) or idle\n * pause (`blur-or-idle`, re-armed on every edit — {@link Scheduler});\n * - an edit of a field that does NOT have focus (a derived value — the auto-slug\n * following the title, fill-from-base) rides along with the focused field's next\n * commit, or commits at once when no field has focus;\n * - {@link DraftCommitter.commitAll} is the FORCED commit (publish, unmount, tab hide,\n * duplicate, an AI proposal, transfer) — everything uncommitted, now.\n *\n * `commit` receives the positions; the caller builds the patches from its latest draft\n * (the value AT commit time, so a committed value is never a stale keystroke).\n */\n\n/** The injected clock: timers for the idle pause. */\nexport interface Scheduler {\n setTimeout(callback: () => void, ms: number): unknown\n clearTimeout(handle: unknown): void\n}\n\n/** The real clock (the editor's default). */\nexport const browserScheduler: Scheduler = {\n setTimeout: (callback, ms) => globalThis.setTimeout(callback, ms),\n clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),\n}\n\n/** One uncommitted position and the field (by name) whose control wrote it. */\nexport interface UncommittedPosition {\n readonly field: string\n readonly path: ValuePath\n /**\n * The revision the user's view was at when they STARTED editing this position — the\n * patch's base. A colleague's change of the same position that arrives (by\n * subscription) while the user is still typing is therefore refused on that field at\n * commit, never overwritten (EC-595 point 7: \"local wins until flushed, then the\n * per-path check decides\").\n */\n readonly base: number | undefined\n}\n\nexport interface DraftCommitter {\n /** A field's control wrote `path`. */\n edited(field: string, path: ValuePath): void\n /** Focus entered a field's control. */\n focused(field: string): void\n /** Focus left a field's control (commits it and what rode along). */\n blurred(field: string): void\n /** Enter in a single-line control (commits it). */\n entered(field: string): void\n /** The forced commit: everything uncommitted, now. */\n commitAll(): void\n /** The uncommitted positions (the editor overlays them on a rebased draft). */\n uncommitted(): readonly UncommittedPosition[]\n /** The field that has focus, if any. */\n focusedField(): string | undefined\n /** Cancel the idle timers (unmount). */\n dispose(): void\n}\n\nexport interface DraftCommitterOptions {\n policyOf: (field: string) => CommitPolicy\n /** The revision the session's view is at now (captured as a position's base on its first edit). */\n baseOf?: () => number | undefined\n commit: (positions: readonly UncommittedPosition[]) => void\n scheduler?: Scheduler\n idleMs?: number\n}\n\nexport function createDraftCommitter(options: DraftCommitterOptions): DraftCommitter {\n const scheduler = options.scheduler ?? browserScheduler\n const idleMs = options.idleMs ?? IDLE_COMMIT_MS\n /** Uncommitted positions by path key (one per position — a re-edit keeps one entry). */\n const pending = new Map<string, UncommittedPosition>()\n const idleTimers = new Map<string, unknown>()\n let focus: string | undefined\n\n const cancelIdle = (field: string) => {\n const handle = idleTimers.get(field)\n if (handle !== undefined) scheduler.clearTimeout(handle)\n idleTimers.delete(field)\n }\n\n /** Commit `field`'s positions plus every position of a field that does not have focus. */\n const commitField = (field: string) => {\n cancelIdle(field)\n const batch = [...pending.entries()].filter(\n ([, position]) => position.field === field || position.field !== focus,\n )\n if (batch.length === 0) return\n for (const [key] of batch) pending.delete(key)\n options.commit(batch.map(([, position]) => position))\n }\n\n return {\n edited(field, path) {\n const key = formatValuePath(path)\n const previous = pending.get(key)\n pending.set(key, { field, path, base: previous ? previous.base : options.baseOf?.() })\n const policy = options.policyOf(field)\n if (policy === 'change' || focus === undefined) {\n commitField(field)\n return\n }\n if (focus !== field) return // rides along with the focused field's commit\n if (policy === 'blur-or-idle') {\n cancelIdle(field)\n idleTimers.set(\n field,\n scheduler.setTimeout(() => {\n idleTimers.delete(field)\n commitField(field)\n }, idleMs),\n )\n }\n },\n focused(field) {\n if (focus !== undefined && focus !== field) commitField(focus)\n focus = field\n },\n blurred(field) {\n if (focus === field) focus = undefined\n commitField(field)\n },\n entered(field) {\n if (options.policyOf(field) === 'blur-or-enter') commitField(field)\n },\n commitAll() {\n for (const field of [...idleTimers.keys()]) cancelIdle(field)\n if (pending.size === 0) return\n const batch = [...pending.values()]\n pending.clear()\n options.commit(batch)\n },\n uncommitted: () => [...pending.values()],\n focusedField: () => focus,\n dispose() {\n for (const field of [...idleTimers.keys()]) cancelIdle(field)\n },\n }\n}\n"]}
1
+ {"version":3,"file":"draft-commit.js","sourceRoot":"","sources":["../../../src/components/cms/draft-commit.ts"],"names":[],"mappings":"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","sourcesContent":["import { formatValuePath } from '@elytracms/core/cms-core'\nimport type { ValuePath } from '@elytracms/core/cms-core'\nimport type { CommitPolicy } from './commit-policy'\nimport { IDLE_COMMIT_MS } from './commit-policy'\n\n/**\n * EC-595 A2 — the editor's COMMIT-ON-QUIESCENCE core (pure, clock injected; the house\n * pattern: no DOM, no React).\n *\n * The editor's draft changes per keystroke (live validation reads it); what is\n * COMMITTED — handed to the persister as patches — follows each field's\n * {@link CommitPolicy}. This core tracks the uncommitted positions and decides when\n * each is committed:\n *\n * - an edit of a `change`-policy field commits at once;\n * - an edit of the FOCUSED field waits for its blur, Enter (`blur-or-enter`) or idle\n * pause (`blur-or-idle`, re-armed on every edit — {@link Scheduler});\n * - an edit of a field that does NOT have focus (a derived value — the auto-slug\n * following the title, fill-from-base) rides along with the focused field's next\n * commit, or commits at once when no field has focus;\n * - {@link DraftCommitter.commitAll} is the FORCED commit (publish, unmount, tab hide,\n * duplicate, an AI proposal, transfer) — everything uncommitted, now.\n *\n * `commit` receives the positions; the caller builds the patches from its latest draft\n * (the value AT commit time, so a committed value is never a stale keystroke).\n */\n\n/** The injected clock: timers for the idle pause. */\nexport interface Scheduler {\n setTimeout(callback: () => void, ms: number): unknown\n clearTimeout(handle: unknown): void\n}\n\n/** The real clock (the editor's default). */\nexport const browserScheduler: Scheduler = {\n setTimeout: (callback, ms) => globalThis.setTimeout(callback, ms),\n clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),\n}\n\n/** One uncommitted position and the field (by name) whose control wrote it. */\nexport interface UncommittedPosition {\n readonly field: string\n readonly path: ValuePath\n /**\n * The revision the user's view was at when they STARTED editing this position — the\n * patch's base. A colleague's change of the same position that arrives (by\n * subscription) while the user is still typing is therefore refused on that field at\n * commit, never overwritten (EC-595 point 7: \"local wins until flushed, then the\n * per-path check decides\").\n */\n readonly base: number | undefined\n}\n\nexport interface DraftCommitter {\n /**\n * A field's control wrote `path`. `authored` — the revision the edit was authored\n * against when the writer knows it better than the draft does (EC-647: a canvas\n * inspector control's burst is authored on the CANVAS's value, whose revision can trail\n * the draft's); absent → {@link DraftCommitterOptions.baseOf}. A re-edit of an\n * uncommitted position keeps the base of its first edit either way.\n */\n edited(field: string, path: ValuePath, authored?: { readonly base: number | undefined }): void\n /** Focus entered a field's control. */\n focused(field: string): void\n /** Focus left a field's control (commits it and what rode along). */\n blurred(field: string): void\n /** Enter in a single-line control (commits it). */\n entered(field: string): void\n /** The forced commit: everything uncommitted, now. */\n commitAll(): void\n /** The uncommitted positions (the editor overlays them on a rebased draft). */\n uncommitted(): readonly UncommittedPosition[]\n /** The field that has focus, if any. */\n focusedField(): string | undefined\n /** Cancel the idle timers (unmount). */\n dispose(): void\n}\n\nexport interface DraftCommitterOptions {\n policyOf: (field: string) => CommitPolicy\n /**\n * The revision the editor's draft is at now — the revision of the view a field's edit\n * is authored on (captured as a position's base on its first edit). EC-647: the\n * draft's, never the session's tracked one (which may hold a change the draft has not\n * rebased onto yet).\n */\n baseOf?: () => number | undefined\n commit: (positions: readonly UncommittedPosition[]) => void\n scheduler?: Scheduler\n idleMs?: number\n}\n\nexport function createDraftCommitter(options: DraftCommitterOptions): DraftCommitter {\n const scheduler = options.scheduler ?? browserScheduler\n const idleMs = options.idleMs ?? IDLE_COMMIT_MS\n /** Uncommitted positions by path key (one per position — a re-edit keeps one entry). */\n const pending = new Map<string, UncommittedPosition>()\n const idleTimers = new Map<string, unknown>()\n let focus: string | undefined\n\n const cancelIdle = (field: string) => {\n const handle = idleTimers.get(field)\n if (handle !== undefined) scheduler.clearTimeout(handle)\n idleTimers.delete(field)\n }\n\n /** Commit `field`'s positions plus every position of a field that does not have focus. */\n const commitField = (field: string) => {\n cancelIdle(field)\n const batch = [...pending.entries()].filter(\n ([, position]) => position.field === field || position.field !== focus,\n )\n if (batch.length === 0) return\n for (const [key] of batch) pending.delete(key)\n options.commit(batch.map(([, position]) => position))\n }\n\n return {\n edited(field, path, authored) {\n const key = formatValuePath(path)\n const previous = pending.get(key)\n const base = previous ? previous.base : authored ? authored.base : options.baseOf?.()\n pending.set(key, { field, path, base })\n const policy = options.policyOf(field)\n if (policy === 'change' || focus === undefined) {\n commitField(field)\n return\n }\n if (focus !== field) return // rides along with the focused field's commit\n if (policy === 'blur-or-idle') {\n cancelIdle(field)\n idleTimers.set(\n field,\n scheduler.setTimeout(() => {\n idleTimers.delete(field)\n commitField(field)\n }, idleMs),\n )\n }\n },\n focused(field) {\n if (focus !== undefined && focus !== field) commitField(focus)\n focus = field\n },\n blurred(field) {\n if (focus === field) focus = undefined\n commitField(field)\n },\n entered(field) {\n if (options.policyOf(field) === 'blur-or-enter') commitField(field)\n },\n commitAll() {\n for (const field of [...idleTimers.keys()]) cancelIdle(field)\n if (pending.size === 0) return\n const batch = [...pending.values()]\n pending.clear()\n options.commit(batch)\n },\n uncommitted: () => [...pending.values()],\n focusedField: () => focus,\n dispose() {\n for (const field of [...idleTimers.keys()]) cancelIdle(field)\n },\n }\n}\n"]}
@@ -1,10 +1,10 @@
1
1
  import type { PresenceEntry } from '@elytracms/core/persistence';
2
2
  import type { CmsDocument, CollectionDef, DocumentPatch, FieldDef, Locale, PropFieldLookup, ValuePath } from '@elytracms/core/cms-core';
3
- import type { CollectionViewSource } from '../../lib/collection-view/collection-view-source.js';
4
- import type { CanvasFieldChange } from '../builder/canvas-rebase.js';
3
+ import type { CollectionViewSource, DocumentWrites } from '../../lib/collection-view/collection-view-source.js';
4
+ import type { CanvasFieldChange, CanvasLift } from '../builder/canvas-rebase.js';
5
5
  import type { CommitPolicy } from './commit-policy.js';
6
- import type { CanvasControlPosition } from './canvas-commit.js';
7
- import type { Scheduler, UncommittedPosition } from './draft-commit.js';
6
+ import type { CanvasControl, CanvasControlPosition } from './canvas-commit.js';
7
+ import type { DraftCommitter, Scheduler, UncommittedPosition } from './draft-commit.js';
8
8
  /**
9
9
  * EC-595 A2 — the detail editor's draft over the revisioned document (and, for the
10
10
  * explicit-save rails, over a record).
@@ -40,6 +40,59 @@ export interface VisibilitySource {
40
40
  onHidden(onHidden: () => void): () => void;
41
41
  }
42
42
  export declare const documentVisibility: VisibilitySource;
43
+ /**
44
+ * EC-647 — the editor's draft and the revision of the session view it was last rebased
45
+ * onto, as ONE value: every edit made on the draft was authored against that revision (an
46
+ * uncommitted position keeps the revision its first keystroke saw). `undefined`: the view
47
+ * had no revision (a fixture session, a record rail, a document still being created).
48
+ *
49
+ * EC-667 — that session view is a COMPOSITE while it shows a pending write (Codex F18):
50
+ * `revision` is its whole-document token (the EARLIEST authoring revision of any position
51
+ * — what a whole-document writer, the slot alias or the translation, writes at), and
52
+ * `shown` is the view itself, the token a position's edit reads ITS provenance from at
53
+ * the first keystroke ({@link positionBase}).
54
+ */
55
+ export interface DraftView {
56
+ readonly document: CmsDocument;
57
+ readonly revision: number | undefined;
58
+ /** EC-667 — the session view the draft was last rebased onto (absent: none — a fixture, a record rail). */
59
+ readonly shown?: CmsDocument;
60
+ }
61
+ /** What a draft reads its tokens from: the write model's view registry (EC-647 / EC-667). */
62
+ export type ViewTokens = Pick<DocumentWrites, 'revisionOfView'>;
63
+ /** EC-667 — the draft of a session view as it is shown (pure): its value, its whole-document token, the view itself. */
64
+ export declare function sessionDraftView(doc: CmsDocument, tokens: ViewTokens | undefined): DraftView;
65
+ /**
66
+ * EC-667 — the base an edit of `path` that STARTS now on `draft` is authored against
67
+ * (pure; the committer captures it at the first keystroke): the revision the content at
68
+ * that position of the shown session view was authored against — under a pending
69
+ * whole-document snapshot the snapshot's revision, under a pending own write that write's
70
+ * base — never the revision of a row adopted underneath it. Captured at the start, it
71
+ * survives the pending write's refusal or failure (looking it up when the commit arrives
72
+ * would be too late). Without a view token: the draft's whole-document revision.
73
+ */
74
+ export declare function positionBase(draft: DraftView, path: ValuePath, tokens: ViewTokens | undefined): number | undefined;
75
+ /**
76
+ * THE draft rebase as one value (pure — what the editor runs when the session's document
77
+ * changes): the session view with the uncommitted positions laid over it ({@link
78
+ * rebaseDraft}), its whole-document token, the view itself (EC-647 → EC-667: the rebase
79
+ * moves the value AND its tokens), and — when the screen has a canvas — the token of the
80
+ * canvas field's position, which the canvas's next edit is authored at.
81
+ */
82
+ export declare function rebaseDraftView(doc: CmsDocument, previous: DraftView, uncommitted: readonly UncommittedPosition[], tokens: ViewTokens, canvasPath?: ValuePath): {
83
+ view: DraftView;
84
+ canvasRevision: number | undefined;
85
+ };
86
+ /**
87
+ * EC-647 — the slot alias write (pure): the draft with `alias` set (or removed), written
88
+ * whole AT THE DRAFT'S REVISION — never the session's tracked one, which may already hold
89
+ * a colleague's change the draft has not absorbed (Codex F10). There is no identity-
90
+ * metadata operation for the alias (the value-position grammar has no position for it,
91
+ * measured on the base), so it stays a whole-document `replace` with its precondition.
92
+ */
93
+ export declare function aliasWrite(draft: DraftView, alias: string | undefined, writes: {
94
+ replace(doc: CmsDocument, revision: number | undefined): void;
95
+ }): CmsDocument;
43
96
  /** The patch that writes the value `doc` holds at `path` (unset when absent). */
44
97
  export declare function positionPatch(doc: CmsDocument, path: ValuePath): DocumentPatch;
45
98
  /**
@@ -74,8 +127,43 @@ export declare function canvasWrite(input: {
74
127
  * field did not move. The canvas's own edits are in both values (the session's view holds
75
128
  * every patch it committed, the draft overlays every position it has not), so the change
76
129
  * is someone else's: a colleague's patch, a write's canonical row, a resolved conflict.
130
+ *
131
+ * EC-647: it carries `revision`, the revision the draft is at after the rebase, and is
132
+ * reported when only THAT moved too (`from` ≡ `to`): the canvas's agreed value is valid
133
+ * at the newer revision, so its next edit is authored there — the revision follows the
134
+ * value, never runs ahead of it.
135
+ *
136
+ * EC-667: `revision` is the token of the canvas FIELD's position in the shown view
137
+ * ({@link rebaseDraftView}) — under a pending snapshot the snapshot's revision. The canvas
138
+ * mounts at the draft's whole-document token (the earliest, possibly earlier than its
139
+ * field's), so the first rebase with a token reports it even when nothing moved.
140
+ */
141
+ export declare function canvasFieldChange(previous: CmsDocument, next: CmsDocument, path: ValuePath, last: CanvasFieldChange | undefined, revision: number | undefined): CanvasFieldChange | undefined;
142
+ /**
143
+ * EC-647 — the canvas's write path (pure; what {@link DocumentDraft.setCanvasValue} runs):
144
+ * the canvas writer's delta into the draft ({@link canvasWrite}, handed to `apply`), then
145
+ * routed ({@link routeCanvasWrite}) — committed at once, or held as a focused inspector
146
+ * control's uncommitted position — and in both cases paired with the revision of the
147
+ * canvas value the edit was authored on (`lift.revision`), never the draft's: the draft
148
+ * may already hold a colleague's change the canvas has not been rebased onto (Codex F12),
149
+ * and the store must check the edit from the canvas's revision to refuse it where it
150
+ * overlaps that change. `commit` absent → a draft without a revisioned write model (the
151
+ * draft moves, nothing is committed). `undefined` when the canvas changed nothing.
77
152
  */
78
- export declare function canvasFieldChange(previous: CmsDocument, next: CmsDocument, path: ValuePath, last: CanvasFieldChange | undefined): CanvasFieldChange | undefined;
153
+ export declare function canvasCommit(input: {
154
+ collection: CollectionDef;
155
+ draft: CmsDocument;
156
+ field: string;
157
+ locale: Locale;
158
+ lift: CanvasLift;
159
+ lookupManifest: PropFieldLookup;
160
+ /** The focused canvas inspector control, if focus is on one. */
161
+ focused: CanvasControl | undefined;
162
+ committer: DraftCommitter;
163
+ /** The draft takes the canvas's write (before it is routed — a forced commit reads it). */
164
+ apply: (next: CmsDocument) => void;
165
+ commit?: (patches: readonly DocumentPatch[], bases: readonly (number | undefined)[]) => void;
166
+ }): 'deferred' | 'committed' | 'draft' | undefined;
79
167
  /**
80
168
  * The reader's "now" for presence liveness: THIS session's own latest heartbeat `at`
81
169
  * (written by the same store clock as everyone else's — the playground store runs a
@@ -87,6 +175,13 @@ export declare function canvasFieldChange(previous: CmsDocument, next: CmsDocume
87
175
  export declare function presenceReaderNow(entries: readonly PresenceEntry[], sessionId: string, fallback: string): string;
88
176
  export interface DocumentDraft {
89
177
  draft: CmsDocument;
178
+ /**
179
+ * EC-647 — the revision of the session view the draft was last rebased onto (see
180
+ * {@link DraftView}): a writer that authors from {@link draft} (the translation) writes
181
+ * at this revision. EC-667: the view's WHOLE-DOCUMENT token — the earliest authoring
182
+ * revision of any position it shows (a field's own edit reads its position's token).
183
+ */
184
+ revision: number | undefined;
90
185
  /** Uncommitted (document model) / unsaved (record model) edits exist. */
91
186
  dirty: boolean;
92
187
  /** The record model: the editor renders a Save button + navigation guard. */
@@ -96,12 +191,14 @@ export interface DocumentDraft {
96
191
  /** A writer that transforms ONE field position (fill / reset from base). */
97
192
  editField(field: FieldDef, update: (draft: CmsDocument) => CmsDocument): void;
98
193
  /**
99
- * The canvas emitted a new value for its field (node patches, committed at once). `base`
100
- * is the value the canvas last agreed on with the draft: the canvas writer's delta is
101
- * `base` → `value`, applied over whatever the draft holds now (EC-630 — a change that
102
- * landed underneath meanwhile is never reverted by the canvas's own write).
194
+ * The canvas emitted a new value for its field (node patches, committed at once). The
195
+ * lift's `base` is the value the canvas last agreed on with the draft: the canvas
196
+ * writer's delta is `base` → `value`, applied over whatever the draft holds now (EC-630 —
197
+ * a change that landed underneath meanwhile is never reverted by the canvas's own
198
+ * write); its `revision` is the revision that value was agreed at — the revision the
199
+ * edit is committed at (EC-647, {@link canvasCommit}).
103
200
  */
104
- setCanvasValue(field: FieldDef, value: unknown, base: unknown): void;
201
+ setCanvasValue(field: FieldDef, lift: CanvasLift): void;
105
202
  /** The slot alias (a document-level key: a whole-document write at the held revision). */
106
203
  setAlias(alias: string | undefined): void;
107
204
  /** Field boundary events (focus / blur / Enter) — the commit policy's triggers. */
@@ -47,6 +47,51 @@ export const documentVisibility = {
47
47
  return () => document.removeEventListener('visibilitychange', listener);
48
48
  },
49
49
  };
50
+ /** EC-667 — the draft of a session view as it is shown (pure): its value, its whole-document token, the view itself. */
51
+ export function sessionDraftView(doc, tokens) {
52
+ return { document: doc, revision: tokens?.revisionOfView(doc), shown: doc };
53
+ }
54
+ /**
55
+ * EC-667 — the base an edit of `path` that STARTS now on `draft` is authored against
56
+ * (pure; the committer captures it at the first keystroke): the revision the content at
57
+ * that position of the shown session view was authored against — under a pending
58
+ * whole-document snapshot the snapshot's revision, under a pending own write that write's
59
+ * base — never the revision of a row adopted underneath it. Captured at the start, it
60
+ * survives the pending write's refusal or failure (looking it up when the commit arrives
61
+ * would be too late). Without a view token: the draft's whole-document revision.
62
+ */
63
+ export function positionBase(draft, path, tokens) {
64
+ return tokens && draft.shown ? tokens.revisionOfView(draft.shown, path) : draft.revision;
65
+ }
66
+ /**
67
+ * THE draft rebase as one value (pure — what the editor runs when the session's document
68
+ * changes): the session view with the uncommitted positions laid over it ({@link
69
+ * rebaseDraft}), its whole-document token, the view itself (EC-647 → EC-667: the rebase
70
+ * moves the value AND its tokens), and — when the screen has a canvas — the token of the
71
+ * canvas field's position, which the canvas's next edit is authored at.
72
+ */
73
+ export function rebaseDraftView(doc, previous, uncommitted, tokens, canvasPath) {
74
+ return {
75
+ view: { document: rebaseDraft(doc, previous.document, uncommitted), revision: tokens.revisionOfView(doc), shown: doc },
76
+ canvasRevision: canvasPath ? tokens.revisionOfView(doc, canvasPath) : undefined,
77
+ };
78
+ }
79
+ /**
80
+ * EC-647 — the slot alias write (pure): the draft with `alias` set (or removed), written
81
+ * whole AT THE DRAFT'S REVISION — never the session's tracked one, which may already hold
82
+ * a colleague's change the draft has not absorbed (Codex F10). There is no identity-
83
+ * metadata operation for the alias (the value-position grammar has no position for it,
84
+ * measured on the base), so it stays a whole-document `replace` with its precondition.
85
+ */
86
+ export function aliasWrite(draft, alias, writes) {
87
+ const next = { ...draft.document };
88
+ if (alias === undefined)
89
+ delete next.alias;
90
+ else
91
+ next.alias = alias;
92
+ writes.replace(next, draft.revision);
93
+ return next;
94
+ }
50
95
  /** The patch that writes the value `doc` holds at `path` (unset when absent). */
51
96
  export function positionPatch(doc, path) {
52
97
  const value = readValueAt(doc, path);
@@ -87,11 +132,61 @@ export function canvasWrite(input) {
87
132
  * field did not move. The canvas's own edits are in both values (the session's view holds
88
133
  * every patch it committed, the draft overlays every position it has not), so the change
89
134
  * is someone else's: a colleague's patch, a write's canonical row, a resolved conflict.
135
+ *
136
+ * EC-647: it carries `revision`, the revision the draft is at after the rebase, and is
137
+ * reported when only THAT moved too (`from` ≡ `to`): the canvas's agreed value is valid
138
+ * at the newer revision, so its next edit is authored there — the revision follows the
139
+ * value, never runs ahead of it.
140
+ *
141
+ * EC-667: `revision` is the token of the canvas FIELD's position in the shown view
142
+ * ({@link rebaseDraftView}) — under a pending snapshot the snapshot's revision. The canvas
143
+ * mounts at the draft's whole-document token (the earliest, possibly earlier than its
144
+ * field's), so the first rebase with a token reports it even when nothing moved.
90
145
  */
91
- export function canvasFieldChange(previous, next, path, last) {
146
+ export function canvasFieldChange(previous, next, path, last, revision) {
92
147
  const from = readValueAt(previous, path);
93
148
  const to = readValueAt(next, path);
94
- return semanticValueEqual(from, to) ? undefined : { from, to, seq: (last?.seq ?? 0) + 1 };
149
+ const tokenHeld = last === undefined ? revision === undefined : last.revision === revision;
150
+ if (semanticValueEqual(from, to) && tokenHeld)
151
+ return undefined;
152
+ return { from, to, seq: (last?.seq ?? 0) + 1, revision };
153
+ }
154
+ /**
155
+ * EC-647 — the canvas's write path (pure; what {@link DocumentDraft.setCanvasValue} runs):
156
+ * the canvas writer's delta into the draft ({@link canvasWrite}, handed to `apply`), then
157
+ * routed ({@link routeCanvasWrite}) — committed at once, or held as a focused inspector
158
+ * control's uncommitted position — and in both cases paired with the revision of the
159
+ * canvas value the edit was authored on (`lift.revision`), never the draft's: the draft
160
+ * may already hold a colleague's change the canvas has not been rebased onto (Codex F12),
161
+ * and the store must check the edit from the canvas's revision to refuse it where it
162
+ * overlaps that change. `commit` absent → a draft without a revisioned write model (the
163
+ * draft moves, nothing is committed). `undefined` when the canvas changed nothing.
164
+ */
165
+ export function canvasCommit(input) {
166
+ const { lift, commit } = input;
167
+ const write = canvasWrite({
168
+ collection: input.collection,
169
+ draft: input.draft,
170
+ field: input.field,
171
+ locale: input.locale,
172
+ value: lift.value,
173
+ base: lift.base,
174
+ lookupManifest: input.lookupManifest,
175
+ });
176
+ if (!write)
177
+ return undefined;
178
+ input.apply(write.next);
179
+ if (!commit)
180
+ return 'draft';
181
+ // EC-630: a burst of typing in a focused inspector control is that control's
182
+ // uncommitted position (its policy commits it); a canvas action commits now.
183
+ return routeCanvasWrite({
184
+ patches: write.patches,
185
+ focused: input.focused,
186
+ committer: input.committer,
187
+ revision: lift.revision,
188
+ commitNow: (patches) => commit(patches, patches.map(() => lift.revision)),
189
+ });
95
190
  }
96
191
  /**
97
192
  * The reader's "now" for presence liveness: THIS session's own latest heartbeat `at`
@@ -118,18 +213,23 @@ export function useDocumentDraft(input) {
118
213
  const writes = source.writes;
119
214
  const documentModel = writes.kind === 'document' ? writes : undefined;
120
215
  const ref = useMemo(() => ({ collection: doc.collection, id: doc.id }), [doc.collection, doc.id]);
121
- const [draft, setDraftState] = useState(doc);
122
- const draftRef = useRef(draft);
216
+ // EC-647: the draft and the revision of the view it rebased onto travel as ONE value
217
+ // (EC-667: with the view itself — the per-position token).
218
+ const [view, setViewState] = useState(() => sessionDraftView(doc, documentModel));
219
+ const draft = view.document;
220
+ const draftRef = useRef(view);
123
221
  // EC-616: the record as the editor opened it (or as of their last successful save) — the
124
222
  // BASE a record rail diffs a save against, so it writes only the editor's changes. It moves
125
223
  // only while the draft equals the source's value (at open, and once a save is reflected
126
224
  // back): another writer's change arriving mid-edit never becomes the editor's base.
127
225
  const baseRef = useRef(doc);
128
- baseRef.current = recordSaveBase(baseRef.current, draftRef.current, doc);
129
- const setDraft = useCallback((next) => {
226
+ baseRef.current = recordSaveBase(baseRef.current, draftRef.current.document, doc);
227
+ const setView = useCallback((next) => {
130
228
  draftRef.current = next;
131
- setDraftState(next);
229
+ setViewState(next);
132
230
  }, []);
231
+ /** An edit of the draft: its value moves, the tokens it is authored against do not. */
232
+ const setDraft = useCallback((next) => setView({ ...draftRef.current, document: next }), [setView]);
133
233
  const scheduler = input.scheduler ?? browserScheduler;
134
234
  const now = input.now ?? (() => new Date().toISOString());
135
235
  const fieldsByName = useMemo(() => new Map(collection.fields.map((f) => [f.name, f])), [collection]);
@@ -149,9 +249,14 @@ export function useDocumentDraft(input) {
149
249
  const target = writesRef.current;
150
250
  if (!target)
151
251
  return;
152
- void target.commit(ref, positions.map((position) => positionPatch(draftRef.current, position.path)), positions.map((position) => position.base));
252
+ void target.commit(ref, positions.map((position) => positionPatch(draftRef.current.document, position.path)), positions.map((position) => position.base));
153
253
  },
154
- baseOf: () => writesRef.current?.revisionOf(ref),
254
+ // EC-647: a position's base is the revision of the DRAFT its first keystroke was
255
+ // made on — never the session's tracked revision, which may already hold a
256
+ // colleague's change the draft has not rebased onto yet. EC-667: the form's
257
+ // writers name the POSITION's token ({@link positionBase}, passed to `edited`);
258
+ // this whole-document token (the earliest) is the fallback.
259
+ baseOf: () => draftRef.current.revision,
155
260
  scheduler,
156
261
  }),
157
262
  // One committer per open document (the editor is keyed by it).
@@ -178,63 +283,71 @@ export function useDocumentDraft(input) {
178
283
  }
179
284
  if (!documentModel)
180
285
  return;
181
- const previous = draftRef.current;
182
- const next = rebaseDraft(doc, previous, committer.uncommitted());
183
- setDraft(next);
286
+ const previous = draftRef.current.document;
287
+ // EC-647: the rebase moves the value AND the revision — the session view's own
288
+ // (EC-667: its tokens, whole-document and per position).
289
+ const { view: next, canvasRevision } = rebaseDraftView(doc, draftRef.current, committer.uncommitted(), documentModel, canvasPath);
290
+ setView(next);
184
291
  // EC-630: the canvas field moved underneath the canvas — the canvas rebases onto the
185
292
  // change (its own edits are in both values: the session's view holds every patch it
186
- // committed, and the draft overlays every position it has not).
293
+ // committed, and the draft overlays every position it has not). EC-647: the change
294
+ // carries the revision — EC-667: the canvas FIELD's token — so the canvas's next edit
295
+ // is authored where its value is.
187
296
  if (!canvasPath)
188
297
  return;
189
- setCanvasChange((last) => canvasFieldChange(previous, next, canvasPath, last) ?? last);
298
+ setCanvasChange((last) => canvasFieldChange(previous, next.document, canvasPath, last, canvasRevision) ?? last);
190
299
  }, [doc]);
191
300
  // --- Writers ----------------------------------------------------------------------------
192
301
  const setFieldValue = useCallback((field, value) => {
193
- const next = writeField(collection, draftRef.current, field.name, locale, value, { lookupManifest });
302
+ // EC-667: the position's token is read BEFORE the draft moves — from the view this
303
+ // keystroke was made on (a re-edit keeps its first keystroke's either way).
304
+ const path = fieldPositionPath(collection, field.name, locale);
305
+ const authored = { base: positionBase(draftRef.current, path, documentModel) };
306
+ const next = writeField(collection, draftRef.current.document, field.name, locale, value, { lookupManifest });
194
307
  setDraft(next);
195
308
  if (documentModel)
196
- committer.edited(field.name, fieldPositionPath(collection, field.name, locale));
309
+ committer.edited(field.name, path, authored);
197
310
  }, [collection, locale, lookupManifest, setDraft, documentModel, committer]);
198
311
  const editField = useCallback((field, update) => {
199
- setDraft(update(draftRef.current));
312
+ const path = fieldPositionPath(collection, field.name, locale);
313
+ const authored = { base: positionBase(draftRef.current, path, documentModel) };
314
+ setDraft(update(draftRef.current.document));
200
315
  if (documentModel)
201
- committer.edited(field.name, fieldPositionPath(collection, field.name, locale));
316
+ committer.edited(field.name, path, authored);
202
317
  }, [collection, locale, setDraft, documentModel, committer]);
203
- const setCanvasValue = useCallback((field, value, base) => {
204
- const write = canvasWrite({ collection, draft: draftRef.current, field: field.name, locale, value, base, lookupManifest });
205
- if (!write)
206
- return;
207
- setDraft(write.next);
208
- if (!documentModel)
209
- return;
210
- // EC-630: a burst of typing in a focused inspector control is that control's
211
- // uncommitted position (its policy commits it); a canvas action commits now.
318
+ const setCanvasValue = useCallback((field, lift) => {
212
319
  const focus = committer.focusedField();
213
- routeCanvasWrite({
214
- patches: write.patches,
320
+ canvasCommit({
321
+ collection,
322
+ draft: draftRef.current.document,
323
+ field: field.name,
324
+ locale,
325
+ lift,
326
+ lookupManifest,
215
327
  focused: focus === undefined ? undefined : canvasControls.current.get(focus),
216
328
  committer,
217
- commitNow: (patches) => void documentModel.commit(ref, patches),
329
+ apply: setDraft,
330
+ ...(documentModel
331
+ ? { commit: (patches, bases) => void documentModel.commit(ref, patches, bases) }
332
+ : {}),
218
333
  });
219
334
  }, [collection, locale, lookupManifest, setDraft, documentModel, ref, committer]);
220
335
  const setAlias = useCallback((alias) => {
221
- const next = { ...draftRef.current };
222
- if (alias === undefined)
223
- delete next.alias;
224
- else
225
- next.alias = alias;
226
- setDraft(next);
227
- if (!documentModel)
336
+ if (!documentModel) {
337
+ aliasWrite(draftRef.current, alias, { replace: (next) => setDraft(next) });
228
338
  return;
339
+ }
340
+ // The uncommitted positions first (the snapshot holds them), then the snapshot AT
341
+ // THE DRAFT'S REVISION (EC-647 — Codex F10).
229
342
  committer.commitAll();
230
- documentModel.replace(next);
343
+ setDraft(aliasWrite(draftRef.current, alias, documentModel));
231
344
  }, [setDraft, documentModel, committer]);
232
345
  // --- Forced flush ----------------------------------------------------------------------
233
346
  const save = useCallback(() => {
234
347
  if (documentModel)
235
348
  committer.commitAll();
236
349
  else if (writes.kind === 'record')
237
- writes.save(draftRef.current, baseRef.current);
350
+ writes.save(draftRef.current.document, baseRef.current);
238
351
  }, [documentModel, committer, writes]);
239
352
  const flush = useCallback(async () => {
240
353
  save();
@@ -334,16 +447,18 @@ export function useDocumentDraft(input) {
334
447
  const replaceDraft = useCallback((next) => {
335
448
  // A restored version replaces the document: when it moved the canvas field, the
336
449
  // canvas remounts from it (its history belonged to the replaced value).
337
- const moved = canvasPath && !semanticValueEqual(readValueAt(draftRef.current, canvasPath), readValueAt(next, canvasPath));
338
- setDraft(next);
450
+ const moved = canvasPath && !semanticValueEqual(readValueAt(draftRef.current.document, canvasPath), readValueAt(next, canvasPath));
451
+ // EC-647: the restored value comes with the revision the session showed it at.
452
+ setView(sessionDraftView(next, documentModel));
339
453
  if (moved)
340
454
  setCanvasNonce((n) => n + 1);
341
455
  },
342
456
  // The path is keyed by its string form.
343
- [setDraft, canvasPathKey]);
457
+ [setView, documentModel, canvasPathKey]);
344
458
  const dirty = useMemo(() => !semanticValueEqual(draft, doc), [draft, doc]);
345
459
  return {
346
460
  draft,
461
+ revision: view.revision,
347
462
  dirty,
348
463
  explicitSave: writes.kind === 'record',
349
464
  setFieldValue,