@marimo-team/frontend 0.25.3-dev19 → 0.25.3-dev20
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/assets/{edit-page-D_Rg0gQO.js → edit-page-CLF_VUTW.js} +1 -1
- package/dist/assets/{index-ptK1gxmo.js → index-CMx5iSyu.js} +2 -2
- package/dist/assets/panels-B_jlikjE.js +107 -0
- package/dist/assets/{run-page-AfZhtEh1.js → run-page-CkP22x9D.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/core/host/AGENTS.md +5 -0
- package/src/core/host/__tests__/document.test.ts +124 -0
- package/src/core/host/__tests__/fixtures.ts +39 -0
- package/src/core/host/__tests__/notebook-client.test.ts +116 -4
- package/src/core/host/__tests__/stream.test.ts +211 -0
- package/src/core/host/notebook-client.ts +47 -1
- package/src/core/host/resources/document.ts +101 -0
- package/src/core/host/resources/resource.ts +59 -0
- package/src/core/host/stream.ts +213 -0
- package/dist/assets/panels-BCcZ_yLK.js +0 -107
|
@@ -8,13 +8,30 @@ import { Deferred } from "@/utils/Deferred";
|
|
|
8
8
|
import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
|
|
9
9
|
import { classifyCloseEvent } from "../../websocket/connection-state";
|
|
10
10
|
import { createNotebookClient } from "../notebook-client";
|
|
11
|
+
import { fixtureMessages } from "./fixtures";
|
|
11
12
|
|
|
12
|
-
function
|
|
13
|
+
function notebookMessage(id: string) {
|
|
14
|
+
const notebook = {
|
|
15
|
+
id,
|
|
16
|
+
project_id: "p1",
|
|
17
|
+
path: null,
|
|
18
|
+
name: id,
|
|
19
|
+
operations: [],
|
|
20
|
+
updated_at: null,
|
|
21
|
+
};
|
|
22
|
+
return JSON.stringify({ event: "notebook", data: { notebook } });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function setup(url: () => string = () => "ws://localhost/ws") {
|
|
13
26
|
const events = new EventTarget();
|
|
14
27
|
const healthy = new Deferred<void>();
|
|
28
|
+
let provider = url;
|
|
29
|
+
const urls: URL[] = [];
|
|
15
30
|
const transport = {
|
|
16
31
|
readyState: WebSocket.CLOSED,
|
|
17
|
-
reconnect: vi.fn()
|
|
32
|
+
reconnect: vi.fn(() => {
|
|
33
|
+
urls.push(new URL(provider()));
|
|
34
|
+
}),
|
|
18
35
|
close: vi.fn(() => events.dispatchEvent(new CloseEvent("close"))),
|
|
19
36
|
send: vi.fn(),
|
|
20
37
|
addEventListener: events.addEventListener.bind(events),
|
|
@@ -27,8 +44,11 @@ function setup() {
|
|
|
27
44
|
const client = controller.client;
|
|
28
45
|
const connect = (signal: AbortSignal) =>
|
|
29
46
|
controller.connect({
|
|
30
|
-
createTransport: () =>
|
|
31
|
-
|
|
47
|
+
createTransport: (next) => {
|
|
48
|
+
provider = next;
|
|
49
|
+
return transport;
|
|
50
|
+
},
|
|
51
|
+
url,
|
|
32
52
|
messageFormat: "host",
|
|
33
53
|
signal,
|
|
34
54
|
waitUntilReady: () => healthy.promise,
|
|
@@ -47,6 +67,11 @@ function setup() {
|
|
|
47
67
|
onNotification,
|
|
48
68
|
store,
|
|
49
69
|
checkHealth,
|
|
70
|
+
urls,
|
|
71
|
+
receive: (message: unknown) =>
|
|
72
|
+
events.dispatchEvent(
|
|
73
|
+
new MessageEvent("message", { data: JSON.stringify(message) }),
|
|
74
|
+
),
|
|
50
75
|
};
|
|
51
76
|
}
|
|
52
77
|
|
|
@@ -79,6 +104,93 @@ describe("NotebookClient lifetime", () => {
|
|
|
79
104
|
},
|
|
80
105
|
);
|
|
81
106
|
|
|
107
|
+
it("resumes from the last cursor until reset or a new session", async () => {
|
|
108
|
+
const session = { id: "first", token: "a" };
|
|
109
|
+
const { connect, events, healthy, urls, receive } = setup(
|
|
110
|
+
() =>
|
|
111
|
+
`ws://localhost/ws?session_id=${session.id}&access_token=${session.token}&cursor=999`,
|
|
112
|
+
);
|
|
113
|
+
const lifetime = new AbortController();
|
|
114
|
+
connect(lifetime.signal);
|
|
115
|
+
healthy.resolve();
|
|
116
|
+
await healthy.promise;
|
|
117
|
+
expect(urls[0].searchParams.get("cursor")).toBeNull();
|
|
118
|
+
|
|
119
|
+
const snapshot = fixtureMessages("notebook-events.sse");
|
|
120
|
+
events.dispatchEvent(new Event("open"));
|
|
121
|
+
for (const message of snapshot) {
|
|
122
|
+
receive(message);
|
|
123
|
+
}
|
|
124
|
+
session.token = "b";
|
|
125
|
+
events.dispatchEvent(new CloseEvent("close"));
|
|
126
|
+
expect(urls[1].searchParams.get("cursor")).toBe("97");
|
|
127
|
+
expect(urls[1].searchParams.get("access_token")).toBe("b");
|
|
128
|
+
|
|
129
|
+
events.dispatchEvent(new Event("open"));
|
|
130
|
+
receive({ event: "reset", data: {} });
|
|
131
|
+
events.dispatchEvent(new CloseEvent("close"));
|
|
132
|
+
expect(urls[2].searchParams.get("cursor")).toBeNull();
|
|
133
|
+
|
|
134
|
+
events.dispatchEvent(new Event("open"));
|
|
135
|
+
for (const message of snapshot) {
|
|
136
|
+
receive(message);
|
|
137
|
+
}
|
|
138
|
+
session.id = "second";
|
|
139
|
+
events.dispatchEvent(new CloseEvent("close"));
|
|
140
|
+
expect(urls[3].searchParams.get("cursor")).toBeNull();
|
|
141
|
+
expect(urls[3].searchParams.get("session_id")).toBe("second");
|
|
142
|
+
lifetime.abort();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("exposes the confirmed document until the lifetime ends", async () => {
|
|
146
|
+
const { connect, events, client, store, healthy, receive } = setup();
|
|
147
|
+
const lifetime = new AbortController();
|
|
148
|
+
connect(lifetime.signal);
|
|
149
|
+
healthy.resolve();
|
|
150
|
+
await healthy.promise;
|
|
151
|
+
events.dispatchEvent(new Event("open"));
|
|
152
|
+
const snapshot = fixtureMessages("notebook-events.sse");
|
|
153
|
+
for (const message of snapshot.slice(0, -3)) {
|
|
154
|
+
receive(message);
|
|
155
|
+
}
|
|
156
|
+
expect(store.get(client.documentAtom)).toBeNull();
|
|
157
|
+
for (const message of snapshot.slice(-3)) {
|
|
158
|
+
receive(message);
|
|
159
|
+
}
|
|
160
|
+
expect(store.get(client.documentAtom)?.revision.order).toEqual([
|
|
161
|
+
"c1",
|
|
162
|
+
"c3",
|
|
163
|
+
"k7f2",
|
|
164
|
+
]);
|
|
165
|
+
events.dispatchEvent(new CloseEvent("close"));
|
|
166
|
+
expect(store.get(client.documentAtom)?.revision.order).toEqual([
|
|
167
|
+
"c1",
|
|
168
|
+
"c3",
|
|
169
|
+
"k7f2",
|
|
170
|
+
]);
|
|
171
|
+
lifetime.abort();
|
|
172
|
+
expect(store.get(client.documentAtom)).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("stops consuming a stream whose notebook changes", () => {
|
|
176
|
+
const { connect, events, client, store, transport } = setup();
|
|
177
|
+
const lifetime = new AbortController();
|
|
178
|
+
connect(lifetime.signal);
|
|
179
|
+
events.dispatchEvent(new Event("open"));
|
|
180
|
+
for (const id of ["n1", "n2"]) {
|
|
181
|
+
events.dispatchEvent(
|
|
182
|
+
new MessageEvent("message", { data: notebookMessage(id) }),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
expect(store.get(client.connectionAtom)).toEqual({
|
|
186
|
+
state: ConnectionState.CLOSED,
|
|
187
|
+
code: "KERNEL_DISCONNECTED",
|
|
188
|
+
reason: "Failed to decode notebook message",
|
|
189
|
+
});
|
|
190
|
+
expect(transport.close).toHaveBeenCalledOnce();
|
|
191
|
+
lifetime.abort();
|
|
192
|
+
});
|
|
193
|
+
|
|
82
194
|
it.each(["MARIMO_UNAUTHORIZED", "MARIMO_TRANSPORT_EXHAUSTED"])(
|
|
83
195
|
"stops automatic recovery after %s but permits an explicit reconnect",
|
|
84
196
|
async (reason) => {
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { NotebookEvent } from "../events";
|
|
5
|
+
import type { Resource } from "../resources/resource";
|
|
6
|
+
import { createStream, type Stream, type StreamState } from "../stream";
|
|
7
|
+
import { fixtureEvents } from "./fixtures";
|
|
8
|
+
|
|
9
|
+
interface CellIds {
|
|
10
|
+
readonly ids: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A resource whose events are complete on their own. */
|
|
14
|
+
const cells: Resource<CellIds> = {
|
|
15
|
+
initial: { ids: [] },
|
|
16
|
+
on: {
|
|
17
|
+
cell: (state, event) =>
|
|
18
|
+
state.ids.includes(event.data.cell.id)
|
|
19
|
+
? state
|
|
20
|
+
: { ids: [...state.ids, event.data.cell.id] },
|
|
21
|
+
"cell.removed": (state, event) =>
|
|
22
|
+
state.ids.includes(event.data.id)
|
|
23
|
+
? { ids: state.ids.filter((id) => id !== event.data.id) }
|
|
24
|
+
: state,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
interface Revisions {
|
|
29
|
+
/** Cells changed since the last revision. */
|
|
30
|
+
readonly pending: readonly string[];
|
|
31
|
+
readonly published: readonly (readonly string[])[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function publish(state: Revisions): Revisions {
|
|
35
|
+
return { pending: [], published: [...state.published, state.pending] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A resource assembled from groups of events, like a document from its cells. */
|
|
39
|
+
const revisions: Resource<Revisions> = {
|
|
40
|
+
initial: { pending: [], published: [] },
|
|
41
|
+
on: {
|
|
42
|
+
cell: (state, event) => ({
|
|
43
|
+
...state,
|
|
44
|
+
pending: [...state.pending, event.data.cell.id],
|
|
45
|
+
}),
|
|
46
|
+
"cell.removed": (state, event) => ({
|
|
47
|
+
...state,
|
|
48
|
+
pending: [...state.pending, event.data.id],
|
|
49
|
+
}),
|
|
50
|
+
// A live revision completes a group. A snapshot's revision waits for ready.
|
|
51
|
+
document: (state, _event, { phase }) =>
|
|
52
|
+
phase === "live" ? publish(state) : state,
|
|
53
|
+
ready: publish,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
interface Resources {
|
|
58
|
+
cells: CellIds;
|
|
59
|
+
revisions: Revisions;
|
|
60
|
+
}
|
|
61
|
+
type State = StreamState<Resources>;
|
|
62
|
+
|
|
63
|
+
function replay(
|
|
64
|
+
stream: Stream<Resources>,
|
|
65
|
+
events: readonly NotebookEvent[],
|
|
66
|
+
from: State = stream.initial,
|
|
67
|
+
): State {
|
|
68
|
+
return events.reduce((state, event) => stream.reduce(state, event), from);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const snapshot = fixtureEvents("notebook-events.sse");
|
|
72
|
+
const ready = snapshot.findIndex((event) => event.event === "ready");
|
|
73
|
+
const cellEvent = snapshot.find((event) => event.event === "cell");
|
|
74
|
+
if (cellEvent?.event !== "cell") {
|
|
75
|
+
throw new Error("Fixture is missing a cell event");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("notebook stream", () => {
|
|
79
|
+
it("reads the snapshot, goes live at ready, and rebuilds after reset", () => {
|
|
80
|
+
const stream = createStream({ cells, revisions });
|
|
81
|
+
const partial = replay(stream, snapshot.slice(0, ready));
|
|
82
|
+
expect(partial).toMatchObject({
|
|
83
|
+
phase: "snapshot",
|
|
84
|
+
notebookId: "n1",
|
|
85
|
+
cursor: null,
|
|
86
|
+
});
|
|
87
|
+
expect(partial.resources.cells.ids).toEqual(["c1", "c3", "k7f2"]);
|
|
88
|
+
expect(partial.resources.revisions).toEqual({
|
|
89
|
+
pending: ["c1", "c3", "k7f2"],
|
|
90
|
+
published: [],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const live = replay(stream, snapshot.slice(ready), partial);
|
|
94
|
+
expect(live).toMatchObject({ phase: "live", cursor: "97" });
|
|
95
|
+
expect(live.resources.cells).toBe(partial.resources.cells);
|
|
96
|
+
expect(live.resources.revisions).toEqual({
|
|
97
|
+
pending: [],
|
|
98
|
+
published: [["c1", "c3", "k7f2"]],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const resetEvents = fixtureEvents("events-reset.sse");
|
|
102
|
+
const afterReset = stream.reduce(live, resetEvents[0]);
|
|
103
|
+
expect(afterReset).toEqual({ ...stream.initial, notebookId: "n1" });
|
|
104
|
+
expect(replay(stream, resetEvents, live).phase).toBe("live");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("keeps resource state by reference when an event does not apply", () => {
|
|
108
|
+
const stream = createStream({ cells, revisions });
|
|
109
|
+
const state = replay(stream, snapshot);
|
|
110
|
+
const runtime = snapshot.find((event) => event.event === "runtime");
|
|
111
|
+
const unhandled = stream.reduce(state, { ...runtime!, id: "100" });
|
|
112
|
+
expect(unhandled.resources).toBe(state.resources);
|
|
113
|
+
expect(unhandled.cursor).toBe("100");
|
|
114
|
+
expect(
|
|
115
|
+
stream.reduce(state, { event: "unknown", name: "x", id: "101" })
|
|
116
|
+
.resources,
|
|
117
|
+
).toBe(state.resources);
|
|
118
|
+
expect(
|
|
119
|
+
stream.reduce(state, { event: "marimo", data: { marimo: {} } }),
|
|
120
|
+
).toBe(state);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("clears resources when its notebook is removed and ignores other notebooks", () => {
|
|
124
|
+
const stream = createStream({ cells, revisions });
|
|
125
|
+
const state = replay(stream, snapshot);
|
|
126
|
+
const time = "2026-10-01T09:30:00Z";
|
|
127
|
+
expect(
|
|
128
|
+
stream.reduce(state, {
|
|
129
|
+
event: "notebook.removed",
|
|
130
|
+
id: "100",
|
|
131
|
+
data: { id: "n2", time },
|
|
132
|
+
}).resources,
|
|
133
|
+
).toBe(state.resources);
|
|
134
|
+
const removed = stream.reduce(state, {
|
|
135
|
+
event: "notebook.removed",
|
|
136
|
+
id: "100",
|
|
137
|
+
data: { id: "n1", time },
|
|
138
|
+
});
|
|
139
|
+
expect(removed).toMatchObject({ phase: "live", notebookId: "n1" });
|
|
140
|
+
expect(removed.resources).toBe(stream.initial.resources);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("rejects objects from another notebook", () => {
|
|
144
|
+
const stream = createStream({ cells, revisions });
|
|
145
|
+
const state = replay(stream, snapshot.slice(0, ready));
|
|
146
|
+
const notebook = snapshot[0];
|
|
147
|
+
if (notebook.event !== "notebook") {
|
|
148
|
+
throw new Error("Fixture does not start with its notebook");
|
|
149
|
+
}
|
|
150
|
+
expect(() =>
|
|
151
|
+
stream.reduce(state, {
|
|
152
|
+
...notebook,
|
|
153
|
+
data: { notebook: { ...notebook.data.notebook, id: "n2" } },
|
|
154
|
+
}),
|
|
155
|
+
).toThrow("changed identity");
|
|
156
|
+
expect(() =>
|
|
157
|
+
stream.reduce(state, {
|
|
158
|
+
...cellEvent,
|
|
159
|
+
data: { cell: { ...cellEvent.data.cell, notebook_id: "n2" } },
|
|
160
|
+
}),
|
|
161
|
+
).toThrow("changed identity");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("records each live event's cursor and resumes in place", () => {
|
|
165
|
+
const stream = createStream({ cells, revisions });
|
|
166
|
+
const partial = replay(stream, snapshot.slice(0, ready));
|
|
167
|
+
expect(stream.resume(partial)).toEqual({
|
|
168
|
+
...stream.initial,
|
|
169
|
+
notebookId: "n1",
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const live = replay(stream, snapshot.slice(ready), partial);
|
|
173
|
+
const [cell, , removed, published, saved] = fixtureEvents("edits.sse");
|
|
174
|
+
const editing = replay(stream, [cell], live);
|
|
175
|
+
expect(editing.cursor).toBe("110");
|
|
176
|
+
expect(editing.resources.revisions.pending).toEqual(["c2"]);
|
|
177
|
+
expect(stream.resume(editing)).toBe(editing);
|
|
178
|
+
|
|
179
|
+
const edited = replay(stream, [removed, published], editing);
|
|
180
|
+
expect(edited.cursor).toBe("113");
|
|
181
|
+
expect(edited.resources.revisions).toEqual({
|
|
182
|
+
pending: [],
|
|
183
|
+
published: [
|
|
184
|
+
["c1", "c3", "k7f2"],
|
|
185
|
+
["c2", "k7f2"],
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
expect(stream.reduce(edited, saved).cursor).toBe("114");
|
|
189
|
+
expect(
|
|
190
|
+
stream.reduce(edited, { event: "ready", id: "116", data: {} }).cursor,
|
|
191
|
+
).toBe("116");
|
|
192
|
+
expect(stream.reduce(edited, { event: "reset", data: {} }).cursor).toBe(
|
|
193
|
+
null,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("requires a cursor on live events other than reset and notifications", () => {
|
|
198
|
+
const stream = createStream({ cells, revisions });
|
|
199
|
+
const state = replay(stream, snapshot);
|
|
200
|
+
expect(() => stream.reduce(state, cellEvent)).toThrow("cursor");
|
|
201
|
+
expect(
|
|
202
|
+
stream.reduce(state, { ...cellEvent, id: "100" }).resources.cells,
|
|
203
|
+
).toBe(state.resources.cells);
|
|
204
|
+
expect(stream.reduce(state, { event: "reset", data: {} }).phase).toBe(
|
|
205
|
+
"snapshot",
|
|
206
|
+
);
|
|
207
|
+
expect(
|
|
208
|
+
stream.reduce(state, { event: "marimo", data: { marimo: null } }),
|
|
209
|
+
).toBe(state);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -11,11 +11,21 @@ import {
|
|
|
11
11
|
TransportReadyState,
|
|
12
12
|
} from "./connection";
|
|
13
13
|
import { parseNotebookEvent } from "./events";
|
|
14
|
+
import { type Document, document } from "./resources/document";
|
|
15
|
+
import { createStream } from "./stream";
|
|
14
16
|
|
|
15
17
|
/** A connection to a notebook on a marimo host. */
|
|
16
18
|
export interface NotebookClient {
|
|
17
19
|
/** Client connection status, independent of application session readiness. */
|
|
18
20
|
readonly connectionAtom: Atom<ConnectionStatus>;
|
|
21
|
+
/**
|
|
22
|
+
* The most recently confirmed document.
|
|
23
|
+
*
|
|
24
|
+
* Null until a snapshot is complete, after reset or removal, and on
|
|
25
|
+
* connections without host document events. A temporary disconnection
|
|
26
|
+
* retains the last document. Partial updates are not exposed.
|
|
27
|
+
*/
|
|
28
|
+
readonly documentAtom: Atom<Document | null>;
|
|
19
29
|
/** Attempt to restore a closed connection after checking host health. */
|
|
20
30
|
reconnect(): Promise<void>;
|
|
21
31
|
}
|
|
@@ -49,9 +59,16 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
49
59
|
const connectionAtom = atom<ConnectionStatus>({
|
|
50
60
|
state: ConnectionState.NOT_STARTED,
|
|
51
61
|
});
|
|
62
|
+
// One atom holds the whole stream, which resource atoms derive from.
|
|
63
|
+
const stream = createStream({ document });
|
|
64
|
+
const streamAtom = atom(stream.initial);
|
|
65
|
+
const documentAtom = atom(
|
|
66
|
+
(get) => get(streamAtom).resources.document.current,
|
|
67
|
+
);
|
|
52
68
|
let reconnect: (() => Promise<void>) | undefined;
|
|
53
69
|
const client: NotebookClient = {
|
|
54
70
|
connectionAtom,
|
|
71
|
+
documentAtom,
|
|
55
72
|
reconnect: async () => reconnect?.(),
|
|
56
73
|
};
|
|
57
74
|
|
|
@@ -73,7 +90,33 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
73
90
|
let shouldRetry = true;
|
|
74
91
|
let terminal = false;
|
|
75
92
|
let checkingHealth = false;
|
|
76
|
-
|
|
93
|
+
let streamKey: string | undefined;
|
|
94
|
+
const transport = createTransport(() => {
|
|
95
|
+
if (messageFormat === "session") {
|
|
96
|
+
return url();
|
|
97
|
+
}
|
|
98
|
+
const next = new URL(url());
|
|
99
|
+
next.searchParams.delete("cursor");
|
|
100
|
+
if (signal.aborted) {
|
|
101
|
+
return next.toString();
|
|
102
|
+
}
|
|
103
|
+
// A cursor is useful with the state that produced it, which a change
|
|
104
|
+
// of session or notebook invalidates. Authentication may change freely.
|
|
105
|
+
const identity = new URL(next);
|
|
106
|
+
identity.searchParams.delete("access_token");
|
|
107
|
+
identity.searchParams.sort();
|
|
108
|
+
const key = identity.toString();
|
|
109
|
+
const state =
|
|
110
|
+
key === streamKey
|
|
111
|
+
? stream.resume(store.get(streamAtom))
|
|
112
|
+
: stream.initial;
|
|
113
|
+
streamKey = key;
|
|
114
|
+
store.set(streamAtom, state);
|
|
115
|
+
if (state.cursor !== null) {
|
|
116
|
+
next.searchParams.set("cursor", state.cursor);
|
|
117
|
+
}
|
|
118
|
+
return next.toString();
|
|
119
|
+
});
|
|
77
120
|
const setStatus = (status: ConnectionStatus) =>
|
|
78
121
|
store.set(connectionAtom, status);
|
|
79
122
|
const disconnected = {
|
|
@@ -130,12 +173,14 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
130
173
|
let event;
|
|
131
174
|
try {
|
|
132
175
|
event = parseNotebookEvent(decodeJson(message.data));
|
|
176
|
+
store.set(streamAtom, stream.reduce(store.get(streamAtom), event));
|
|
133
177
|
} catch (error) {
|
|
134
178
|
Logger.error("Failed to decode notebook message", error);
|
|
135
179
|
if (messageFormat === "session") {
|
|
136
180
|
onMessageError(error);
|
|
137
181
|
return;
|
|
138
182
|
}
|
|
183
|
+
store.set(streamAtom, stream.initial);
|
|
139
184
|
terminal = true;
|
|
140
185
|
setStatus({
|
|
141
186
|
...disconnected,
|
|
@@ -193,6 +238,7 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
193
238
|
transport.removeEventListener("message", onMessage);
|
|
194
239
|
transport.close();
|
|
195
240
|
reconnect = undefined;
|
|
241
|
+
store.set(streamAtom, stream.initial);
|
|
196
242
|
setStatus({ state: ConnectionState.NOT_STARTED });
|
|
197
243
|
},
|
|
198
244
|
{ once: true },
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type * as Protocol from "../protocol";
|
|
4
|
+
import type { Resource } from "./resource";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The notebook's content at one revision.
|
|
8
|
+
*
|
|
9
|
+
* The revision record carries the cell order, app config, etags, and
|
|
10
|
+
* version. Each cell in its order has a record in `cells`.
|
|
11
|
+
*/
|
|
12
|
+
export interface Document {
|
|
13
|
+
readonly revision: Protocol.Document;
|
|
14
|
+
readonly cells: ReadonlyMap<Protocol.CellId, Protocol.Cell>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The records of a revision still being received. */
|
|
18
|
+
interface PendingDocument {
|
|
19
|
+
/** Null until the `document` event that closes the group arrives. */
|
|
20
|
+
readonly revision: Protocol.Document | null;
|
|
21
|
+
readonly cells: ReadonlyMap<Protocol.CellId, Protocol.Cell>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The confirmed document and the revision being assembled after it.
|
|
26
|
+
*
|
|
27
|
+
* A snapshot's records are pending until `ready`. A live revision is pending
|
|
28
|
+
* from its first cell until its `document` event.
|
|
29
|
+
*/
|
|
30
|
+
export interface DocumentState {
|
|
31
|
+
readonly current: Document | null;
|
|
32
|
+
readonly pending: PendingDocument | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pendingOf(state: DocumentState): PendingDocument {
|
|
36
|
+
return (
|
|
37
|
+
state.pending ?? {
|
|
38
|
+
revision: state.current?.revision ?? null,
|
|
39
|
+
cells: state.current?.cells ?? new Map(),
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Confirm a revision whose records are complete. */
|
|
45
|
+
function publish(
|
|
46
|
+
revision: Protocol.Document,
|
|
47
|
+
cells: ReadonlyMap<Protocol.CellId, Protocol.Cell>,
|
|
48
|
+
): DocumentState {
|
|
49
|
+
const order = new Set(revision.order);
|
|
50
|
+
if (
|
|
51
|
+
order.size !== revision.order.length ||
|
|
52
|
+
order.size !== cells.size ||
|
|
53
|
+
[...order].some((id) => !cells.has(id)) ||
|
|
54
|
+
[...cells.values()].some((cell) => cell.version > revision.version)
|
|
55
|
+
) {
|
|
56
|
+
throw new Error("Document records do not match its revision and order");
|
|
57
|
+
}
|
|
58
|
+
return { current: { revision, cells }, pending: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const document: Resource<DocumentState> = {
|
|
62
|
+
initial: { current: null, pending: null },
|
|
63
|
+
on: {
|
|
64
|
+
cell: (state, { data }) => {
|
|
65
|
+
const pending = pendingOf(state);
|
|
66
|
+
return {
|
|
67
|
+
...state,
|
|
68
|
+
pending: {
|
|
69
|
+
revision: pending.revision,
|
|
70
|
+
cells: new Map(pending.cells).set(data.cell.id, data.cell),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
"cell.removed": (state, { data }) => {
|
|
75
|
+
const pending = pendingOf(state);
|
|
76
|
+
if (!pending.cells.has(data.id)) {
|
|
77
|
+
return state;
|
|
78
|
+
}
|
|
79
|
+
const cells = new Map(pending.cells);
|
|
80
|
+
cells.delete(data.id);
|
|
81
|
+
return { ...state, pending: { ...pending, cells } };
|
|
82
|
+
},
|
|
83
|
+
document: (state, { data }, { phase }) => {
|
|
84
|
+
const { cells } = pendingOf(state);
|
|
85
|
+
return phase === "snapshot"
|
|
86
|
+
? { ...state, pending: { revision: data.document, cells } }
|
|
87
|
+
: publish(data.document, cells);
|
|
88
|
+
},
|
|
89
|
+
ready: (state, _event, { phase }) => {
|
|
90
|
+
if (state.pending === null) {
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
if (phase === "live") {
|
|
94
|
+
throw new Error("Stream ended with an incomplete document update");
|
|
95
|
+
}
|
|
96
|
+
return state.pending.revision === null
|
|
97
|
+
? { current: null, pending: null }
|
|
98
|
+
: publish(state.pending.revision, state.pending.cells);
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type * as Protocol from "../protocol";
|
|
4
|
+
|
|
5
|
+
/** An event name from the protocol. */
|
|
6
|
+
export type EventName = Protocol.EventOutput["event"];
|
|
7
|
+
|
|
8
|
+
/** The decoded event with the given name. */
|
|
9
|
+
export type EventOf<Name extends EventName> = Extract<
|
|
10
|
+
Protocol.EventOutput,
|
|
11
|
+
{ event: Name }
|
|
12
|
+
>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Events the stream handles itself.
|
|
16
|
+
*
|
|
17
|
+
* They identify the notebook, discard state, or carry a native
|
|
18
|
+
* notification. Resources do not receive them.
|
|
19
|
+
*/
|
|
20
|
+
export type StreamEventName =
|
|
21
|
+
| "reset"
|
|
22
|
+
| "notebook"
|
|
23
|
+
| "notebook.removed"
|
|
24
|
+
| "marimo";
|
|
25
|
+
|
|
26
|
+
/** Events a resource may handle. `ready` marks the end of the snapshot. */
|
|
27
|
+
export type ResourceEventName = Exclude<EventName, StreamEventName>;
|
|
28
|
+
|
|
29
|
+
/** `snapshot` until `ready` arrives and `live` after it. */
|
|
30
|
+
export type StreamPhase = "snapshot" | "live";
|
|
31
|
+
|
|
32
|
+
/** What a handler may know about the stream beyond the event. */
|
|
33
|
+
export interface HandlerContext {
|
|
34
|
+
readonly phase: StreamPhase;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A pure handler for each event the resource reads.
|
|
39
|
+
*
|
|
40
|
+
* A handler returns `state` when the event does not apply to it.
|
|
41
|
+
*/
|
|
42
|
+
export type EventHandlers<State> = {
|
|
43
|
+
readonly [Name in ResourceEventName]?: (
|
|
44
|
+
state: State,
|
|
45
|
+
event: EventOf<Name>,
|
|
46
|
+
context: HandlerContext,
|
|
47
|
+
) => State;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One kind of object assembled from its events.
|
|
52
|
+
*
|
|
53
|
+
* The stream returns a resource to `initial` on `reset` and when the
|
|
54
|
+
* notebook is removed.
|
|
55
|
+
*/
|
|
56
|
+
export interface Resource<State> {
|
|
57
|
+
readonly initial: State;
|
|
58
|
+
readonly on: EventHandlers<State>;
|
|
59
|
+
}
|