@marimo-team/frontend 0.25.3-dev92 → 0.25.3-dev95

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 (123) hide show
  1. package/dist/assets/{CellStatus-DwPjR2_8.js → CellStatus-CplYFNTy.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-D9UK8o9A.js → ConnectedDataExplorerComponent-Dft8R6l5.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-BhXbaVBa.js → ErrorBoundary-CdGuz4a_.js} +2 -2
  4. package/dist/assets/{JsonOutput-Dti6dc1X.js → JsonOutput-Csbov8Fx.js} +1 -1
  5. package/dist/assets/{MarimoErrorOutput-BKvntkfq.js → MarimoErrorOutput-czclLlOn.js} +1 -1
  6. package/dist/assets/{RenderHTML-DUI47UDk.js → RenderHTML-CNDedpS4.js} +1 -1
  7. package/dist/assets/{RunButton-CLhvSMZG.js → RunButton-CRfdOxVd.js} +1 -1
  8. package/dist/assets/{add-cell-with-ai-ev63rhN4.js → add-cell-with-ai-BAIX7r0P.js} +1 -1
  9. package/dist/assets/{add-connection-dialog-2Tgl7Mjn.js → add-connection-dialog-DrDuM-tI.js} +1 -1
  10. package/dist/assets/{agent-panel-D4lNacey.js → agent-panel-CoXq_rlA.js} +1 -1
  11. package/dist/assets/{ai-model-dropdown-BqjVBWQp.js → ai-model-dropdown-COvWn46z.js} +1 -1
  12. package/dist/assets/{app-config-button-BToMQZBN.js → app-config-button-CtxoYG4F.js} +1 -1
  13. package/dist/assets/{blob-DMgg2NZo.js → blob-DCjrjZKW.js} +1 -1
  14. package/dist/assets/{cache-panel-Byolv2ht.js → cache-panel-CbWZcBu0.js} +1 -1
  15. package/dist/assets/{cell-editor-DGRa0o5e.js → cell-editor-BnL0Sorl.js} +1 -1
  16. package/dist/assets/{cell-link-D6rI90gy.js → cell-link-BFbgKiRH.js} +1 -1
  17. package/dist/assets/{cells-Cetiartc.js → cells-yOs72eZm.js} +1 -1
  18. package/dist/assets/{chat-display-CgpJ6XM4.js → chat-display-DwRM41t_.js} +1 -1
  19. package/dist/assets/{chat-panel-Djs36Uy4.js → chat-panel-DeTEdjxF.js} +1 -1
  20. package/dist/assets/{chat-ui-B_JGdp0E.js → chat-ui-D6XucD07.js} +1 -1
  21. package/dist/assets/{code-visibility-ny5n3-GH.js → code-visibility-DYDagVy7.js} +1 -1
  22. package/dist/assets/{column-preview-BIpGfTJf.js → column-preview-DXM8SqGY.js} +1 -1
  23. package/dist/assets/{command-palette-9pxpuKHs.js → command-palette-CA6yfNLb.js} +1 -1
  24. package/dist/assets/{common-D0jeQZnr.js → common-B-ZYnO1x.js} +1 -1
  25. package/dist/assets/{components-CJRBUI2s.js → components-CBE8cOLK.js} +1 -1
  26. package/dist/assets/{components-DrU8dOzy.js → components-DCo7mAWO.js} +1 -1
  27. package/dist/assets/{components-DkQChRNr.js → components-vu2jLG08.js} +1 -1
  28. package/dist/assets/{config-BWKfkUWA.js → config-CD1IuZFJ.js} +1 -1
  29. package/dist/assets/{config-CP8Y8_R0.js → config-DMz7oN0r.js} +1 -1
  30. package/dist/assets/{config-DGX7JYor.js → config-DpY86_Yq.js} +1 -1
  31. package/dist/assets/{createReducer-HKXUbqyz.js → createReducer-CaI_Oa0v.js} +1 -1
  32. package/dist/assets/{datasource-BXFNO5td.js → datasource-BakEOWsf.js} +1 -1
  33. package/dist/assets/{dependency-graph-panel-BFVIEz0s.js → dependency-graph-panel-DCGB3_id.js} +1 -1
  34. package/dist/assets/{documentation-panel-CaUMsgkg.js → documentation-panel-CpGZ2Dfn.js} +1 -1
  35. package/dist/assets/{download-C-q7-OBT.js → download-CV1k4XQ4.js} +1 -1
  36. package/dist/assets/{edit-page-VhYLLklC.js → edit-page-By7n2M84.js} +4 -4
  37. package/dist/assets/{error-entries-p_IDc4iy.js → error-entries-JgrZYMbe.js} +1 -1
  38. package/dist/assets/{error-panel-B0BwSTRv.js → error-panel-DS7HWph-.js} +1 -1
  39. package/dist/assets/feedback-button-Dgs_HOOC.js +1 -0
  40. package/dist/assets/{feedback-button-D0HjY39R.js → feedback-button-eYsTCK-g.js} +1 -1
  41. package/dist/assets/{file-explorer-panel-BLIEpQzW.js → file-explorer-panel-BTLwwwD4.js} +1 -1
  42. package/dist/assets/{file-name-input-CZ_W4QXG.js → file-name-input-BaR4h2Ud.js} +1 -1
  43. package/dist/assets/{floating-outline-D7DFh_29.js → floating-outline-Drd73Quo.js} +1 -1
  44. package/dist/assets/{gallery-page-BLgoybvc.js → gallery-page-DXUD11Rm.js} +1 -1
  45. package/dist/assets/{glide-data-editor-DVpOmYXo.js → glide-data-editor-DqBBoQBl.js} +1 -1
  46. package/dist/assets/{home-page--OLVJPiE.js → home-page-pgFFsmga.js} +1 -1
  47. package/dist/assets/{hooks-D0Pzb8Q4.js → hooks-D25kP4Sr.js} +1 -1
  48. package/dist/assets/{index-DB50a7AR.js → index-DNG4qQ-E.js} +6 -6
  49. package/dist/assets/{input-BZdRe7pb.js → input-DmHMEZGh.js} +1 -1
  50. package/dist/assets/kiosk-mode-DFS6HopF.js +1 -0
  51. package/dist/assets/{layout-HYpXx9Kc.js → layout-DLHIGZTK.js} +3 -3
  52. package/dist/assets/links-DX2w97N9.js +1 -0
  53. package/dist/assets/{logs-panel-Cbx5BJ3w.js → logs-panel-D5AAD2s9.js} +1 -1
  54. package/dist/assets/{markdown-renderer-B1GLwS-C.js → markdown-renderer--CwVm4kJ.js} +1 -1
  55. package/dist/assets/{mermaid-B6mqMm5_.js → mermaid-BKXlWk4F.js} +1 -1
  56. package/dist/assets/{name-cell-input-CgINqxMv.js → name-cell-input-BFKZaNI5.js} +1 -1
  57. package/dist/assets/{outline-panel-DaCt5blP.js → outline-panel-qcwRnl0R.js} +1 -1
  58. package/dist/assets/{package-data-dv4FBHxR.js → package-data-CKPmBDtg.js} +1 -1
  59. package/dist/assets/{packages-panel-DRKwpeJ2.js → packages-panel-DnSryNcz.js} +1 -1
  60. package/dist/assets/{panel-accordion-state-C4fxJULv.js → panel-accordion-state-BwJpDMVt.js} +1 -1
  61. package/dist/assets/{panels-D_TmFEit.js → panels-Aqzwb3UR.js} +1 -1
  62. package/dist/assets/{process-output-D1KxMfN7.js → process-output-DuUfHYSr.js} +1 -1
  63. package/dist/assets/{readonly-code-display-DVn7_uLn.js → readonly-code-display-BY-9I_Yk.js} +1 -1
  64. package/dist/assets/{readonly-python-code-5F8u1711.js → readonly-python-code-DSyr-yE2.js} +1 -1
  65. package/dist/assets/{renderShortcut-H60uiHBp.js → renderShortcut-rUwGqKgC.js} +1 -1
  66. package/dist/assets/{requests-lt0JK8Jt.js → requests-BIFR-h4M.js} +1 -1
  67. package/dist/assets/requests-DuLJN9Tf.js +1 -0
  68. package/dist/assets/{reveal-component-B4rC_fg1.js → reveal-component-C_Lb7Mom.js} +1 -1
  69. package/dist/assets/{run-page-BHdHPiQA.js → run-page-ZTw-kCU-.js} +1 -1
  70. package/dist/assets/{runs-6lJxKV1w.js → runs-C49hleBy.js} +1 -1
  71. package/dist/assets/{runtime-details-state-BLDCeDBF.js → runtime-details-state-B7gpnuXx.js} +1 -1
  72. package/dist/assets/{scratchpad-panel-ChSdhkiu.js → scratchpad-panel-D2IHkKoG.js} +1 -1
  73. package/dist/assets/{secrets-panel-Dh3v7XPE.js → secrets-panel-bcfEKxRx.js} +1 -1
  74. package/dist/assets/{session-B8Y-GJnS.js → session-CMLIzZKw.js} +1 -1
  75. package/dist/assets/session-panel-cOSO3WCX.js +1 -0
  76. package/dist/assets/{snippets-panel-D7PPIwX1.js → snippets-panel-BNadUJLU.js} +1 -1
  77. package/dist/assets/{state-BY4_P6ko.js → state-3mcyTS9q.js} +1 -1
  78. package/dist/assets/{state-C0irhlOZ.js → state-B_MCKzBL.js} +1 -1
  79. package/dist/assets/{state-Bd_wAeZf.js → state-BaYudep3.js} +1 -1
  80. package/dist/assets/{state-DaWgt-rn.js → state-C5WAeuTu.js} +1 -1
  81. package/dist/assets/{state-Ck7FMD5A.js → state-Cv2JW3l9.js} +1 -1
  82. package/dist/assets/{state-pXUWLVXv.js → state-_1LTGJQM.js} +1 -1
  83. package/dist/assets/{switch-IoWE8PbW.js → switch-DYjBfpNJ.js} +1 -1
  84. package/dist/assets/{terminal-BwLC9HS5.js → terminal-Ca-kubKb.js} +1 -1
  85. package/dist/assets/{tracing-WN4XSiAm.js → tracing-DbTgS4u9.js} +1 -1
  86. package/dist/assets/{tracing-panel-BVC4DsH6.js → tracing-panel-CjlrpXPy.js} +2 -2
  87. package/dist/assets/{tree-actions-MaUvbTxA.js → tree-actions-C_KYlaSv.js} +1 -1
  88. package/dist/assets/{useAddCell-D7SKNQll.js → useAddCell-C0J8quGE.js} +1 -1
  89. package/dist/assets/{useAsyncData-DhhdOGGd.js → useAsyncData-qvoPYHtx.js} +1 -1
  90. package/dist/assets/{useBoolean-WABMLM83.js → useBoolean-Dd2nWXFJ.js} +1 -1
  91. package/dist/assets/{useCellActionButton-Byz7LlUo.js → useCellActionButton-CbkOj6BE.js} +1 -1
  92. package/dist/assets/{useDebounce-k9QTpQ9-.js → useDebounce-DwTQ_1Pu.js} +1 -1
  93. package/dist/assets/{useDeepCompareMemoize-DFIVN9dn.js → useDeepCompareMemoize-B2DVK2De.js} +1 -1
  94. package/dist/assets/{useDeleteCell-D-k2fD1X.js → useDeleteCell-DHkF5wgS.js} +1 -1
  95. package/dist/assets/{useDependencyPanelTab-neRJ4T16.js → useDependencyPanelTab--yXJW6X5.js} +1 -1
  96. package/dist/assets/{useEvent-Dfcbgjhg.js → useEvent-BDjgaBtz.js} +1 -1
  97. package/dist/assets/{useInstallPackage-CWcPxSLP.js → useInstallPackage-wcDq9hYT.js} +1 -1
  98. package/dist/assets/{useNotebookActions-Df_jxEKJ.js → useNotebookActions-dJQalORg.js} +1 -1
  99. package/dist/assets/{useRunCells-AZJD5ITo.js → useRunCells-C_IwVJR_.js} +1 -1
  100. package/dist/assets/{useSplitCell-CgiU_w77.js → useSplitCell-Ci4MEUj8.js} +1 -1
  101. package/dist/assets/{useTheme-DLtPc-39.js → useTheme-BAA27lh7.js} +1 -1
  102. package/dist/assets/{vega-component-CY6HmM06.js → vega-component-BeyxAJad.js} +1 -1
  103. package/dist/assets/{write-secret-modal-7DzehGyr.js → write-secret-modal-BEx-EUlh.js} +1 -1
  104. package/dist/index.html +40 -40
  105. package/package.json +1 -1
  106. package/src/components/datasources/__tests__/table-list.test.tsx +118 -0
  107. package/src/components/datasources/datasources.tsx +35 -21
  108. package/src/components/pages/__tests__/home-page.test.tsx +116 -0
  109. package/src/components/pages/home-page.tsx +24 -3
  110. package/src/core/document/__tests__/draft.test.ts +322 -0
  111. package/src/core/document/__tests__/fixtures.ts +92 -0
  112. package/src/core/document/__tests__/model.test.ts +434 -0
  113. package/src/core/document/__tests__/submission.test.ts +334 -0
  114. package/src/core/document/draft.ts +472 -0
  115. package/src/core/document/model.ts +395 -0
  116. package/src/core/state/__tests__/jotai.test.tsx +108 -0
  117. package/src/core/state/jotai.ts +1 -1
  118. package/dist/assets/feedback-button-BFoleTOt.js +0 -1
  119. package/dist/assets/kiosk-mode-BGmMSc7A.js +0 -1
  120. package/dist/assets/links-CXcpNIM0.js +0 -1
  121. package/dist/assets/requests-CgSY1X4a.js +0 -1
  122. package/dist/assets/session-panel-CtQgwbFW.js +0 -1
  123. package/src/core/state/__tests__/jotai.test.ts +0 -25
@@ -0,0 +1,472 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { Document } from "../host/resources/document";
4
+ import * as Protocol from "@marimo-team/client/protocol";
5
+
6
+ /** The fields a client edits. Null code means the host withheld source. */
7
+ export type EditableCell = Readonly<
8
+ Pick<Protocol.Cell, "id" | "code" | "name">
9
+ > & {
10
+ readonly config: Readonly<Protocol.CellConfig>;
11
+ };
12
+
13
+ type CellWithSource = EditableCell & { readonly code: string };
14
+
15
+ /** A confirmed document with a draft applied. */
16
+ export interface EditableDocument {
17
+ readonly order: readonly Protocol.CellId[];
18
+ readonly cells: ReadonlyMap<Protocol.CellId, EditableCell>;
19
+ }
20
+
21
+ /** Fields to change on a cell. A field left out keeps its value, including within config. */
22
+ export interface CellEdit {
23
+ readonly code?: string;
24
+ readonly name?: string;
25
+ readonly config?: Partial<Readonly<Protocol.CellConfig>>;
26
+ }
27
+
28
+ type MutableEdit = { -readonly [K in keyof CellEdit]: CellEdit[K] };
29
+
30
+ /** Where a created cell goes. With neither, at the end. */
31
+ export interface Placement {
32
+ readonly after?: Protocol.CellId;
33
+ readonly before?: Protocol.CellId;
34
+ }
35
+
36
+ /** What this client wants one cell to become. */
37
+ type CellDraft =
38
+ | { readonly kind: "edit"; readonly edit: CellEdit }
39
+ | {
40
+ readonly kind: "create";
41
+ readonly cell: CellWithSource;
42
+ readonly placement: Placement;
43
+ }
44
+ | { readonly kind: "delete" };
45
+
46
+ /**
47
+ * This client's changes, layered over a confirmed document.
48
+ *
49
+ * A draft is a document delta in the terms of the host's edit request:
50
+ * per-cell puts and deletes, and optionally the order as a whole. The view
51
+ * a person edits and the request that synchronizes it both derive from it.
52
+ */
53
+ export interface Draft {
54
+ readonly cells: ReadonlyMap<Protocol.CellId, CellDraft>;
55
+ /** The full order to apply, or null to follow the host's. */
56
+ readonly order: readonly Protocol.CellId[] | null;
57
+ }
58
+
59
+ export const EMPTY_DRAFT: Draft = { cells: new Map(), order: null };
60
+
61
+ export function isEmptyDraft(draft: Draft): boolean {
62
+ return draft.cells.size === 0 && draft.order === null;
63
+ }
64
+
65
+ // Merging -------------------------------------------------------------------
66
+
67
+ /** Copy the defined fields of `patch` over `base`. */
68
+ function assignDefined<T extends object>(base: T, patch: Partial<T>): T {
69
+ const result = { ...base };
70
+ for (const key in patch) {
71
+ const value = patch[key];
72
+ if (value !== undefined) {
73
+ result[key] = value;
74
+ }
75
+ }
76
+ return result;
77
+ }
78
+
79
+ /** The defined fields of `to` that differ from `from`. */
80
+ function differingFields<T extends object>(
81
+ from: T,
82
+ to: Partial<T>,
83
+ ): Partial<T> {
84
+ const result: Partial<T> = {};
85
+ for (const key in to) {
86
+ const value = to[key];
87
+ if (value !== undefined && value !== from[key]) {
88
+ result[key] = value;
89
+ }
90
+ }
91
+ return result;
92
+ }
93
+
94
+ function isEmpty(object: object): boolean {
95
+ return Object.keys(object).length === 0;
96
+ }
97
+
98
+ function sameOrder(
99
+ a: readonly Protocol.CellId[],
100
+ b: readonly Protocol.CellId[],
101
+ ): boolean {
102
+ return a.length === b.length && a.every((id, index) => id === b[index]);
103
+ }
104
+
105
+ /** Combine two edits, the later one winning field by field. */
106
+ function mergeEdit(previous: CellEdit, edit: CellEdit): CellEdit {
107
+ const merged = assignDefined(previous, edit);
108
+ return edit.config === undefined
109
+ ? merged
110
+ : { ...merged, config: assignDefined(previous.config ?? {}, edit.config) };
111
+ }
112
+
113
+ /** The cell with an edit applied, as a new object with the editable fields. */
114
+ function applyEdit(cell: CellWithSource, edit: CellEdit): CellWithSource;
115
+ function applyEdit(cell: EditableCell, edit: CellEdit): EditableCell;
116
+ function applyEdit(cell: EditableCell, edit: CellEdit): EditableCell {
117
+ return {
118
+ id: cell.id,
119
+ code: edit.code ?? cell.code,
120
+ name: edit.name ?? cell.name,
121
+ config:
122
+ edit.config === undefined
123
+ ? cell.config
124
+ : assignDefined(cell.config, edit.config),
125
+ };
126
+ }
127
+
128
+ export function sameCell(a: EditableCell, b: EditableCell): boolean {
129
+ return (
130
+ a.id === b.id &&
131
+ a.code === b.code &&
132
+ a.name === b.name &&
133
+ isEmpty(differingFields(a.config, b.config))
134
+ );
135
+ }
136
+
137
+ /** The edit that turns `from` into `to`. Empty when they are the same. */
138
+ function editBetween(from: CellWithSource, to: CellWithSource): CellEdit {
139
+ const edit: MutableEdit = {};
140
+ if (to.code !== from.code) {
141
+ edit.code = to.code;
142
+ }
143
+ if (to.name !== from.name) {
144
+ edit.name = to.name;
145
+ }
146
+ const config = differingFields(from.config, to.config);
147
+ if (!isEmpty(config)) {
148
+ edit.config = config;
149
+ }
150
+ return edit;
151
+ }
152
+
153
+ /** The fields of `edit` still waiting after `sent` was accepted. */
154
+ function remainingEdit(edit: CellEdit, sent: CellEdit): CellEdit {
155
+ const remaining: MutableEdit = {};
156
+ if (edit.code !== undefined && edit.code !== sent.code) {
157
+ remaining.code = edit.code;
158
+ }
159
+ if (edit.name !== undefined && edit.name !== sent.name) {
160
+ remaining.name = edit.name;
161
+ }
162
+ if (edit.config !== undefined) {
163
+ const config = differingFields(sent.config ?? {}, edit.config);
164
+ if (!isEmpty(config)) {
165
+ remaining.config = config;
166
+ }
167
+ }
168
+ return remaining;
169
+ }
170
+
171
+ // Transitions ---------------------------------------------------------------
172
+ //
173
+ // Transitions are total. One with no effect returns its input, so a store
174
+ // can skip the write and its notifications.
175
+
176
+ function withCell(
177
+ draft: Draft,
178
+ id: Protocol.CellId,
179
+ cell: CellDraft | undefined,
180
+ ): Draft {
181
+ const cells = new Map(draft.cells);
182
+ if (cell === undefined) {
183
+ cells.delete(id);
184
+ } else {
185
+ cells.set(id, cell);
186
+ }
187
+ return { ...draft, cells };
188
+ }
189
+
190
+ /** Place a cell, falling back to the end if its anchor has disappeared. */
191
+ function insertAt(
192
+ order: readonly Protocol.CellId[],
193
+ id: Protocol.CellId,
194
+ placement: Placement,
195
+ ): Protocol.CellId[] {
196
+ const without = order.filter((other) => other !== id);
197
+ let index = without.length;
198
+ if (placement.after !== undefined) {
199
+ const anchor = without.indexOf(placement.after);
200
+ if (anchor !== -1) {
201
+ index = anchor + 1;
202
+ }
203
+ } else if (placement.before !== undefined) {
204
+ const anchor = without.indexOf(placement.before);
205
+ if (anchor !== -1) {
206
+ index = anchor;
207
+ }
208
+ }
209
+ return [...without.slice(0, index), id, ...without.slice(index)];
210
+ }
211
+
212
+ /**
213
+ * Edit a cell's fields.
214
+ *
215
+ * An edit to a cell the host has removed is dropped: there is no state to
216
+ * apply it to. So is an edit to a cell this draft deletes.
217
+ */
218
+ export function editCell(
219
+ confirmed: Document,
220
+ draft: Draft,
221
+ id: Protocol.CellId,
222
+ edit: CellEdit,
223
+ ): Draft {
224
+ const current = draft.cells.get(id);
225
+ if (current?.kind === "delete") {
226
+ return draft;
227
+ }
228
+ if (current?.kind === "create") {
229
+ const cell = applyEdit(current.cell, edit);
230
+ return sameCell(cell, current.cell)
231
+ ? draft
232
+ : withCell(draft, id, { ...current, cell });
233
+ }
234
+ const base = confirmed.cells.get(id);
235
+ if (base === undefined) {
236
+ return draft;
237
+ }
238
+ const previous = current?.edit ?? {};
239
+ const next = mergeEdit(previous, edit);
240
+ return sameCell(applyEdit(base, previous), applyEdit(base, next))
241
+ ? draft
242
+ : withCell(draft, id, { kind: "edit", edit: next });
243
+ }
244
+
245
+ /**
246
+ * Create a cell with the id the client chose.
247
+ *
248
+ * Omitted fields take the protocol's defaults. A draft order, if any, gains
249
+ * the cell at its placement so that it stays complete.
250
+ */
251
+ export function createCell(
252
+ draft: Draft,
253
+ input: Protocol.CellInput,
254
+ placement: Placement = {},
255
+ ): Draft {
256
+ const parsed = Protocol.CellInput.parse(input);
257
+ const cell: CellWithSource = {
258
+ id: parsed.id,
259
+ code: parsed.code,
260
+ name: parsed.name,
261
+ config: parsed.config ?? Protocol.CellConfigInput.parse({}),
262
+ };
263
+ const next = withCell(draft, cell.id, { kind: "create", cell, placement });
264
+ return draft.order === null
265
+ ? next
266
+ : { ...next, order: insertAt(draft.order, cell.id, placement) };
267
+ }
268
+
269
+ /** Forget an unsent creation; otherwise retain the deletion until confirmed. */
270
+ export function deleteCell(
271
+ draft: Draft,
272
+ id: Protocol.CellId,
273
+ sent: Draft = EMPTY_DRAFT,
274
+ ): Draft {
275
+ const current = draft.cells.get(id);
276
+ if (current?.kind === "delete") {
277
+ return draft;
278
+ }
279
+ const next = withCell(
280
+ draft,
281
+ id,
282
+ current?.kind === "create" && sent.cells.get(id)?.kind !== "create"
283
+ ? undefined
284
+ : { kind: "delete" },
285
+ );
286
+ return draft.order === null
287
+ ? next
288
+ : { ...next, order: draft.order.filter((other) => other !== id) };
289
+ }
290
+
291
+ /** Replace the order. Lists each cell of the document once, as `set_order` does. */
292
+ export function setOrder(
293
+ draft: Draft,
294
+ order: readonly Protocol.CellId[],
295
+ ): Draft {
296
+ return draft.order !== null && sameOrder(draft.order, order)
297
+ ? draft
298
+ : { ...draft, order: [...order] };
299
+ }
300
+
301
+ /** Drop satisfied deletions, retaining edits to missing cells for recovery. */
302
+ export function prune(
303
+ confirmed: Document,
304
+ draft: Draft,
305
+ sent: Draft = EMPTY_DRAFT,
306
+ ): Draft {
307
+ let cells: Map<Protocol.CellId, CellDraft> | undefined;
308
+ for (const [id, cell] of draft.cells) {
309
+ if (
310
+ cell.kind === "delete" &&
311
+ !confirmed.cells.has(id) &&
312
+ sent.cells.get(id)?.kind !== "create"
313
+ ) {
314
+ cells ??= new Map(draft.cells);
315
+ cells.delete(id);
316
+ }
317
+ }
318
+ return cells === undefined ? draft : { ...draft, cells };
319
+ }
320
+
321
+ /**
322
+ * Release a draft after the accepted revision is present in the source.
323
+ *
324
+ * `sent` is the draft the accepted request was derived from. A field is
325
+ * released when its value is still the one sent; a created cell becomes an
326
+ * edit of whatever changed after it was sent. The model enforces the revision
327
+ * precondition; this pure transition assumes it has already been checked.
328
+ */
329
+ export function acknowledge(draft: Draft, sent: Draft): Draft {
330
+ let next = draft;
331
+ for (const [id, accepted] of sent.cells) {
332
+ const current = next.cells.get(id);
333
+ if (current?.kind === "delete" && accepted.kind === "delete") {
334
+ next = withCell(next, id, undefined);
335
+ } else if (current?.kind === "create" && accepted.kind === "create") {
336
+ next = withEdit(next, id, editBetween(accepted.cell, current.cell));
337
+ } else if (current?.kind === "edit" && accepted.kind === "edit") {
338
+ next = withEdit(next, id, remainingEdit(current.edit, accepted.edit));
339
+ }
340
+ }
341
+ if (
342
+ next.order !== null &&
343
+ sent.order !== null &&
344
+ sameOrder(next.order, sent.order)
345
+ ) {
346
+ next = { ...next, order: null };
347
+ }
348
+ return next;
349
+ }
350
+
351
+ function withEdit(draft: Draft, id: Protocol.CellId, edit: CellEdit): Draft {
352
+ return withCell(
353
+ draft,
354
+ id,
355
+ isEmpty(edit) ? undefined : { kind: "edit", edit },
356
+ );
357
+ }
358
+
359
+ // Projections ---------------------------------------------------------------
360
+
361
+ /** A create or edit as the edit it makes to an existing cell. */
362
+ function editOf(local: Exclude<CellDraft, { kind: "delete" }>): CellEdit {
363
+ return local.kind === "edit" ? local.edit : local.cell;
364
+ }
365
+
366
+ /**
367
+ * The confirmed document with a draft applied.
368
+ *
369
+ * A cell without a draft is the confirmed record itself, so its identity is
370
+ * stable until the host changes it. Cells the host added since a draft order
371
+ * was set go at the end; cells it removed leave the order.
372
+ */
373
+ export function viewDocument(
374
+ confirmed: Document,
375
+ draft: Draft,
376
+ ): EditableDocument {
377
+ const cells = new Map<Protocol.CellId, EditableCell>();
378
+ for (const [id, cell] of confirmed.cells) {
379
+ const local = draft.cells.get(id);
380
+ if (local?.kind === "delete") {
381
+ continue;
382
+ }
383
+ cells.set(id, local === undefined ? cell : applyEdit(cell, editOf(local)));
384
+ }
385
+ let order: readonly Protocol.CellId[] =
386
+ draft.order ?? confirmed.revision.order;
387
+ for (const [id, local] of draft.cells) {
388
+ if (local.kind === "create" && !confirmed.cells.has(id)) {
389
+ cells.set(id, local.cell);
390
+ if (draft.order === null) {
391
+ order = insertAt(order, id, local.placement);
392
+ }
393
+ }
394
+ }
395
+ const placed = new Set(order);
396
+ const unplaced = [...cells.keys()].filter((id) => !placed.has(id));
397
+ if (unplaced.length > 0 || order.some((id) => !cells.has(id))) {
398
+ order = [...order.filter((id) => cells.has(id)), ...unplaced];
399
+ }
400
+ return {
401
+ order,
402
+ cells,
403
+ };
404
+ }
405
+
406
+ /**
407
+ * The edit request that would make the host match the view.
408
+ *
409
+ * Preconditions are the confirmed etags. A put with no effect is left out.
410
+ * Cells come before the order, so one request can create a cell and then
411
+ * place it. Returns null if a put lacks source or its edited cell was removed,
412
+ * keeping the draft atomic and retaining local intent for recovery.
413
+ */
414
+ export function toChanges(
415
+ confirmed: Document,
416
+ draft: Draft,
417
+ ): Protocol.Change[] | null {
418
+ const changes: Protocol.Change[] = [];
419
+ let stagedOrder = confirmed.revision.order;
420
+ const stagedIds = new Set(stagedOrder);
421
+ for (const [id, local] of draft.cells) {
422
+ const base = confirmed.cells.get(id);
423
+ if (local.kind === "delete") {
424
+ if (base !== undefined) {
425
+ changes.push({ kind: "delete_cell", id, base_etag: base.etag });
426
+ stagedOrder = stagedOrder.filter((other) => other !== id);
427
+ stagedIds.delete(id);
428
+ }
429
+ } else if (local.kind === "create") {
430
+ const anchor = local.placement.after ?? local.placement.before;
431
+ const placement =
432
+ anchor === undefined || stagedIds.has(anchor) ? local.placement : {};
433
+ changes.push({
434
+ kind: "put_cell",
435
+ base_etag: null,
436
+ cell: local.cell,
437
+ ...placement,
438
+ });
439
+ if (base === undefined) {
440
+ stagedOrder = insertAt(stagedOrder, id, placement);
441
+ stagedIds.add(id);
442
+ }
443
+ } else if (base !== undefined) {
444
+ const cell = applyEdit(base, local.edit);
445
+ if (!sameCell(base, cell)) {
446
+ const { code } = cell;
447
+ if (code === null) {
448
+ return null;
449
+ }
450
+ changes.push({
451
+ kind: "put_cell",
452
+ base_etag: base.etag,
453
+ cell: { ...cell, code },
454
+ });
455
+ }
456
+ } else {
457
+ return null;
458
+ }
459
+ }
460
+ const { order } = viewDocument(confirmed, draft);
461
+ if (
462
+ (draft.order !== null && !sameOrder(order, confirmed.revision.order)) ||
463
+ !sameOrder(order, stagedOrder)
464
+ ) {
465
+ changes.push({
466
+ kind: "set_order",
467
+ base_etag: confirmed.revision.order_etag,
468
+ order: [...order],
469
+ });
470
+ }
471
+ return changes;
472
+ }