@marimo-team/frontend 0.25.3-dev100 → 0.25.3-dev102

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.
@@ -3,6 +3,7 @@
3
3
  import { atom, createStore } from "jotai/vanilla";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import type { Document } from "../../host/resources/document";
6
+ import { selectCell } from "../select";
6
7
  import { createDocumentState, type DocumentSubmission } from "../state";
7
8
  import { cell, document, withCells } from "./fixtures";
8
9
 
@@ -25,7 +26,7 @@ function setup() {
25
26
  store,
26
27
  source,
27
28
  ...model,
28
- abort: () => controller.abort(),
29
+ cellAtom: (id: string) => selectCell(model.documentAtom, id),
29
30
  submit() {
30
31
  const submission = store.set(model.submitAtom);
31
32
  if (submission === null) {
@@ -104,106 +105,39 @@ describe("document submissions", () => {
104
105
  );
105
106
 
106
107
  it.each(["response", "stream"] as const)(
107
- "preserves later typing when the %s arrives first",
108
+ "does not notify cell subscribers when the %s acknowledges a superseded draft",
108
109
  (first) => {
109
- const {
110
- store,
111
- source,
112
- editAtom,
113
- cellAtom,
114
- changesAtom,
115
- submitAtom,
116
- submit,
117
- settle,
118
- } = setup();
110
+ const { store, source, editAtom, cellAtom, submit, settle } = setup();
119
111
  store.set(editAtom, (tx) => {
120
112
  tx.setCode("c1", "sent");
121
- tx.updateConfig("c1", { hide_code: true });
113
+ tx.createCell({ id: "c3", code: "new" });
122
114
  });
123
115
  const sent = submit();
124
- store.set(editAtom, (tx) => tx.setCode("c1", "still typing"));
125
- const confirmed = withCells(document(), {
126
- c1: cell("c1", "sent", {
127
- etag: "accepted",
128
- config: { ...cell("c1", "").config, hide_code: true },
129
- }),
116
+ store.set(editAtom, (tx) => {
117
+ tx.setCode("c1", "still typing");
118
+ tx.deleteCell("c3");
130
119
  });
131
- const onCell = vi.fn();
132
- store.sub(cellAtom("c1"), onCell);
133
- const arrivals = {
134
- response: () => settle(sent, confirmed),
135
- stream: () => store.set(source, confirmed),
136
- };
137
- arrivals[first]();
138
- expect(store.set(submitAtom)).toBeNull();
139
- arrivals[first === "response" ? "stream" : "response"]();
140
- expect(onCell).not.toHaveBeenCalled();
141
- expect(store.get(cellAtom("c1"))?.code).toBe("still typing");
142
- expect(store.get(changesAtom)).toEqual([
143
- {
144
- kind: "put_cell",
145
- base_etag: "accepted",
146
- cell: {
147
- id: "c1",
148
- code: "still typing",
149
- name: "_",
150
- config: confirmed.cells.get("c1")?.config,
151
- },
152
- },
153
- ]);
154
- const next = submit();
155
- settle(sent, confirmed);
156
- expect(store.set(submitAtom)).toBeNull();
157
- const updated = withCells(confirmed, {
158
- c1: cell("c1", "still typing", {
159
- config: confirmed.cells.get("c1")!.config,
160
- }),
120
+ const onCode = vi.fn();
121
+ const onCreation = vi.fn();
122
+ const unsubscribeCode = store.sub(cellAtom("c1"), onCode);
123
+ const unsubscribeCreation = store.sub(cellAtom("c3"), onCreation);
124
+ const confirmed = withCells(document(), {
125
+ c1: cell("c1", "sent", { etag: "accepted", version: 2 }),
126
+ c3: cell("c3", "new"),
161
127
  });
162
- settle(next, updated);
163
- store.set(source, updated);
164
- expect(store.get(changesAtom)).toEqual([]);
165
- },
166
- );
167
-
168
- it.each(["response", "stream"] as const)(
169
- "never shows a cancelled creation when the %s arrives first",
170
- (first) => {
171
- const {
172
- store,
173
- source,
174
- editAtom,
175
- cellAtom,
176
- orderAtom,
177
- changesAtom,
178
- submit,
179
- settle,
180
- } = setup();
181
- store.set(editAtom, (tx) => tx.createCell({ id: "c3", code: "new" }));
182
- const sent = submit();
183
- store.set(editAtom, (tx) => tx.deleteCell("c3"));
184
- const onCell = vi.fn();
185
- store.sub(cellAtom("c3"), onCell);
186
- // An unrelated revision before the creation must not forget the deletion.
187
- const intermediate = withCells(document(), { c2: cell("c2", "remote") });
188
- store.set(source, intermediate);
189
- const confirmed = withCells(intermediate, { c3: cell("c3", "new") });
190
128
  const arrivals = {
191
129
  response: () => settle(sent, confirmed),
192
130
  stream: () => store.set(source, confirmed),
193
131
  };
194
- arrivals[first]();
195
- arrivals[first === "response" ? "stream" : "response"]();
196
- expect(onCell).not.toHaveBeenCalled();
197
- expect(store.get(orderAtom)).toEqual(["c1", "c2"]);
198
- expect(store.get(changesAtom)).toEqual([
199
- { kind: "delete_cell", id: "c3", base_etag: "c3-etag" },
200
- ]);
201
- const deletion = submit();
202
- const removed = withCells(confirmed, { c3: null });
203
- store.set(source, removed);
204
- settle(deletion, removed);
205
- settle(sent, confirmed);
206
- expect(store.get(changesAtom)).toEqual([]);
132
+ try {
133
+ arrivals[first]();
134
+ arrivals[first === "response" ? "stream" : "response"]();
135
+ expect(onCode).not.toHaveBeenCalled();
136
+ expect(onCreation).not.toHaveBeenCalled();
137
+ } finally {
138
+ unsubscribeCode();
139
+ unsubscribeCreation();
140
+ }
207
141
  },
208
142
  );
209
143
 
@@ -227,36 +161,6 @@ describe("document submissions", () => {
227
161
  expect(store.set(submitAtom)).toBeNull();
228
162
  });
229
163
 
230
- it.each(["before reset", "during reset", "after snapshot"] as const)(
231
- "confirms a submission across reset when its response arrives %s",
232
- (arrival) => {
233
- const { store, source, editAtom, cellAtom, submit, settle } = setup();
234
- store.set(editAtom, (tx) => tx.setCode("c1", "unsaved"));
235
- const sent = submit();
236
- const old = withCells(document(), { c1: cell("c1", "unsaved") });
237
- store.set(editAtom, (tx) => tx.setCode("c1", "newer typing"));
238
- if (arrival === "before reset") {
239
- settle(sent, old);
240
- }
241
- store.set(source, null);
242
- if (arrival === "during reset") {
243
- settle(sent, old);
244
- }
245
- const fresh = withCells(old, { c2: cell("c2", "unrelated") });
246
- store.set(source, fresh);
247
- if (arrival === "after snapshot") {
248
- settle(sent, old);
249
- }
250
- expect(store.get(cellAtom("c1"))?.code).toBe("newer typing");
251
- const retried = submit();
252
- const accepted = withCells(fresh, { c1: cell("c1", "newer typing") });
253
- settle(retried, accepted);
254
- store.set(source, accepted);
255
- store.set(source, withCells(accepted, { c1: cell("c1", "remote") }));
256
- expect(store.get(cellAtom("c1"))?.code).toBe("remote");
257
- },
258
- );
259
-
260
164
  it("invalidates submissions when switching notebooks, retaining each draft", () => {
261
165
  const { store, source, editAtom, cellAtom, submit, settle } = setup();
262
166
  store.set(editAtom, (tx) => tx.setCode("c1", "first"));
@@ -296,51 +200,4 @@ describe("document submissions", () => {
296
200
  settle(sent, remote);
297
201
  expect(store.set(submitAtom)).toBeNull();
298
202
  });
299
-
300
- it("does not treat a response for another notebook as acceptance", () => {
301
- const { store, editAtom, settleAtom, submitAtom, submit } = setup();
302
- store.set(editAtom, (tx) => tx.setCode("c1", "local"));
303
- const sent = submit();
304
- expect(() => store.set(settleAtom, sent, document("n2").revision)).toThrow(
305
- "another notebook",
306
- );
307
- expect(store.set(submitAtom)).toBeNull();
308
- });
309
-
310
- it("releases an unsent reversal without making a request", () => {
311
- const { store, source, editAtom, cellAtom, submitAtom } = setup();
312
- store.set(editAtom, (tx) => tx.setCode("c1", "local"));
313
- store.set(editAtom, (tx) => tx.setCode("c1", "x = 1"));
314
- expect(store.set(submitAtom)).toBeNull();
315
- store.set(source, withCells(document(), { c1: cell("c1", "remote") }));
316
- expect(store.get(cellAtom("c1"))?.code).toBe("remote");
317
- });
318
-
319
- it("stops observing and accepting work after disposal", () => {
320
- const {
321
- store,
322
- source,
323
- editAtom,
324
- orderAtom,
325
- submitAtom,
326
- submit,
327
- settle,
328
- abort,
329
- } = setup();
330
- store.set(editAtom, (tx) => tx.setCode("c1", "local"));
331
- const sent = submit();
332
- abort();
333
- const onOrder = vi.fn();
334
- store.sub(orderAtom, onOrder);
335
- const confirmed = withCells(document(), { c1: cell("c1", "local") });
336
- store.set(source, confirmed);
337
- settle(sent, confirmed);
338
- const edit = vi.fn();
339
- store.set(editAtom, edit);
340
- abort();
341
- expect(edit).not.toHaveBeenCalled();
342
- expect(onOrder).not.toHaveBeenCalled();
343
- expect(store.get(orderAtom)).toBeNull();
344
- expect(store.set(submitAtom)).toBeNull();
345
- });
346
203
  });
@@ -0,0 +1,92 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import * as Protocol from "@marimo-team/client/protocol";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+ import { initialDocument, protocolFixture } from "./protocol-fixtures";
6
+ import { createTestHost } from "./test-host";
7
+
8
+ afterEach(() => vi.useRealTimers());
9
+
10
+ it("matches the portable host examples for accepted and conflicting edits", () => {
11
+ const host = createTestHost(initialDocument());
12
+ const accepted = host.process({
13
+ notebookId: "n1",
14
+ idempotencyKey: "accepted",
15
+ changes: Protocol.EditRequest.parse(protocolFixture("edit.json")).changes,
16
+ });
17
+ const expected = Protocol.EditResponse.parse(
18
+ protocolFixture("edit-response.json"),
19
+ );
20
+ expect(accepted.result).toEqual({ ok: true, status: 200, data: expected });
21
+ expect(accepted.update).toEqual(host.document());
22
+
23
+ const rejected = host.process({
24
+ notebookId: "n1",
25
+ idempotencyKey: "rejected",
26
+ changes: Protocol.EditRequest.parse(
27
+ protocolFixture("edit-conflict-request.json"),
28
+ ).changes,
29
+ });
30
+ const problem = Protocol.Problem.parse(
31
+ protocolFixture("problems/edit-conflict.json"),
32
+ );
33
+ expect(rejected).toEqual({
34
+ result: {
35
+ ok: false,
36
+ status: 409,
37
+ problem: { ...problem, detail: expect.any(String) },
38
+ },
39
+ });
40
+ expect(host.document().revision).toEqual(expected.document);
41
+ });
42
+
43
+ it("rejects an entire batch when a later precondition is stale", () => {
44
+ const initial = initialDocument();
45
+ const host = createTestHost(initial);
46
+ const result = host.apply([
47
+ { kind: "put_cell", base_etag: null, cell: { id: "created", code: "new" } },
48
+ { kind: "delete_cell", id: "c3", base_etag: "stale" },
49
+ ]);
50
+ expect(result).toMatchObject({
51
+ result: { ok: false, status: 409, problem: { edit: { change: 1 } } },
52
+ });
53
+ expect(host.document()).toEqual(initial);
54
+ expect(result.update).toBeUndefined();
55
+ });
56
+
57
+ it("keeps successful and rejected receipts tied to their original payload and revision", () => {
58
+ vi.useFakeTimers({ now: 0 });
59
+ const host = createTestHost(initialDocument());
60
+ const request = {
61
+ notebookId: "n1",
62
+ idempotencyKey: "original",
63
+ changes: Protocol.EditRequest.parse(protocolFixture("edit.json")).changes,
64
+ };
65
+ const accepted = host.process(request);
66
+ const stale = {
67
+ notebookId: "n1",
68
+ idempotencyKey: "stale",
69
+ changes: Protocol.EditRequest.parse(
70
+ protocolFixture("edit-conflict-request.json"),
71
+ ).changes,
72
+ };
73
+ const rejected = host.process(stale);
74
+ host.apply([
75
+ { kind: "put_cell", base_etag: "*", cell: { id: "c3", code: "later" } },
76
+ ]);
77
+ const latest = host.document();
78
+ expect(host.process(request)).toEqual({ result: accepted.result });
79
+ expect(host.process(stale)).toEqual({ result: rejected.result });
80
+ expect(host.process({ ...request, changes: [] }).result).toMatchObject({
81
+ ok: false,
82
+ status: 409,
83
+ });
84
+ expect(host.document()).toEqual(latest);
85
+
86
+ vi.advanceTimersByTime(300_000);
87
+ expect(host.process(request).result).toMatchObject({
88
+ ok: false,
89
+ status: 409,
90
+ });
91
+ expect(host.document()).toEqual(latest);
92
+ });
@@ -0,0 +1,247 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { createHash } from "node:crypto";
4
+ import type { Result } from "@marimo-team/client";
5
+ import * as Protocol from "@marimo-team/client/protocol";
6
+ import type { Document } from "../../host/resources/document";
7
+
8
+ export interface EditRequest {
9
+ readonly notebookId: Protocol.NotebookId;
10
+ readonly changes: readonly Protocol.Change[];
11
+ readonly idempotencyKey: string;
12
+ }
13
+
14
+ interface Outcome {
15
+ readonly result: Result<Protocol.EditResponseOutput>;
16
+ /** Only a new commit produces a stream update; replaying a receipt does not. */
17
+ readonly update?: Document;
18
+ }
19
+
20
+ const hash = (text: string) =>
21
+ createHash("sha256").update(text).digest("hex").slice(0, 16);
22
+
23
+ function record(
24
+ input: Protocol.CellInput,
25
+ notebookId: Protocol.NotebookId,
26
+ version: number,
27
+ ): Protocol.Cell {
28
+ const cell = Protocol.CellInput.parse(input);
29
+ const config = Protocol.CellConfigInput.parse(cell.config ?? {});
30
+ const code = cell.code.replace(/\r\n?/g, "\n");
31
+ const codeHash = hash(code);
32
+ return {
33
+ id: cell.id,
34
+ notebook_id: notebookId,
35
+ name: cell.name,
36
+ code,
37
+ config,
38
+ code_hash: codeHash,
39
+ etag: hash(
40
+ [
41
+ codeHash,
42
+ cell.name,
43
+ ...[
44
+ config.column,
45
+ config.disabled,
46
+ config.hide_code,
47
+ config.expand_output,
48
+ ].map((value) => JSON.stringify(value)),
49
+ ].join("\n"),
50
+ ),
51
+ version,
52
+ };
53
+ }
54
+
55
+ function rejected(
56
+ status: number,
57
+ kind: string,
58
+ detail: string,
59
+ edit?: Protocol.Problem["edit"],
60
+ ): Outcome {
61
+ return {
62
+ result: {
63
+ ok: false,
64
+ status,
65
+ problem: {
66
+ type: `tag:marimo.io,2026:host/${kind}`,
67
+ title: kind === "conflict" ? "Conflict" : "Invalid request",
68
+ detail,
69
+ status,
70
+ ...(edit === undefined ? {} : { edit }),
71
+ },
72
+ },
73
+ };
74
+ }
75
+
76
+ /** An independent implementation of the document edit protocol, without client draft logic. */
77
+ export function createTestHost(initial: Document) {
78
+ let current = initial;
79
+ const receipts = new Map<
80
+ string,
81
+ {
82
+ fingerprint: string;
83
+ result: Result<Protocol.EditResponseOutput>;
84
+ expiresAt: number;
85
+ }
86
+ >();
87
+
88
+ function apply(changes: readonly Protocol.Change[]): Outcome {
89
+ const cells = new Map(current.cells);
90
+ let order = [...current.revision.order];
91
+ const touched = new Set<Protocol.CellId>();
92
+ const version = current.revision.version + 1;
93
+ const matches = (id: Protocol.CellId, etag: string) =>
94
+ etag === "*" || current.cells.get(id)?.etag === etag;
95
+ const conflict = (change: number, id?: Protocol.CellId) =>
96
+ rejected(409, "conflict", `Edit conflict at change ${change}`, {
97
+ change,
98
+ cell: id === undefined ? null : (current.cells.get(id) ?? null),
99
+ document: current.revision,
100
+ });
101
+
102
+ for (const [index, change] of changes.entries()) {
103
+ switch (change.kind) {
104
+ case "put_cell": {
105
+ const { id } = change.cell;
106
+ if (change.base_etag === null) {
107
+ if (current.cells.has(id) || cells.has(id)) {
108
+ return conflict(index, id);
109
+ }
110
+ if (change.after !== undefined && change.before !== undefined) {
111
+ return rejected(
112
+ 400,
113
+ "invalid-request",
114
+ "A cell cannot name both before and after",
115
+ );
116
+ }
117
+ const anchor = change.after ?? change.before;
118
+ if (anchor !== undefined && !order.includes(anchor)) {
119
+ return rejected(
120
+ 400,
121
+ "invalid-request",
122
+ "The placement anchor does not exist",
123
+ );
124
+ }
125
+ const position =
126
+ anchor === undefined
127
+ ? order.length
128
+ : order.indexOf(anchor) + (change.after === undefined ? 0 : 1);
129
+ order.splice(position, 0, id);
130
+ } else if (!cells.has(id) || !matches(id, change.base_etag)) {
131
+ return conflict(index, id);
132
+ }
133
+ cells.set(
134
+ id,
135
+ record(change.cell, current.revision.notebook_id, version),
136
+ );
137
+ touched.add(id);
138
+ break;
139
+ }
140
+ case "delete_cell":
141
+ if (!cells.has(change.id) || !matches(change.id, change.base_etag)) {
142
+ return conflict(index, change.id);
143
+ }
144
+ cells.delete(change.id);
145
+ order = order.filter((id) => id !== change.id);
146
+ touched.add(change.id);
147
+ break;
148
+ case "set_order":
149
+ if (
150
+ change.base_etag !== "*" &&
151
+ change.base_etag !== current.revision.order_etag
152
+ ) {
153
+ return conflict(index);
154
+ }
155
+ if (
156
+ change.order.length !== cells.size ||
157
+ new Set(change.order).size !== cells.size ||
158
+ change.order.some((id) => !cells.has(id))
159
+ ) {
160
+ return rejected(
161
+ 400,
162
+ "invalid-request",
163
+ "Order must name every cell exactly once",
164
+ );
165
+ }
166
+ order = [...change.order];
167
+ break;
168
+ }
169
+ }
170
+
171
+ for (const [id, cell] of cells) {
172
+ const previous = current.cells.get(id);
173
+ if (previous?.etag === cell.etag) {
174
+ cells.set(id, previous);
175
+ }
176
+ }
177
+ const orderEtag = hash(JSON.stringify(order));
178
+ const etag = hash(
179
+ [
180
+ orderEtag,
181
+ current.revision.app_config_etag,
182
+ ...order.map((id) => cells.get(id)!.etag),
183
+ ].join("\n"),
184
+ );
185
+ const changed = etag !== current.revision.etag;
186
+ if (changed) {
187
+ current = {
188
+ revision: {
189
+ ...current.revision,
190
+ order,
191
+ order_etag: orderEtag,
192
+ etag,
193
+ version,
194
+ },
195
+ cells,
196
+ };
197
+ }
198
+ const versions: Protocol.EditResponseOutput["cells"] = {};
199
+ for (const id of touched) {
200
+ const cell = current.cells.get(id);
201
+ versions[id] =
202
+ cell === undefined
203
+ ? null
204
+ : {
205
+ etag: cell.etag,
206
+ code_hash: cell.code_hash,
207
+ version: cell.version,
208
+ };
209
+ }
210
+ return {
211
+ result: {
212
+ ok: true,
213
+ status: 200,
214
+ data: { document: current.revision, cells: versions },
215
+ },
216
+ ...(changed ? { update: current } : {}),
217
+ };
218
+ }
219
+
220
+ return {
221
+ document: () => current,
222
+ apply,
223
+ process(request: EditRequest): Outcome {
224
+ const fingerprint = JSON.stringify([request.notebookId, request.changes]);
225
+ const receipt = receipts.get(request.idempotencyKey);
226
+ if (receipt !== undefined && Date.now() < receipt.expiresAt) {
227
+ return receipt.fingerprint === fingerprint
228
+ ? { result: receipt.result }
229
+ : rejected(
230
+ 409,
231
+ "conflict",
232
+ "Idempotency key was used for another request",
233
+ );
234
+ }
235
+ const outcome =
236
+ request.notebookId === current.revision.notebook_id
237
+ ? apply(request.changes)
238
+ : rejected(404, "not-found", "Notebook does not exist");
239
+ receipts.set(request.idempotencyKey, {
240
+ fingerprint,
241
+ result: outcome.result,
242
+ expiresAt: Date.now() + 300_000,
243
+ });
244
+ return outcome;
245
+ },
246
+ };
247
+ }