@marimo-team/islands 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/{ErrorBoundary-CBHB755J.js → ErrorBoundary-DAPIRYqd.js} +1 -1
- package/dist/{code-visibility-CEXXuuOE.js → code-visibility-DkCqn-PO.js} +1 -1
- package/dist/{common-OLE92qsR.js → common-DsUPhkD6.js} +1 -1
- package/dist/{data-grid-overlay-editor-CpHX98zN.js → data-grid-overlay-editor-D7UuVsgB.js} +1 -1
- package/dist/{feedback-button-_f7fx7zj.js → feedback-button-wnAMXOGA.js} +2 -2
- package/dist/{glide-data-editor-Cmu4vWKD.js → glide-data-editor-KvS90MGh.js} +3 -3
- package/dist/{globals-1Hfhz6Dx.js → globals-yruoELTW.js} +1 -1
- package/dist/main.js +6 -6
- package/dist/{number-overlay-editor-BQN3-Nye.js → number-overlay-editor-R2i0FdxV.js} +1 -1
- package/dist/{reveal-component-nqUW7zgn.js → reveal-component-C42-YBfQ.js} +2 -2
- 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
|
@@ -135,7 +135,7 @@ function F(e) {
|
|
|
135
135
|
}
|
|
136
136
|
//#endregion
|
|
137
137
|
//#region src/components/editor/boundary/ErrorBoundary.tsx
|
|
138
|
-
var I = (0, T.lazy)(() => import("./feedback-button-
|
|
138
|
+
var I = (0, T.lazy)(() => import("./feedback-button-wnAMXOGA.js").then((e) => ({ default: e.FeedbackModal }))), L = (e) => {
|
|
139
139
|
let t = (0, w.c)(2), n;
|
|
140
140
|
return t[0] === e.children ? n = t[1] : (n = /* @__PURE__ */ (0, j.jsx)(O, {
|
|
141
141
|
FallbackComponent: R,
|
|
@@ -18,7 +18,7 @@ function x() {
|
|
|
18
18
|
return e.warn("Failed to get version from mount config"), null;
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
-
var S = x() || "0.25.3-
|
|
21
|
+
var S = x() || "0.25.3-dev20", C = u(S), w = u(!0);
|
|
22
22
|
u(null);
|
|
23
23
|
//#endregion
|
|
24
24
|
//#region src/core/wasm/state.ts
|
|
@@ -12,7 +12,7 @@ import { t as dt } from "./check-EDwSwP89.js";
|
|
|
12
12
|
import { A as ft, C as pt, M as mt, O as ht, S as gt, _ as _t, b as vt, c as yt, d as bt, g as xt, h as St, i as Ct, l as wt, m as Tt, n as Et, p as Dt, s as Ot, t as kt, u as At, v as jt, y as Mt } from "./select-BButFi-d.js";
|
|
13
13
|
import { $ as Nt, $t as Pt, A as Ft, At as It, B as Lt, Bt as Rt, Ct as zt, D as Bt, Dt as Vt, E as Ht, Et as Ut, F as Wt, Ft as Gt, G as Kt, Gt as qt, H as Jt, Ht as Yt, I as Xt, It as Zt, J as Qt, Jt as $t, Kt as en, L as tn, Lt as nn, M as rn, Mt as an, N as on, Nt as sn, O as cn, Ot as ln, P as un, Pt as dn, Q as fn, Qt as pn, R as mn, Rt as hn, S as gn, St as _n, T as vn, Tt as yn, U as bn, Ut as xn, V as Sn, Vt as Cn, W as wn, Wt as Tn, X as En, Xt as Dn, Y as On, Yt as kn, Z as An, Zt as jn, _ as Mn, _n as Nn, _t as Pn, a as Fn, at as In, b as Ln, bt as Rn, cn as zn, ct as Bn, d as Vn, dn as Hn, dt as Un, et as Wn, f as Gn, fn as Kn, ft as qn, gn as Jn, gt as Yn, h as Xn, hn as Zn, ht as Qn, i as $n, in as er, it as tr, j as nr, jt as rr, k as ir, kt as ar, l as or, lt as sr, mn as cr, mt as lr, n as ur, nn as dr, nt as fr, o as pr, on as mr, ot as hr, pn as gr, pt as _r, qt as vr, rn as yr, rt as br, s as xr, sn as Sr, st as Cr, t as wr, tn as Tr, tt as Er, u as Dr, un as Or, vn as kr, vt as Ar, w as jr, wt as Mr, x as Nr, xt as Pr, y as Fr, yt as Ir, z as Lr, zt as Rr } from "./dropdown-menu-CP_qCYHC.js";
|
|
14
14
|
import { a as zr, c as Br, d as Vr, i as Hr, l as Ur, n as Wr, o as Gr, r as Kr, t as qr, v as Jr, x as Yr, y as Xr } from "./toDate-UtiwmnH2.js";
|
|
15
|
-
import { n as Zr, t as Qr } from "./ErrorBoundary-
|
|
15
|
+
import { n as Zr, t as Qr } from "./ErrorBoundary-DAPIRYqd.js";
|
|
16
16
|
import { t as $r } from "./clsx-CoeGtWvn.js";
|
|
17
17
|
import { t as ei } from "./react-dom-DUmH0GXv.js";
|
|
18
18
|
import { t as ti } from "./jsx-runtime-3BD1kT0s.js";
|
|
@@ -2,7 +2,7 @@ import { a as e } from "./rolldown-runtime-Cb76FRpK.js";
|
|
|
2
2
|
import { t } from "./react-sHtZdjeP.js";
|
|
3
3
|
import { t as n } from "./react-dom-DUmH0GXv.js";
|
|
4
4
|
import { c as r, l as i, u as a } from "./data-grid-types-BH_r7g7l.js";
|
|
5
|
-
import { a as o, i as s, n as c, r as l } from "./glide-data-editor-
|
|
5
|
+
import { a as o, i as s, n as c, r as l } from "./glide-data-editor-KvS90MGh.js";
|
|
6
6
|
//#region ../node_modules/.pnpm/@glideapps+glide-data-grid@6.0.4-alpha24_lodash@4.17.21_marked@15.0.12_react-dom@19.3.0_aca11a353ce3b112922852200b9c160f/node_modules/@glideapps/glide-data-grid/dist/esm/internal/data-grid-overlay-editor/data-grid-overlay-editor-style.js
|
|
7
7
|
var u = /* @__PURE__ */ e(t(), 1), d = /* @__PURE__ */ e(n(), 1), f = /*#__PURE__*/ o("div")({
|
|
8
8
|
name: "DataGridOverlayEditorStyle",
|
|
@@ -5,12 +5,12 @@ import { t as i } from "./compiler-runtime-CldRdSsv.js";
|
|
|
5
5
|
import { t as a } from "./copy-icon-Ft7t9bqz.js";
|
|
6
6
|
import { G as o, Lt as s, Ot as c, V as l, fn as u, h as d, i as f, n as p, q as m, zt as h } from "./error-entries-Bv6bZPF3.js";
|
|
7
7
|
import { b as g } from "./select-BButFi-d.js";
|
|
8
|
-
import { n as ee, t as te } from "./globals-
|
|
8
|
+
import { n as ee, t as te } from "./globals-yruoELTW.js";
|
|
9
9
|
import { t as _ } from "./jsx-runtime-3BD1kT0s.js";
|
|
10
10
|
import { C as v, _ as ne, i as y } from "./useTheme-CRnjkku0.js";
|
|
11
11
|
import { a as re, i as ie, n as ae, r as oe } from "./dialog-CEBFZNyc.js";
|
|
12
12
|
import { t as se } from "./useAsyncData-Deh5kszT.js";
|
|
13
|
-
import { t as ce } from "./code-visibility-
|
|
13
|
+
import { t as ce } from "./code-visibility-DkCqn-PO.js";
|
|
14
14
|
//#region src/components/ui/skeleton.tsx
|
|
15
15
|
var b = /* @__PURE__ */ e(r(), 1), x = i(), S = _(), le = (e) => {
|
|
16
16
|
let n = (0, x.c)(8), r, i;
|
|
@@ -6,7 +6,7 @@ import { i as s, n as c, r as l } from "./copy-icon-Ft7t9bqz.js";
|
|
|
6
6
|
import { C as u, c as d, i as f, n as p, s as m, t as h, x as g } from "./select-BButFi-d.js";
|
|
7
7
|
import { d as _, h as v, i as y, l as b, n as x, o as S, s as C, t as w, u as T } from "./dropdown-menu-CP_qCYHC.js";
|
|
8
8
|
import { c as E, d as D, l as O, n as k, o as A, s as ee, t as j, u as te } from "./glide-utils-DJyqh0EM.js";
|
|
9
|
-
import { n as ne, t as re } from "./ErrorBoundary-
|
|
9
|
+
import { n as ne, t as re } from "./ErrorBoundary-DAPIRYqd.js";
|
|
10
10
|
import { t as M } from "./react-dom-DUmH0GXv.js";
|
|
11
11
|
import { t as N } from "./jsx-runtime-3BD1kT0s.js";
|
|
12
12
|
import { t as ie } from "./label-BDKF3Afc.js";
|
|
@@ -7062,7 +7062,7 @@ function rs(e, t, n, r, i) {
|
|
|
7062
7062
|
}
|
|
7063
7063
|
//#endregion
|
|
7064
7064
|
//#region ../node_modules/.pnpm/@glideapps+glide-data-grid@6.0.4-alpha24_lodash@4.17.21_marked@15.0.12_react-dom@19.3.0_aca11a353ce3b112922852200b9c160f/node_modules/@glideapps/glide-data-grid/dist/esm/data-editor/data-editor.js
|
|
7065
|
-
var is = /* @__PURE__ */ e(_r(), 1), as = /* @__PURE__ */ e(xr(), 1), os = K.lazy(async () => await import("./data-grid-overlay-editor-
|
|
7065
|
+
var is = /* @__PURE__ */ e(_r(), 1), as = /* @__PURE__ */ e(xr(), 1), os = K.lazy(async () => await import("./data-grid-overlay-editor-D7UuVsgB.js")), ss = 0;
|
|
7066
7066
|
function cs(e) {
|
|
7067
7067
|
return (0, is.default)((0, as.default)((0, as.default)(e).filter((e) => e.span !== void 0).map((e) => (0, Za.default)((e.span?.[0] ?? 0) + 1, (e.span?.[1] ?? 0) + 1))));
|
|
7068
7068
|
}
|
|
@@ -9293,7 +9293,7 @@ function Vs(e, t, n, r, i) {
|
|
|
9293
9293
|
}
|
|
9294
9294
|
//#endregion
|
|
9295
9295
|
//#region ../node_modules/.pnpm/@glideapps+glide-data-grid@6.0.4-alpha24_lodash@4.17.21_marked@15.0.12_react-dom@19.3.0_aca11a353ce3b112922852200b9c160f/node_modules/@glideapps/glide-data-grid/dist/esm/cells/number-cell.js
|
|
9296
|
-
var Hs = K.lazy(async () => await import("./number-overlay-editor-
|
|
9296
|
+
var Hs = K.lazy(async () => await import("./number-overlay-editor-R2i0FdxV.js")), Us = {
|
|
9297
9297
|
getAccessibilityString: (e) => e.data?.toString() ?? "",
|
|
9298
9298
|
kind: W.Number,
|
|
9299
9299
|
needsHover: (e) => e.hoverEffect === !0,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { h as e } from "./tooltip-BO1REbqs.js";
|
|
2
2
|
import { _ as t } from "./useTheme-CRnjkku0.js";
|
|
3
|
-
import { r as n } from "./code-visibility-
|
|
3
|
+
import { r as n } from "./code-visibility-DkCqn-PO.js";
|
|
4
4
|
var r = e("triangle-alert", [
|
|
5
5
|
["path", {
|
|
6
6
|
d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3",
|
package/dist/main.js
CHANGED
|
@@ -7,7 +7,7 @@ import { C as w, F as T, G as E, I as D, J as O, K as k, L as A, P as j, R as M,
|
|
|
7
7
|
import { a as oe, d as se, f as ce, h as V, i as le, n as ue, o as de, r as fe, t as pe } from "./tooltip-BO1REbqs.js";
|
|
8
8
|
import { $ as me, At as he, B as ge, Bt as _e, D as ve, Dt as ye, E as be, Ht as xe, It as Se, J as Ce, Jt as we, Kt as Te, M as Ee, Mt as De, N as Oe, Nt as ke, O as Ae, Ot as je, Q as Me, R as Ne, S as Pe, Tt as Fe, V as Ie, Vt as Le, W as Re, Wt as ze, X as Be, Xt as Ve, Y as He, Yt as Ue, _ as We, _n as Ge, _t as Ke, an as qe, b as Je, cn as Ye, ct as Xe, dn as Ze, dt as Qe, en as $e, f as et, ft as tt, g as nt, gn as rt, gt as it, h as at, it as ot, j as st, jt as ct, k as lt, kt as ut, ln as dt, m as ft, mn as pt, nn as mt, o as ht, p as gt, pn as _t, pt as vt, qt as yt, rn as bt, rt as xt, s as St, tn as Ct, u as wt, un as Tt, v as Et, xt as Dt, y as Ot, z as kt } from "./error-entries-Bv6bZPF3.js";
|
|
9
9
|
import { D as At, E as jt, O as Mt, S as Nt, T as Pt, _ as Ft, a as It, c as Lt, d as Rt, f as zt, g as Bt, h as Vt, i as Ht, l as Ut, m as Wt, n as Gt, o as Kt, p as qt, r as Jt, s as Yt, t as Xt, u as Zt, v as Qt, w as $t, x as en, y as tn } from "./spec-BwarBpUJ.js";
|
|
10
|
-
import { $t as nn, A as rn, At as an, B as on, Bt as sn, C as cn, Ct as ln, D as un, Dt as dn, E as fn, Et as pn, F as mn, Ft as hn, G as gn, Gt as _n, H as vn, Ht as yn, I as bn, It as xn, J as Sn, Jt as Cn, K as wn, Kt as Tn, L as En, Lt as Dn, M as On, Mt as kn, N as An, Nt as jn, O as Mn, Ot as Nn, P as Pn, Pt as Fn, Qt as In, R as Ln, Rt as Rn, S as zn, St as Bn, T as Vn, Tt as Hn, U as Un, Ut as Wn, V as Gn, Vt as Kn, W as qn, Wt as Jn, X as Yn, Xt as Xn, Y as Zn, Yt as Qn, Zt as $n, _ as er, _t as tr, an as nr, at as rr, b as ir, c as ar, cn as or, ct as sr, d as cr, dn as lr, dt as ur, en as dr, et as fr, f as pr, fn as mr, ft as hr, g as gr, gt as _r, h as vr, hn as yr, ht as br, in as xr, it as Sr, j as Cr, jt as wr, k as Tr, kt as Er, ln as Dr, lt as Or, m as kr, mn as Ar, mt as jr, nn as Mr, nt as Nr, ot as Pr, p as Fr, pn as Ir, pt as Lr, q as Rr, qt as zr, r as Br, rn as Vr, rt as Hr, sn as Ur, st as Wr, t as Gr, tn as Kr, tt as qr, u as Jr, un as Yr, ut as Xr, v as Zr, vt as Qr, w as $r, wt as ei, x as ti, y as ni, yt as ri, z as ii, zt as ai } from "./common-
|
|
10
|
+
import { $t as nn, A as rn, At as an, B as on, Bt as sn, C as cn, Ct as ln, D as un, Dt as dn, E as fn, Et as pn, F as mn, Ft as hn, G as gn, Gt as _n, H as vn, Ht as yn, I as bn, It as xn, J as Sn, Jt as Cn, K as wn, Kt as Tn, L as En, Lt as Dn, M as On, Mt as kn, N as An, Nt as jn, O as Mn, Ot as Nn, P as Pn, Pt as Fn, Qt as In, R as Ln, Rt as Rn, S as zn, St as Bn, T as Vn, Tt as Hn, U as Un, Ut as Wn, V as Gn, Vt as Kn, W as qn, Wt as Jn, X as Yn, Xt as Xn, Y as Zn, Yt as Qn, Zt as $n, _ as er, _t as tr, an as nr, at as rr, b as ir, c as ar, cn as or, ct as sr, d as cr, dn as lr, dt as ur, en as dr, et as fr, f as pr, fn as mr, ft as hr, g as gr, gt as _r, h as vr, hn as yr, ht as br, in as xr, it as Sr, j as Cr, jt as wr, k as Tr, kt as Er, ln as Dr, lt as Or, m as kr, mn as Ar, mt as jr, nn as Mr, nt as Nr, ot as Pr, p as Fr, pn as Ir, pt as Lr, q as Rr, qt as zr, r as Br, rn as Vr, rt as Hr, sn as Ur, st as Wr, t as Gr, tn as Kr, tt as qr, u as Jr, un as Yr, ut as Xr, v as Zr, vt as Qr, w as $r, wt as ei, x as ti, y as ni, yt as ri, z as ii, zt as ai } from "./common-DsUPhkD6.js";
|
|
11
11
|
import { c as oi, i as si, n as ci, o as li, t as ui } from "./useLifecycle-j7D9uD1o.js";
|
|
12
12
|
import { t as di } from "./check-EDwSwP89.js";
|
|
13
13
|
import { A as fi, C as pi, D as mi, E as hi, M as gi, O as _i, S as vi, T as yi, a as bi, b as xi, c as Si, i as Ci, k as wi, l as Ti, n as Ei, o as Di, r as Oi, s as ki, t as Ai, w as ji, x as Mi } from "./select-BButFi-d.js";
|
|
@@ -15,8 +15,8 @@ import { $ as Ni, C as Pi, Et as Fi, J as Ii, K as Li, R as Ri, S as zi, U as Bi
|
|
|
15
15
|
import { C as ua, _ as da, a as fa, b as pa, c as ma, f as ha, h as ga, m as _a, p as va, s as ya, u as ba, x as xa, y as Sa } from "./toDate-UtiwmnH2.js";
|
|
16
16
|
import { a as Ca, c as wa, d as Ta, f as Ea, i as Da, l as Oa, n as ka, o as Aa, r as ja, s as Ma, t as Na } from "./process-output-BZg56CCb.js";
|
|
17
17
|
import { a as Pa, c as Fa, d as Ia, i as La, l as Ra, r as za, u as Ba } from "./glide-utils-DJyqh0EM.js";
|
|
18
|
-
import { n as Va, t as Ha } from "./ErrorBoundary-
|
|
19
|
-
import { n as Ua, t as Wa } from "./globals-
|
|
18
|
+
import { n as Va, t as Ha } from "./ErrorBoundary-DAPIRYqd.js";
|
|
19
|
+
import { n as Ua, t as Wa } from "./globals-yruoELTW.js";
|
|
20
20
|
import { t as Ga } from "./react-dom-DUmH0GXv.js";
|
|
21
21
|
import { t as Ka } from "./jsx-runtime-3BD1kT0s.js";
|
|
22
22
|
import { B as qa, C as H, Ct as Ja, D as Ya, E as Xa, F as U, G as Za, H as Qa, I as $a, L as eo, M as W, N as to, O as no, P as ro, Q as io, R as ao, S as G, St as oo, Tt as so, U as co, W as lo, Y as uo, Z as fo, _ as po, _t as mo, a as ho, b as go, bt as _o, c as vo, d as yo, f as bo, g as xo, h as So, ht as Co, i as wo, j as K, k as To, l as Eo, m as Do, n as Oo, o as ko, p as Ao, r as jo, s as Mo, u as No, ut as Po, v as Fo, vt as Io, w as Lo, wt as Ro, x as zo, xt as Bo, y as Vo, yt as Ho } from "./use-restore-focus-BEM7sFWl.js";
|
|
@@ -39,7 +39,7 @@ import { t as Ms } from "./useAsyncData-Deh5kszT.js";
|
|
|
39
39
|
import { i as Ns, n as Ps, r as Fs } from "./formats-27CW_UP4.js";
|
|
40
40
|
import { M as Is, N as Ls } from "./vega-loader.browser-DNST6uVf.js";
|
|
41
41
|
import { a as Rs, n as zs } from "./loader-BQOfjTIJ.js";
|
|
42
|
-
import { c as Bs, d as Vs, i as Hs, l as Us, o as Ws, s as Gs, t as Ks, u as qs } from "./code-visibility-
|
|
42
|
+
import { c as Bs, d as Vs, i as Hs, l as Us, o as Ws, s as Gs, t as Ks, u as qs } from "./code-visibility-DkCqn-PO.js";
|
|
43
43
|
import { t as Js } from "./useDeepCompareMemoize-DXwzRINI.js";
|
|
44
44
|
import { t as Ys } from "./esm-D2gTsXEV.js";
|
|
45
45
|
//#region ../node_modules/.pnpm/iconify-icon@2.3.0/node_modules/iconify-icon/dist/iconify-icon.mjs
|
|
@@ -10533,7 +10533,7 @@ function VC(e, t) {
|
|
|
10533
10533
|
}
|
|
10534
10534
|
//#endregion
|
|
10535
10535
|
//#region src/plugins/impl/DataEditorPlugin.tsx
|
|
10536
|
-
var HC = J.lazy(() => import("./glide-data-editor-
|
|
10536
|
+
var HC = J.lazy(() => import("./glide-data-editor-KvS90MGh.js").then((e) => e.t)), UC = wS("marimo-data-editor", { cssStyles: [qS] }).withData(W({
|
|
10537
10537
|
initialValue: W({ edits: G(W({
|
|
10538
10538
|
rowIdx: K(),
|
|
10539
10539
|
columnId: U(),
|
|
@@ -30107,7 +30107,7 @@ function OR(e) {
|
|
|
30107
30107
|
}
|
|
30108
30108
|
//#endregion
|
|
30109
30109
|
//#region src/components/editor/renderers/slides-layout/slides-layout.tsx
|
|
30110
|
-
var kR = J.lazy(() => import("./reveal-component-
|
|
30110
|
+
var kR = J.lazy(() => import("./reveal-component-C42-YBfQ.js")), AR = (e) => {
|
|
30111
30111
|
let t = (0, X.c)(33), { layout: n, setLayout: r, cells: i, mode: a } = e, o = ns(da), s = a === "read" || o, c = ns(We) > 1, [l, u] = (0, J.useState)(null), d;
|
|
30112
30112
|
t[0] !== i || t[1] !== n ? (d = EP(i, n), t[0] = i, t[1] = n, t[2] = d) : d = t[2];
|
|
30113
30113
|
let { slideCells: f, skippedIds: p, noOutputIds: m, slideTypes: h, startCellIndex: g } = d, _;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { a as e } from "./rolldown-runtime-Cb76FRpK.js";
|
|
2
2
|
import { t } from "./react-sHtZdjeP.js";
|
|
3
|
-
import { a as n } from "./glide-data-editor-
|
|
3
|
+
import { a as n } from "./glide-data-editor-KvS90MGh.js";
|
|
4
4
|
//#region ../node_modules/.pnpm/@glideapps+glide-data-grid@6.0.4-alpha24_lodash@4.17.21_marked@15.0.12_react-dom@19.3.0_aca11a353ce3b112922852200b9c160f/node_modules/@glideapps/glide-data-grid/dist/esm/internal/data-grid-overlay-editor/private/number-overlay-editor-style.js
|
|
5
5
|
var r = /* @__PURE__ */ e(t()), i = /*#__PURE__*/ n("div")({
|
|
6
6
|
name: "NumberOverlayEditorStyle",
|
|
@@ -4,14 +4,14 @@ import { t as o } from "./react-sHtZdjeP.js";
|
|
|
4
4
|
import { t as s } from "./compiler-runtime-CldRdSsv.js";
|
|
5
5
|
import { h as c, t as l } from "./tooltip-BO1REbqs.js";
|
|
6
6
|
import { O as u } from "./error-entries-Bv6bZPF3.js";
|
|
7
|
-
import { $ as d, Q as f, Z as p, an as m, i as h, l as g, ln as _, n as v, on as y, r as b, t as x, u as S } from "./common-
|
|
7
|
+
import { $ as d, Q as f, Z as p, an as m, i as h, l as g, ln as _, n as v, on as y, r as b, t as x, u as S } from "./common-DsUPhkD6.js";
|
|
8
8
|
import { M as C } from "./select-BButFi-d.js";
|
|
9
9
|
import { q as ee } from "./dropdown-menu-CP_qCYHC.js";
|
|
10
10
|
import { _ as w } from "./toDate-UtiwmnH2.js";
|
|
11
11
|
import { t as T } from "./jsx-runtime-3BD1kT0s.js";
|
|
12
12
|
import { q as E } from "./use-restore-focus-BEM7sFWl.js";
|
|
13
13
|
import { A as D, C as O, b as k } from "./useTheme-CRnjkku0.js";
|
|
14
|
-
import { a as A, n as te, t as ne } from "./code-visibility-
|
|
14
|
+
import { a as A, n as te, t as ne } from "./code-visibility-DkCqn-PO.js";
|
|
15
15
|
/* empty css */
|
|
16
16
|
var j = c("sticky-note", [["path", {
|
|
17
17
|
d: "M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z",
|
package/package.json
CHANGED
package/src/core/host/AGENTS.md
CHANGED
|
@@ -4,3 +4,8 @@ Production code may import host modules, `jotai/vanilla`, `zod`, and
|
|
|
4
4
|
`@/utils/Logger`. Enforced by `marimo/host-imports`. Tests are exempt.
|
|
5
5
|
|
|
6
6
|
Define interfaces here and inject application behavior through adapters.
|
|
7
|
+
|
|
8
|
+
`stream.ts` reduces stream events into one state. Each kind of object is a
|
|
9
|
+
`Resource` in `resources/` with pure handlers keyed by event name. The stream
|
|
10
|
+
handles phase, notebook identity, and control events itself. The client holds
|
|
11
|
+
one state atom and exposes derived atoms.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { NotebookEvent } from "../events";
|
|
5
|
+
import { document } from "../resources/document";
|
|
6
|
+
import { createStream } from "../stream";
|
|
7
|
+
import { fixtureEvents } from "./fixtures";
|
|
8
|
+
|
|
9
|
+
const stream = createStream({ document });
|
|
10
|
+
const snapshot = fixtureEvents("notebook-events.sse");
|
|
11
|
+
const edits = fixtureEvents("edits.sse");
|
|
12
|
+
|
|
13
|
+
function replay(events: readonly NotebookEvent[], from = stream.initial) {
|
|
14
|
+
return events.reduce((state, event) => stream.reduce(state, event), from);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const confirmed = () => replay(snapshot);
|
|
18
|
+
const current = (state: ReturnType<typeof replay>) =>
|
|
19
|
+
state.resources.document.current;
|
|
20
|
+
|
|
21
|
+
describe("document resource", () => {
|
|
22
|
+
it("publishes the snapshot at ready, then each completed edit and save", () => {
|
|
23
|
+
const ready = snapshot.findIndex((event) => event.event === "ready");
|
|
24
|
+
const opening = replay(snapshot.slice(0, ready));
|
|
25
|
+
expect(current(opening)).toBeNull();
|
|
26
|
+
const first = current(replay(snapshot.slice(ready), opening));
|
|
27
|
+
expect(first?.revision.order).toEqual(["c1", "c3", "k7f2"]);
|
|
28
|
+
expect([...first!.cells.keys()]).toEqual(first?.revision.order);
|
|
29
|
+
|
|
30
|
+
const [edit, save] = edits.filter((event) => event.event === "document");
|
|
31
|
+
const pending = replay(
|
|
32
|
+
edits.filter((event) => event.event !== "document"),
|
|
33
|
+
confirmed(),
|
|
34
|
+
);
|
|
35
|
+
expect(current(pending)).toEqual(first);
|
|
36
|
+
const changed = stream.reduce(pending, edit);
|
|
37
|
+
expect(current(changed)?.revision).toEqual(edit.data.document);
|
|
38
|
+
expect([...current(changed)!.cells.keys()].toSorted()).toEqual(
|
|
39
|
+
edit.data.document.order.toSorted(),
|
|
40
|
+
);
|
|
41
|
+
const saved = current(stream.reduce(changed, save));
|
|
42
|
+
expect(saved?.revision).toEqual(save.data.document);
|
|
43
|
+
expect(saved?.cells.get("c3")?.code).toBe("x = 22");
|
|
44
|
+
expect(saved?.cells.has("k7f2")).toBe(false);
|
|
45
|
+
expect(saved?.revision.saved_etag).toBe(saved?.revision.etag);
|
|
46
|
+
expect(first?.cells.get("c3")?.code).toBe("x = 21");
|
|
47
|
+
expect(first?.cells.has("k7f2")).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("applies order-only changes and distinguishes an empty notebook from no document", () => {
|
|
51
|
+
const state = confirmed();
|
|
52
|
+
const revision = current(state)!.revision;
|
|
53
|
+
const reordered = {
|
|
54
|
+
...revision,
|
|
55
|
+
order: revision.order.toReversed(),
|
|
56
|
+
version: revision.version + 1,
|
|
57
|
+
};
|
|
58
|
+
const changed = stream.reduce(state, {
|
|
59
|
+
event: "document",
|
|
60
|
+
data: { document: reordered },
|
|
61
|
+
id: "200",
|
|
62
|
+
});
|
|
63
|
+
expect(current(changed)).toEqual({
|
|
64
|
+
revision: reordered,
|
|
65
|
+
cells: current(state)?.cells,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const empty = { ...revision, order: [] };
|
|
69
|
+
const opening = stream.reduce(stream.initial, {
|
|
70
|
+
event: "document",
|
|
71
|
+
data: { document: empty },
|
|
72
|
+
});
|
|
73
|
+
expect(current(opening)).toBeNull();
|
|
74
|
+
expect(
|
|
75
|
+
current(stream.reduce(opening, { event: "ready", data: {}, id: "0" })),
|
|
76
|
+
).toEqual({ revision: empty, cells: new Map() });
|
|
77
|
+
expect(
|
|
78
|
+
current(
|
|
79
|
+
stream.reduce(stream.initial, { event: "ready", data: {}, id: "0" }),
|
|
80
|
+
),
|
|
81
|
+
).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keeps the document while a live update is pending and after a replay completes it", () => {
|
|
85
|
+
const state = confirmed();
|
|
86
|
+
const [cell, , removed, published] = edits;
|
|
87
|
+
if (published.event !== "document") {
|
|
88
|
+
throw new Error("Fixture does not publish a document");
|
|
89
|
+
}
|
|
90
|
+
const partial = replay([cell, removed], state);
|
|
91
|
+
expect(current(partial)).toBe(current(state));
|
|
92
|
+
expect(stream.resume(partial)).toBe(partial);
|
|
93
|
+
const completed = replay(
|
|
94
|
+
[published, { event: "ready", data: {}, id: "113" }],
|
|
95
|
+
partial,
|
|
96
|
+
);
|
|
97
|
+
expect(current(completed)?.revision).toEqual(published.data.document);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("removes a notebook without waiting for individual cell removals", () => {
|
|
101
|
+
const removed = stream.reduce(confirmed(), {
|
|
102
|
+
event: "notebook.removed",
|
|
103
|
+
data: { id: "n1", time: "2026-10-01T09:30:00Z" },
|
|
104
|
+
id: "200",
|
|
105
|
+
});
|
|
106
|
+
expect(current(removed)).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("rejects records that cannot form a complete revision", () => {
|
|
110
|
+
const state = confirmed();
|
|
111
|
+
const revision = current(state)!.revision;
|
|
112
|
+
expect(() =>
|
|
113
|
+
stream.reduce(state, {
|
|
114
|
+
event: "document",
|
|
115
|
+
data: { document: { ...revision, order: ["missing"] } },
|
|
116
|
+
id: "200",
|
|
117
|
+
}),
|
|
118
|
+
).toThrow("Document records do not match");
|
|
119
|
+
const partial = stream.reduce(state, edits[0]);
|
|
120
|
+
expect(() =>
|
|
121
|
+
stream.reduce(partial, { event: "ready", data: {}, id: "200" }),
|
|
122
|
+
).toThrow("incomplete document update");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import NodeFs from "node:fs";
|
|
4
|
+
import NodeUrl from "node:url";
|
|
5
|
+
import { parseNotebookEvent, type NotebookEvent } from "../events";
|
|
6
|
+
|
|
7
|
+
/** Read the protocol's portable examples as transport messages. */
|
|
8
|
+
export function fixtureMessages(name: string) {
|
|
9
|
+
const path = new NodeUrl.URL(
|
|
10
|
+
`../../../../../protocols/host/fixtures/${name}`,
|
|
11
|
+
import.meta.url,
|
|
12
|
+
);
|
|
13
|
+
return NodeFs.readFileSync(path, "utf8")
|
|
14
|
+
.split(/\r?\n\r?\n/)
|
|
15
|
+
.flatMap((frame) => {
|
|
16
|
+
const event = /^event: (.+)$/m.exec(frame)?.[1];
|
|
17
|
+
if (!event) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const data = frame
|
|
21
|
+
.split(/\r?\n/)
|
|
22
|
+
.filter((line) => line.startsWith("data:"))
|
|
23
|
+
.map((line) => line.slice(5).trimStart())
|
|
24
|
+
.join("\n");
|
|
25
|
+
const payload: unknown = JSON.parse(data);
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
event,
|
|
29
|
+
id: /^id: (.+)$/m.exec(frame)?.[1],
|
|
30
|
+
data: payload,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Read the protocol's portable examples as decoded notebook events. */
|
|
37
|
+
export function fixtureEvents(name: string): NotebookEvent[] {
|
|
38
|
+
return fixtureMessages(name).map(parseNotebookEvent);
|
|
39
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type { NotebookEvent } from "./events";
|
|
4
|
+
import type * as Protocol from "./protocol";
|
|
5
|
+
import type {
|
|
6
|
+
EventOf,
|
|
7
|
+
HandlerContext,
|
|
8
|
+
Resource,
|
|
9
|
+
ResourceEventName,
|
|
10
|
+
StreamPhase,
|
|
11
|
+
} from "./resources/resource";
|
|
12
|
+
|
|
13
|
+
/** A resource for each entry of a state map under the same name. */
|
|
14
|
+
export type Resources<States> = {
|
|
15
|
+
readonly [Name in keyof States]: Resource<States[Name]>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The state of one notebook stream.
|
|
20
|
+
*
|
|
21
|
+
* Holds the phase, the notebook identity, the resume cursor, and the state
|
|
22
|
+
* of each resource.
|
|
23
|
+
*/
|
|
24
|
+
export interface StreamState<States> {
|
|
25
|
+
readonly phase: StreamPhase;
|
|
26
|
+
/**
|
|
27
|
+
* The notebook the stream describes.
|
|
28
|
+
*
|
|
29
|
+
* Retained across `reset`.
|
|
30
|
+
*/
|
|
31
|
+
readonly notebookId: Protocol.NotebookIdOutput | null;
|
|
32
|
+
/**
|
|
33
|
+
* The id of the last live event.
|
|
34
|
+
*
|
|
35
|
+
* Null before `ready` and after `reset`.
|
|
36
|
+
*/
|
|
37
|
+
readonly cursor: string | null;
|
|
38
|
+
/** The state of each resource by name. */
|
|
39
|
+
readonly resources: States;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A reducer over the events of one notebook stream. */
|
|
43
|
+
export interface Stream<States> {
|
|
44
|
+
readonly initial: StreamState<States>;
|
|
45
|
+
/**
|
|
46
|
+
* Apply one event.
|
|
47
|
+
*
|
|
48
|
+
* Throws on a live event without a cursor or on an object from another
|
|
49
|
+
* notebook.
|
|
50
|
+
*/
|
|
51
|
+
reduce(state: StreamState<States>, event: NotebookEvent): StreamState<States>;
|
|
52
|
+
/**
|
|
53
|
+
* The state to hold while reconnecting.
|
|
54
|
+
*
|
|
55
|
+
* A live stream keeps its state and resumes from `cursor`. An incomplete
|
|
56
|
+
* snapshot starts over.
|
|
57
|
+
*/
|
|
58
|
+
resume(state: StreamState<States>): StreamState<States>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Events that may arrive without a cursor after `ready`. */
|
|
62
|
+
const UNCURSORED = new Set<NotebookEvent["event"]>([
|
|
63
|
+
"reset",
|
|
64
|
+
"marimo",
|
|
65
|
+
"unknown",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The notebook the event's object belongs to.
|
|
70
|
+
*
|
|
71
|
+
* Undefined for objects that do not name one.
|
|
72
|
+
*/
|
|
73
|
+
function notebookIdOf(
|
|
74
|
+
event: EventOf<ResourceEventName>,
|
|
75
|
+
): Protocol.NotebookIdOutput | undefined {
|
|
76
|
+
switch (event.event) {
|
|
77
|
+
case "document":
|
|
78
|
+
return event.data.document.notebook_id;
|
|
79
|
+
case "cell":
|
|
80
|
+
return event.data.cell.notebook_id;
|
|
81
|
+
case "analysis":
|
|
82
|
+
return event.data.analysis.notebook_id;
|
|
83
|
+
case "runtime":
|
|
84
|
+
return event.data.runtime.notebook_id;
|
|
85
|
+
case "attachment":
|
|
86
|
+
return event.data.attachment.notebook_id;
|
|
87
|
+
case "run":
|
|
88
|
+
return event.data.run.notebook_id;
|
|
89
|
+
default:
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Compose resources into one stream. */
|
|
95
|
+
export function createStream<States extends object>(
|
|
96
|
+
resources: Resources<States>,
|
|
97
|
+
): Stream<States> {
|
|
98
|
+
const names = Object.keys(resources) as (keyof States & string)[];
|
|
99
|
+
const initialResources = Object.fromEntries(
|
|
100
|
+
names.map((name) => [name, resources[name].initial]),
|
|
101
|
+
) as States;
|
|
102
|
+
const initial: StreamState<States> = {
|
|
103
|
+
phase: "snapshot",
|
|
104
|
+
notebookId: null,
|
|
105
|
+
cursor: null,
|
|
106
|
+
resources: initialResources,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function identify(
|
|
110
|
+
state: StreamState<States>,
|
|
111
|
+
notebookId: Protocol.NotebookIdOutput,
|
|
112
|
+
): StreamState<States> {
|
|
113
|
+
if (state.notebookId === notebookId) {
|
|
114
|
+
return state;
|
|
115
|
+
}
|
|
116
|
+
if (state.notebookId !== null) {
|
|
117
|
+
throw new Error("Notebook stream changed identity");
|
|
118
|
+
}
|
|
119
|
+
return { ...state, notebookId };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function clear(state: StreamState<States>): StreamState<States> {
|
|
123
|
+
return state.resources === initialResources
|
|
124
|
+
? state
|
|
125
|
+
: { ...state, resources: initialResources };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function fanOut(
|
|
129
|
+
state: StreamState<States>,
|
|
130
|
+
event: EventOf<ResourceEventName>,
|
|
131
|
+
): StreamState<States> {
|
|
132
|
+
const context: HandlerContext = { phase: state.phase };
|
|
133
|
+
let next = state.resources;
|
|
134
|
+
for (const name of names) {
|
|
135
|
+
type State = States[typeof name];
|
|
136
|
+
// Handlers are keyed by the event name they accept.
|
|
137
|
+
const handler = resources[name].on[event.event] as
|
|
138
|
+
| ((
|
|
139
|
+
state: State,
|
|
140
|
+
event: Protocol.EventOutput,
|
|
141
|
+
context: HandlerContext,
|
|
142
|
+
) => State)
|
|
143
|
+
| undefined;
|
|
144
|
+
if (handler === undefined) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const before = next[name];
|
|
148
|
+
const after = handler(before, event, context);
|
|
149
|
+
if (after !== before) {
|
|
150
|
+
next = { ...next, [name]: after };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return next === state.resources ? state : { ...state, resources: next };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Record the position of a live event. */
|
|
157
|
+
function advance(
|
|
158
|
+
state: StreamState<States>,
|
|
159
|
+
id: string | undefined,
|
|
160
|
+
): StreamState<States> {
|
|
161
|
+
return state.phase === "live" && id !== undefined && id !== state.cursor
|
|
162
|
+
? { ...state, cursor: id }
|
|
163
|
+
: state;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
initial,
|
|
168
|
+
reduce(state, event) {
|
|
169
|
+
if (
|
|
170
|
+
state.phase === "live" &&
|
|
171
|
+
event.id === undefined &&
|
|
172
|
+
!UNCURSORED.has(event.event)
|
|
173
|
+
) {
|
|
174
|
+
throw new Error("Live notebook event is missing its cursor");
|
|
175
|
+
}
|
|
176
|
+
switch (event.event) {
|
|
177
|
+
case "marimo":
|
|
178
|
+
case "unknown":
|
|
179
|
+
return advance(state, event.id);
|
|
180
|
+
case "reset":
|
|
181
|
+
return { ...initial, notebookId: state.notebookId };
|
|
182
|
+
case "ready":
|
|
183
|
+
// Handlers see the phase that ended, so a snapshot's ready differs
|
|
184
|
+
// from one that ends a replay.
|
|
185
|
+
return { ...fanOut(state, event), phase: "live", cursor: event.id };
|
|
186
|
+
case "notebook":
|
|
187
|
+
return advance(identify(state, event.data.notebook.id), event.id);
|
|
188
|
+
case "notebook.removed":
|
|
189
|
+
return advance(
|
|
190
|
+
state.notebookId !== null && event.data.id !== state.notebookId
|
|
191
|
+
? state
|
|
192
|
+
: clear(state),
|
|
193
|
+
event.id,
|
|
194
|
+
);
|
|
195
|
+
default: {
|
|
196
|
+
const notebookId = notebookIdOf(event);
|
|
197
|
+
return advance(
|
|
198
|
+
fanOut(
|
|
199
|
+
notebookId === undefined ? state : identify(state, notebookId),
|
|
200
|
+
event,
|
|
201
|
+
),
|
|
202
|
+
event.id,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
resume(state) {
|
|
208
|
+
return state.phase === "snapshot"
|
|
209
|
+
? { ...initial, notebookId: state.notebookId }
|
|
210
|
+
: state;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|