@marimo-team/islands 0.25.3-dev100 → 0.25.3-dev101
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.
- package/dist/{ErrorBoundary-GScQVnJ8.js → ErrorBoundary-kKLKZNGz.js} +1 -1
- package/dist/{code-visibility-DnqM5oW_.js → code-visibility-CK84K0J7.js} +1 -1
- package/dist/{common-D2HSo8Gy.js → common-DbG2Uufx.js} +1 -1
- package/dist/{data-grid-overlay-editor-BgIMQs9G.js → data-grid-overlay-editor-BggIiNWF.js} +1 -1
- package/dist/{feedback-button-BXcB6llx.js → feedback-button-CRF9v3d1.js} +2 -2
- package/dist/{glide-data-editor-S-_J-7SO.js → glide-data-editor-DgZRBzBJ.js} +3 -3
- package/dist/{globals-D4vBwcD6.js → globals-Bxo0esfB.js} +1 -1
- package/dist/main.js +6 -6
- package/dist/{number-overlay-editor-CUrUm7Np.js → number-overlay-editor-CI5EhMRq.js} +1 -1
- package/dist/{reveal-component-CFzdsOg8.js → reveal-component-Y4-XPwJF.js} +2 -2
- package/package.json +1 -1
- package/src/core/document/__tests__/lifecycle-scenarios.test.ts +61 -0
- package/src/core/document/__tests__/model.test.ts +0 -325
- package/src/core/document/__tests__/protocol-fixtures.ts +38 -0
- package/src/core/document/__tests__/recovery-scenarios.test.ts +230 -0
- package/src/core/document/__tests__/scenario-fixture.ts +42 -0
- package/src/core/document/__tests__/scenarios.test.ts +205 -0
- package/src/core/document/__tests__/submission.test.ts +22 -167
- package/src/core/document/__tests__/test-host.test.ts +92 -0
- package/src/core/document/__tests__/test-host.ts +247 -0
- package/src/core/document/__tests__/test-notebook.ts +139 -0
- package/src/core/document/__tests__/user.ts +61 -0
|
@@ -25,7 +25,6 @@ function setup() {
|
|
|
25
25
|
store,
|
|
26
26
|
source,
|
|
27
27
|
...model,
|
|
28
|
-
abort: () => controller.abort(),
|
|
29
28
|
submit() {
|
|
30
29
|
const submission = store.set(model.submitAtom);
|
|
31
30
|
if (submission === null) {
|
|
@@ -104,106 +103,39 @@ describe("document submissions", () => {
|
|
|
104
103
|
);
|
|
105
104
|
|
|
106
105
|
it.each(["response", "stream"] as const)(
|
|
107
|
-
"
|
|
106
|
+
"does not notify cell subscribers when the %s acknowledges a superseded draft",
|
|
108
107
|
(first) => {
|
|
109
|
-
const {
|
|
110
|
-
store,
|
|
111
|
-
source,
|
|
112
|
-
editAtom,
|
|
113
|
-
cellAtom,
|
|
114
|
-
changesAtom,
|
|
115
|
-
submitAtom,
|
|
116
|
-
submit,
|
|
117
|
-
settle,
|
|
118
|
-
} = setup();
|
|
108
|
+
const { store, source, editAtom, cellAtom, submit, settle } = setup();
|
|
119
109
|
store.set(editAtom, (tx) => {
|
|
120
110
|
tx.setCode("c1", "sent");
|
|
121
|
-
tx.
|
|
111
|
+
tx.createCell({ id: "c3", code: "new" });
|
|
122
112
|
});
|
|
123
113
|
const sent = submit();
|
|
124
|
-
store.set(editAtom, (tx) =>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
etag: "accepted",
|
|
128
|
-
config: { ...cell("c1", "").config, hide_code: true },
|
|
129
|
-
}),
|
|
114
|
+
store.set(editAtom, (tx) => {
|
|
115
|
+
tx.setCode("c1", "still typing");
|
|
116
|
+
tx.deleteCell("c3");
|
|
130
117
|
});
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
}),
|
|
118
|
+
const onCode = vi.fn();
|
|
119
|
+
const onCreation = vi.fn();
|
|
120
|
+
const unsubscribeCode = store.sub(cellAtom("c1"), onCode);
|
|
121
|
+
const unsubscribeCreation = store.sub(cellAtom("c3"), onCreation);
|
|
122
|
+
const confirmed = withCells(document(), {
|
|
123
|
+
c1: cell("c1", "sent", { etag: "accepted", version: 2 }),
|
|
124
|
+
c3: cell("c3", "new"),
|
|
161
125
|
});
|
|
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
126
|
const arrivals = {
|
|
191
127
|
response: () => settle(sent, confirmed),
|
|
192
128
|
stream: () => store.set(source, confirmed),
|
|
193
129
|
};
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
store.set(source, removed);
|
|
204
|
-
settle(deletion, removed);
|
|
205
|
-
settle(sent, confirmed);
|
|
206
|
-
expect(store.get(changesAtom)).toEqual([]);
|
|
130
|
+
try {
|
|
131
|
+
arrivals[first]();
|
|
132
|
+
arrivals[first === "response" ? "stream" : "response"]();
|
|
133
|
+
expect(onCode).not.toHaveBeenCalled();
|
|
134
|
+
expect(onCreation).not.toHaveBeenCalled();
|
|
135
|
+
} finally {
|
|
136
|
+
unsubscribeCode();
|
|
137
|
+
unsubscribeCreation();
|
|
138
|
+
}
|
|
207
139
|
},
|
|
208
140
|
);
|
|
209
141
|
|
|
@@ -227,36 +159,6 @@ describe("document submissions", () => {
|
|
|
227
159
|
expect(store.set(submitAtom)).toBeNull();
|
|
228
160
|
});
|
|
229
161
|
|
|
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
162
|
it("invalidates submissions when switching notebooks, retaining each draft", () => {
|
|
261
163
|
const { store, source, editAtom, cellAtom, submit, settle } = setup();
|
|
262
164
|
store.set(editAtom, (tx) => tx.setCode("c1", "first"));
|
|
@@ -296,51 +198,4 @@ describe("document submissions", () => {
|
|
|
296
198
|
settle(sent, remote);
|
|
297
199
|
expect(store.set(submitAtom)).toBeNull();
|
|
298
200
|
});
|
|
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
201
|
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { Result } from "@marimo-team/client";
|
|
4
|
+
import type * as Protocol from "@marimo-team/client/protocol";
|
|
5
|
+
import { atom, type createStore } from "jotai/vanilla";
|
|
6
|
+
import { vi } from "vitest";
|
|
7
|
+
import { Deferred } from "@/utils/Deferred";
|
|
8
|
+
import { invariant } from "@/utils/invariant";
|
|
9
|
+
import type { Document } from "../../host/resources/document";
|
|
10
|
+
import type { DocumentHost } from "../model";
|
|
11
|
+
import { createTestHost, type EditRequest } from "./test-host";
|
|
12
|
+
|
|
13
|
+
export interface PendingRequest extends EditRequest {
|
|
14
|
+
readonly signal: AbortSignal;
|
|
15
|
+
process(): void;
|
|
16
|
+
deliverResponse(): void;
|
|
17
|
+
loseResponse(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createTestNotebook(
|
|
21
|
+
store: ReturnType<typeof createStore>,
|
|
22
|
+
initial: Document,
|
|
23
|
+
) {
|
|
24
|
+
const host = createTestHost(initial);
|
|
25
|
+
const source = atom<Document | null>(initial);
|
|
26
|
+
const requests: PendingRequest[] = [];
|
|
27
|
+
const waiting: Deferred<PendingRequest>[] = [];
|
|
28
|
+
const documents: Document[] = [];
|
|
29
|
+
function apply(changes: readonly Protocol.Change[]) {
|
|
30
|
+
const { result, update } = host.apply(changes);
|
|
31
|
+
invariant(result.ok, "Another writer's edit was rejected");
|
|
32
|
+
if (update !== undefined) {
|
|
33
|
+
documents.push(update);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const edit = vi.fn<DocumentHost["edit"]>((notebookId, changes, options) => {
|
|
37
|
+
options.signal.throwIfAborted();
|
|
38
|
+
const reply = new Deferred<Result<Protocol.EditResponseOutput>>();
|
|
39
|
+
let outcome: Result<Protocol.EditResponseOutput> | undefined;
|
|
40
|
+
const abort = () => reply.reject(options.signal.reason);
|
|
41
|
+
const finish = () => options.signal.removeEventListener("abort", abort);
|
|
42
|
+
options.signal.addEventListener("abort", abort, { once: true });
|
|
43
|
+
void reply.promise.then(finish, finish);
|
|
44
|
+
const request: PendingRequest = {
|
|
45
|
+
notebookId,
|
|
46
|
+
changes: structuredClone(changes),
|
|
47
|
+
idempotencyKey: options.idempotencyKey,
|
|
48
|
+
signal: options.signal,
|
|
49
|
+
process() {
|
|
50
|
+
invariant(outcome === undefined, "Request was already processed");
|
|
51
|
+
// Cancellation of the client's wait does not roll back a server commit.
|
|
52
|
+
const processed = host.process(request);
|
|
53
|
+
outcome = processed.result;
|
|
54
|
+
if (processed.update !== undefined) {
|
|
55
|
+
documents.push(processed.update);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
deliverResponse() {
|
|
59
|
+
invariant(
|
|
60
|
+
outcome !== undefined,
|
|
61
|
+
"Process the request before delivering its response",
|
|
62
|
+
);
|
|
63
|
+
reply.resolve(outcome);
|
|
64
|
+
},
|
|
65
|
+
loseResponse() {
|
|
66
|
+
reply.reject(new Error("Connection lost"));
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const next = waiting.shift();
|
|
70
|
+
if (next === undefined) {
|
|
71
|
+
requests.push(request);
|
|
72
|
+
} else {
|
|
73
|
+
next.resolve(request);
|
|
74
|
+
}
|
|
75
|
+
return reply.promise;
|
|
76
|
+
});
|
|
77
|
+
const notebook = { documentAtom: source, edit } satisfies DocumentHost;
|
|
78
|
+
return {
|
|
79
|
+
notebook,
|
|
80
|
+
control: {
|
|
81
|
+
document: host.document,
|
|
82
|
+
nextRequest(): Promise<PendingRequest> {
|
|
83
|
+
const next = requests.shift();
|
|
84
|
+
if (next !== undefined) {
|
|
85
|
+
return Promise.resolve(next);
|
|
86
|
+
}
|
|
87
|
+
const deferred = new Deferred<PendingRequest>();
|
|
88
|
+
waiting.push(deferred);
|
|
89
|
+
return deferred.promise;
|
|
90
|
+
},
|
|
91
|
+
changeCell(
|
|
92
|
+
id: Protocol.CellId,
|
|
93
|
+
patch: Partial<Omit<Protocol.CellInput, "id">>,
|
|
94
|
+
) {
|
|
95
|
+
const cell = host.document().cells.get(id);
|
|
96
|
+
invariant(
|
|
97
|
+
cell !== undefined && cell.code !== null,
|
|
98
|
+
`No host source for cell ${id}`,
|
|
99
|
+
);
|
|
100
|
+
apply([
|
|
101
|
+
{
|
|
102
|
+
kind: "put_cell",
|
|
103
|
+
base_etag: cell.etag,
|
|
104
|
+
cell: {
|
|
105
|
+
...cell,
|
|
106
|
+
code: cell.code,
|
|
107
|
+
...patch,
|
|
108
|
+
config: { ...cell.config, ...patch.config },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
},
|
|
113
|
+
createCell(cell: Protocol.CellInput) {
|
|
114
|
+
apply([{ kind: "put_cell", base_etag: null, cell }]);
|
|
115
|
+
},
|
|
116
|
+
deleteCell(id: Protocol.CellId) {
|
|
117
|
+
const cell = host.document().cells.get(id);
|
|
118
|
+
invariant(cell !== undefined, `No host cell ${id}`);
|
|
119
|
+
apply([{ kind: "delete_cell", id, base_etag: cell.etag }]);
|
|
120
|
+
},
|
|
121
|
+
deliverDocument() {
|
|
122
|
+
const next = documents.shift();
|
|
123
|
+
invariant(
|
|
124
|
+
next !== undefined,
|
|
125
|
+
"No document update is waiting for delivery",
|
|
126
|
+
);
|
|
127
|
+
store.set(source, next);
|
|
128
|
+
},
|
|
129
|
+
reset() {
|
|
130
|
+
documents.length = 0;
|
|
131
|
+
store.set(source, null);
|
|
132
|
+
},
|
|
133
|
+
deliverSnapshot() {
|
|
134
|
+
documents.length = 0;
|
|
135
|
+
store.set(source, host.document());
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|