@marimo-team/frontend 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.
@@ -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
+ }
@@ -0,0 +1,61 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type * as Protocol from "@marimo-team/client/protocol";
4
+ import type { createStore } from "jotai/vanilla";
5
+ import type { DocumentModel } from "../model";
6
+
7
+ /** Translate user actions and observations without keeping any expected state. */
8
+ export function createUser(
9
+ store: ReturnType<typeof createStore>,
10
+ model: DocumentModel,
11
+ ) {
12
+ function observe(promise: Promise<Protocol.Document>) {
13
+ // A failed assertion may end a scenario before it awaits sync; teardown
14
+ // should not obscure that failure with an unhandled abort rejection.
15
+ void promise.catch(() => undefined);
16
+ return promise;
17
+ }
18
+ return {
19
+ changeCode(id: Protocol.CellId, code: string) {
20
+ store.set(model.editAtom, (tx) => tx.setCode(id, code));
21
+ },
22
+ changeCells(
23
+ changes: Record<Protocol.CellId, Partial<Omit<Protocol.CellInput, "id">>>,
24
+ ) {
25
+ store.set(model.editAtom, (tx) => {
26
+ for (const [id, patch] of Object.entries(changes)) {
27
+ if (patch.code !== undefined) {
28
+ tx.setCode(id, patch.code);
29
+ }
30
+ if (patch.name !== undefined) {
31
+ tx.setName(id, patch.name);
32
+ }
33
+ if (patch.config !== undefined) {
34
+ tx.updateConfig(id, patch.config);
35
+ }
36
+ }
37
+ });
38
+ },
39
+ createCell(cell: Protocol.CellInput) {
40
+ store.set(model.editAtom, (tx) => tx.createCell(cell));
41
+ },
42
+ deleteCell(id: Protocol.CellId) {
43
+ store.set(model.editAtom, (tx) => tx.deleteCell(id));
44
+ },
45
+ cell(id: Protocol.CellId) {
46
+ const cell = store.get(model.cellAtom(id));
47
+ return cell === undefined
48
+ ? undefined
49
+ : {
50
+ id: cell.id,
51
+ code: cell.code,
52
+ name: cell.name,
53
+ config: cell.config,
54
+ };
55
+ },
56
+ order: () => store.get(model.orderAtom),
57
+ status: () => store.get(model.syncStateAtom),
58
+ sync: () => observe(model.flush()),
59
+ retry: () => observe(model.retry()),
60
+ };
61
+ }