Skip to content

Commit

A doc opens at once: the page carries the document's saved state, so the editor mounts without waiting for its live room, which connects in the background and sends only what changed, with a quiet dot that turns green when it's synced; edits made before then are kept and sent once. The editor's code is preloaded from the page and fetched early when you hover a doc, the app's shared code ships in a few chunks instead of a hundred small ones, socket tickets come with the page instead of a round trip, and the artifacts service answers a page, a sidebar and a room in parallel lookups instead of a dozen in a row, reporting its database round trips in Server-Timing. The artifacts guide says how a doc opens, and CONTRIBUTING's Speed section has the numbers.

syntaqxcommitted Parent8ac43dbBrowse files
29 files+785−1070/29 viewed
+24−0
109109 signed-in pages under 400 ms, streamed panels within a second.
110110 - Workers run without Smart Placement; `scripts/perf/placement-probe.mjs`
111111 measures a placement before you pin one.
112+- A TypeScript service reports its database time too (`db;dur`, from
113+ `openD1` in `@g1t/contracts`): the round trips a method makes are what
114+ it costs, so start them together (`Promise.all`), keep what never
115+ changes across requests in the isolate (the artifacts service keeps a
116+ workspace's slug for 30 s and whether its General space exists), and
117+ don't wake a Durable Object for a read D1 can answer.
118+- Nothing live gates a page. A page carries what it shows (a doc's text
119+ and its saved document), opens its sockets as it hydrates rather than
120+ from inside the code they feed, keeps what is typed until they answer,
121+ and says so with a dot, never a spinner. A page opened with an access
122+ token gets its socket tickets with the page (the root loader mints the
123+ feed's, an artifact's loader its room's) rather than asking for each.
124+- Code that only the browser runs (the doc editor) is still fetched with
125+ the page: `vite.config.ts` notes the chunk and what it imports for the
126+ route's `links`, and a link to such a page warms the chunk on hover.
127+ Modules several routes share are grouped by who shares them
128+ (`codeSplitting.groups`), so a page fetches tens of files, not a
129+ hundred and more. Before this, opening a doc fetched 124 files
130+ (1,049 KB) in three waves and the editor appeared at 2.8–3.3 s; a doc
131+ now fetches 71 files (1,026 KB) in one wave, and against the deployed
132+ services the editor appears at 2.1 s, of which the room's answer is
133+ 0.65 s and the editor's own start 0.4 s. The room's part (the saved
134+ document in the page, one call to the room, the context in one round)
135+ is measured after deploy.
112136
113137 ## Rate limits
114138
+18−0
190190 drops, the header says **Offline, changes will sync** and sends your changes
191191 when you're back.
192192
193+### How a doc opens
194+
195+A doc's text is in the page itself, so it reads from the first moment, before
196+any script runs. The page then connects to the doc's live room and loads the
197+editor together, and the editor opens on the document the page carried: you
198+can read and type at once, and the room only sends what changed since the
199+doc was last saved. Anything you type before the room answers is kept and
200+sent then, so nothing is lost. The dot in the header says where things stand:
201+
202+| Dot | Meaning |
203+| --- | --- |
204+| Faint, pulsing | Connecting to the live room. You can already read and edit. |
205+| Green | Live: everyone's changes show as they happen. |
206+| **Offline, changes will sync** | The connection dropped; your changes are kept and sent when it's back. |
207+
208+A doc that was never saved since this started, or one far larger than usual,
209+waits for the room's copy before the editor opens, as every doc once did.
210+
193211 ### Comments
194212
195213 Select text and choose **Comment** for a passage, or write in **Discussion**
+5−4
3333 return hydrated;
3434 }
3535
36−export default function DocBody({ slug, page, folio, role, showComments, onPresence, onStatus, onFolio, onRole, onError }: FolioBodyProps) {
36+export default function DocBody({ slug, page, folio, live, role, showComments, onPresence, onFolio, onRole, onError }: FolioBodyProps) {
3737 const layout = useFoliosData();
3838 const root = useRouteLoaderData("root");
3939 const usercontent = usercontentFrom(root);
8787 },
8888 [navigate, slug, revalidator, onFolio, onRole],
8989 );
90+ // The room's notices (a rename, a suggestion, a change of access), from the page's connection.
91+ useEffect(() => live?.onEvent(onEvent), [live, onEvent]);
9092
9193 const decide = async (id: string, decision: "accept" | "reject") => {
9294 setSuggestions((was) => was.filter((s) => s.id !== id));
259261
260262 {/* The doc itself: live once the editor loads; its saved Markdown until then. */}
261263 <div className="mt-6">
262− {hydrated && !trashed ? (
264+ {hydrated && live && !trashed ? (
263265 <Suspense fallback={<ReadView markdown={page.text} />}>
264266 <DocEditor
265267 key={folio.id}
266268 slug={slug}
267269 folioId={folio.id}
270+ provider={live}
268271 role={role}
269272 me={layout?.me ?? { key: "", name: "", display_name: "", avatar: null }}
270273 mentionables={layout?.mentionables ?? []}
272275 suggestions={suggestions}
273276 showComments={showComments}
274277 onPresence={onPresence}
275− onStatus={onStatus}
276− onEvent={onEvent}
277278 onPageThreads={setThreads}
278279 renderSuggestion={(s) => <SuggestionCard suggestion={s} canDecide={editable} onDecide={decide} />}
279280 projects={[]}
+19−27
1313 import { syntaxHighlighter } from "@blocknote/code-block";
1414 import { BlockNoteViewEditor, SuggestionMenuController, ThreadsSidebar, getDefaultReactSlashMenuItems, useCreateBlockNote } from "@blocknote/react";
1515 import { BlockNoteView } from "@blocknote/shadcn";
16−import type { DocFile, DocRole, FolioSearchHit, FolioSuggestion, FoliosLiveEvent, Result } from "@g1t/contracts";
16+import type { DocFile, DocRole, FolioSearchHit, FolioSuggestion, Result } from "@g1t/contracts";
1717 import { AlertTriangle, AtSign, Calendar, CheckCircle2, FileCode2, FileText, GitPullRequest, Info, Link2, Sigma, Workflow } from "lucide-react";
1818 import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1919
2323 import { CiteDialog } from "./code";
2424 import { EditorSkeleton } from "./editor-skeleton";
2525 import type { PageThread } from "./page-parts";
26−import { FolioProvider, type LiveStatus } from "../provider";
26+import type { FolioProvider } from "../provider";
2727 import type { Presence } from "../shell";
2828 import { whoAre } from "../who";
2929
3333 export type DocEditorProps = {
3434 slug: string;
3535 folioId: string;
36+ /** The folio's live document, opened by the page as it hydrated (routes/workspace/folios/folio.tsx). */
37+ provider: FolioProvider;
3638 role: DocRole;
3739 me: { key: string; name: string; display_name: string; avatar: string | null };
3840 mentionables: DocMentionable[];
4042 suggestions: FolioSuggestion[];
4143 showComments: boolean;
4244 onPresence?: (people: Presence[]) => void;
43− onStatus?: (status: LiveStatus) => void;
44− onEvent?: (event: FoliosLiveEvent) => void;
4545 /** The suggestion cards' own UI, drawn by the page beside the blocks each one changes. */
4646 renderSuggestion?: (suggestion: FolioSuggestion) => ReactNode;
4747 /** Comments on the whole doc (not on a passage), live from the document. */
8282 }
8383
8484 export default function DocEditor(props: DocEditorProps) {
85− const { slug, folioId } = props;
86− const [provider, setProvider] = useState<FolioProvider | null>(null);
87− const [synced, setSynced] = useState(false);
88−
85+ const { provider } = props;
86+ // A document the page carried is here already: the editor opens on it
87+ // now, and the room's answer (what changed since the save) merges in
88+ // as it arrives. Without one, the editor waits for the room's copy, so
89+ // it never starts from an empty document of its own; offline, after a
90+ // moment, from what we have.
91+ const [ready, setReady] = useState(provider.seeded || provider.status === "synced");
8992 useEffect(() => {
90− const scheme = window.location.protocol === "https:" ? "wss" : "ws";
91− const live = new FolioProvider(`${scheme}://${window.location.host}/${slug}/-/artifacts/live?folio=${encodeURIComponent(folioId)}`);
92− setProvider(live);
93− setSynced(false);
94− // Mount the editor once the document is here, so it never starts from
95− // an empty doc of its own; offline, after a moment, from what we have.
96− const fallback = setTimeout(() => setSynced(true), 4000);
97− const off = live.onStatus((status) => {
98− props.onStatus?.(status);
99− if (status === "synced") setSynced(true);
93+ if (provider.seeded) return;
94+ const fallback = setTimeout(() => setReady(true), 4000);
95+ const off = provider.onStatus((status) => {
96+ if (status === "synced") setReady(true);
10097 });
101− const offEvent = live.onEvent((event) => props.onEvent?.(event));
10298 return () => {
10399 clearTimeout(fallback);
104100 off();
105− offEvent();
106− live.destroy();
107101 };
108− // The callbacks are read through props each time; reconnect only for a new folio.
109− // eslint-disable-next-line react-hooks/exhaustive-deps
110− }, [slug, folioId]);
102+ }, [provider]);
111103
112− if (!provider || !synced) return <EditorSkeleton />;
113− return <LiveEditor {...props} provider={provider} />;
104+ if (!ready) return <EditorSkeleton />;
105+ return <LiveEditor {...props} />;
114106 }
115107
116−function LiveEditor({ slug, folioId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, projects, provider }: DocEditorProps & { provider: FolioProvider }) {
108+function LiveEditor({ slug, folioId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, projects, provider }: DocEditorProps) {
117109 const editable = canDo(role, "edit");
118110 // BlockNote's own parts follow the page's Appearance (lib/theme.ts).
119111 const theme = useDrawnTheme();
+32−5
99 import { FileText, LayoutDashboard, type LucideIcon, PenTool, Presentation } from "lucide-react";
1010 import { type ComponentType, type LazyExoticComponent, lazy } from "react";
1111
12−import type { LiveStatus } from "./provider";
12+import type { FolioProvider } from "./provider";
1313 import type { Presence } from "./shell";
1414
1515 /**
1616 * What a kind's page body is given: the folio's page and the folio as it
17− * is now, the viewer's role, and the header's comment toggle; it tells the
18− * header who is here, how the connection is, and what changed live.
17+ * is now, its live connection (opened by the page as it hydrates, before
18+ * the body's code arrives), the viewer's role, and the header's comment
19+ * toggle; it tells the header who is here and what changed live.
1920 */
2021 export type FolioBodyProps = {
2122 slug: string;
2223 page: FolioPage;
2324 folio: Folio;
25+ /** The folio's live document and presence; null until the page hydrates. */
26+ live: FolioProvider | null;
2427 role: DocRole;
2528 showComments: boolean;
2629 onPresence: (people: Presence[]) => void;
27− onStatus: (status: LiveStatus) => void;
2830 /** A rename or another change to the folio, from the room. */
2931 onFolio: (folio: Folio) => void;
3032 /** The viewer's role changed; null: their access ended. */
4547 Body: LazyExoticComponent<ComponentType<FolioBodyProps>> | null;
4648 };
4749
50+/**
51+ * Starts fetching the doc editor's code (BlockNote, Yjs, the blocks) in
52+ * the browser, so it is here by the time it is wanted: as a doc's page
53+ * hydrates, together with its body rather than after it, and when a
54+ * link to a doc is hovered or focused. Nothing is fetched twice.
55+ */
56+let editorWarmed = false;
57+export function warmDocEditor(): void {
58+ if (editorWarmed || import.meta.env.SSR) return;
59+ editorWarmed = true;
60+ void import("./doc/editor").catch(() => {
61+ editorWarmed = false;
62+ });
63+}
64+
4865 export const FOLIO_KIND_UI: Record<FolioKind, FolioKindUi> = {
49− doc: { label: FOLIO_KIND_LABELS.doc, icon: FileText, tone: "text-info bg-info/12", ready: true, mobileEditable: true, Body: lazy(() => import("./doc/body")) },
66+ doc: {
67+ label: FOLIO_KIND_LABELS.doc,
68+ icon: FileText,
69+ tone: "text-info bg-info/12",
70+ ready: true,
71+ mobileEditable: true,
72+ Body: lazy(() => {
73+ warmDocEditor();
74+ return import("./doc/body");
75+ }),
76+ },
5077 slides: { label: FOLIO_KIND_LABELS.slides, icon: Presentation, tone: "text-warn bg-warn/12", ready: false, mobileEditable: false, Body: null },
5178 design: { label: FOLIO_KIND_LABELS.design, icon: PenTool, tone: "text-merged bg-merged/12", ready: false, mobileEditable: false, Body: null },
5279 dashboard: { label: FOLIO_KIND_LABELS.dashboard, icon: LayoutDashboard, tone: "text-success bg-success/12", ready: false, beta: true, mobileEditable: false, Body: null },
+13−3
1414 import { Card } from "../ui/card";
1515 import { Hint } from "../ui/hint";
1616 import { useFoliosData } from "./actions";
17−import { FolioThumbnail, KindIcon } from "./kinds";
17+import { FolioThumbnail, KindIcon, warmDocEditor } from "./kinds";
18+
19+/**
20+ * On a link to a doc: as the link's own `prefetch="intent"` fetches the
21+ * page's code and data on hover or focus, this fetches the editor's, so
22+ * opening it is a navigation and nothing more.
23+ */
24+export function warmOnIntent(folio: Pick<Folio, "kind">): { onMouseEnter?: () => void; onFocus?: () => void; onTouchStart?: () => void } {
25+ if (folio.kind !== "doc") return {};
26+ return { onMouseEnter: warmDocEditor, onFocus: warmDocEditor, onTouchStart: warmDocEditor };
27+}
1828 import { FolioMenu } from "./menu";
1929 import { AccessMark } from "./parts";
2030
4151 <li className="group relative flex items-center gap-3 px-3 py-2 transition-colors hover:bg-raised/60 sm:px-4">
4252 <KindIcon kind={folio.kind} />
4353 <span className="flex min-w-0 grow items-center gap-2">
44− <Link to={folio.path} prefetch="intent" className="min-w-0 truncate text-sm text-fg after:absolute after:inset-0 hover:text-accent">
54+ <Link to={folio.path} prefetch="intent" {...warmOnIntent(folio)} className="min-w-0 truncate text-sm text-fg after:absolute after:inset-0 hover:text-accent">
4555 {folio.icon && <span className="mr-1.5">{folio.icon}</span>}
4656 {folio.title || "Untitled"}
4757 </Link>
96106 <div className="flex items-start gap-2.5 px-3.5 pt-3 pb-3">
97107 <KindIcon kind={folio.kind} size={14} box={24} />
98108 <span className="min-w-0 grow">
99− <Link to={folio.path} prefetch="intent" className="line-clamp-1 text-sm font-medium text-fg after:absolute after:inset-0 group-hover:text-accent">
109+ <Link to={folio.path} prefetch="intent" {...warmOnIntent(folio)} className="line-clamp-1 text-sm font-medium text-fg after:absolute after:inset-0 group-hover:text-accent">
100110 {folio.icon && <span className="mr-1">{folio.icon}</span>}
101111 {folio.title || "Untitled"}
102112 </Link>
+142−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import * as decoding from "lib0/decoding";
5+import * as encoding from "lib0/encoding";
6+import * as syncProtocol from "y-protocols/sync";
7+import * as Y from "yjs";
8+
9+import { FolioProvider, pageStateBytes } from "./provider.ts";
10+
11+// The provider is browser code; the little it needs of the browser, a page address and sockets, is faked here.
12+(globalThis as { location?: unknown }).location = { href: "https://g1t.test/acme/-/artifacts/x", protocol: "https:" };
13+
14+class FakeSocket {
15+ static readonly CONNECTING = 0;
16+ static readonly OPEN = 1;
17+ static readonly CLOSED = 3;
18+ static instances: FakeSocket[] = [];
19+ readyState = FakeSocket.CONNECTING;
20+ binaryType = "blob";
21+ sent: (Uint8Array | string)[] = [];
22+ onopen: (() => void) | null = null;
23+ onmessage: ((event: { data: unknown }) => void) | null = null;
24+ onclose: ((event: { code: number }) => void) | null = null;
25+ readonly url: string;
26+ constructor(url: string) {
27+ this.url = url;
28+ FakeSocket.instances.push(this);
29+ }
30+ send(data: Uint8Array | string) {
31+ this.sent.push(data);
32+ }
33+ close(code = 1000) {
34+ this.readyState = FakeSocket.CLOSED;
35+ this.onclose?.({ code });
36+ }
37+ /** The server's turn: a frame for the client. */
38+ receive(bytes: Uint8Array) {
39+ this.onmessage?.({ data: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) });
40+ }
41+}
42+(globalThis as { WebSocket?: unknown }).WebSocket = FakeSocket;
43+
44+const MESSAGE_SYNC = 0;
45+
46+/** A room as src/folios/room.ts behaves: its own step 1 on connect, and an answer to every sync message. */
47+function room(doc: Y.Doc, socket: FakeSocket) {
48+ const step1 = encoding.createEncoder();
49+ encoding.writeVarUint(step1, MESSAGE_SYNC);
50+ syncProtocol.writeSyncStep1(step1, doc);
51+ socket.receive(encoding.toUint8Array(step1));
52+ return {
53+ /** Reads what the client sent since, answering as the room would. */
54+ exchange() {
55+ for (const frame of socket.sent.splice(0)) {
56+ if (typeof frame === "string") continue;
57+ const decoder = decoding.createDecoder(frame);
58+ if (decoding.readVarUint(decoder) !== MESSAGE_SYNC) continue;
59+ const encoder = encoding.createEncoder();
60+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
61+ syncProtocol.readSyncMessage(decoder, encoder, doc, "room");
62+ if (encoding.length(encoder) > 1) socket.receive(encoding.toUint8Array(encoder));
63+ }
64+ },
65+ };
66+}
67+
68+function frames(socket: FakeSocket): number[] {
69+ return socket.sent.filter((f): f is Uint8Array => typeof f !== "string").map((f) => decoding.readVarUint(decoding.createDecoder(f)));
70+}
71+
72+test("a doc seeded from the page's state opens at once, and what is typed before the room answers reaches it, once", (t) => {
73+ FakeSocket.instances = [];
74+ // The room's document, as the page carried its saved state.
75+ const server = new Y.Doc();
76+ server.getText("t").insert(0, "hello");
77+ const carried = Y.encodeStateAsUpdate(server);
78+ // Someone else edited after the save: the room is ahead of the page.
79+ server.getText("t").insert(0, "Oh, ");
80+
81+ const provider = new FolioProvider("wss://g1t.test/acme/-/artifacts/live?folio=f1", { state: carried });
82+ t.after(() => provider.destroy());
83+ assert.equal(provider.seeded, true);
84+ assert.equal(provider.doc.getText("t").toString(), "hello", "the editor can open on the page's document before any socket");
85+ assert.equal(provider.status, "connecting");
86+ const socket = FakeSocket.instances[0]!;
87+ assert.equal(socket.url, "wss://g1t.test/acme/-/artifacts/live?folio=f1");
88+
89+ // Typed before the socket opened: kept in the document, nothing sent yet.
90+ provider.doc.getText("t").insert(5, " world");
91+ assert.deepEqual(frames(socket), [], "nothing goes down a socket that is not open");
92+
93+ socket.readyState = FakeSocket.OPEN;
94+ socket.onopen?.();
95+ assert.deepEqual(frames(socket), [MESSAGE_SYNC, 1, 3], "on open: our state vector, our presence, and a question about who is here");
96+ const theRoom = room(server, socket);
97+ theRoom.exchange();
98+ theRoom.exchange();
99+ assert.equal(provider.status, "synced");
100+ assert.equal(server.getText("t").toString(), "Oh, hello world", "the room got the early edit, and only once");
101+ assert.equal(provider.doc.getText("t").toString(), "Oh, hello world", "the page got what changed since the save");
102+
103+ // From here, an edit goes straight down the socket.
104+ provider.doc.getText("t").insert(0, "> ");
105+ assert.deepEqual(frames(socket), [MESSAGE_SYNC]);
106+ theRoom.exchange();
107+ assert.equal(server.getText("t").toString(), "> Oh, hello world");
108+ provider.destroy();
109+});
110+
111+test("without a saved state the document starts empty and is not seeded, and the room's copy fills it", (t) => {
112+ FakeSocket.instances = [];
113+ const server = new Y.Doc();
114+ server.getText("t").insert(0, "from the room");
115+ const provider = new FolioProvider("wss://g1t.test/acme/-/artifacts/live?folio=f2", { state: null });
116+ t.after(() => provider.destroy());
117+ assert.equal(provider.seeded, false);
118+ const socket = FakeSocket.instances[0]!;
119+ socket.readyState = FakeSocket.OPEN;
120+ socket.onopen?.();
121+ const theRoom = room(server, socket);
122+ theRoom.exchange();
123+ assert.equal(provider.status, "synced");
124+ assert.equal(provider.doc.getText("t").toString(), "from the room");
125+ provider.destroy();
126+});
127+
128+test("a state that is not a Yjs update is ignored, and the provider waits for the room as before", (t) => {
129+ FakeSocket.instances = [];
130+ const provider = new FolioProvider("wss://g1t.test/acme/-/artifacts/live?folio=f3", { state: new Uint8Array([1, 2, 3, 4, 5, 6, 7]) });
131+ t.after(() => provider.destroy());
132+ assert.equal(provider.seeded, false);
133+ provider.destroy();
134+});
135+
136+test("the page's base64 state becomes bytes, and anything else becomes nothing", (t) => {
137+ const bytes = new Uint8Array([0, 1, 2, 250, 251, 252]);
138+ assert.deepEqual(pageStateBytes(btoa(String.fromCharCode(...bytes))), bytes);
139+ assert.equal(pageStateBytes(null), null);
140+ assert.equal(pageStateBytes(""), null);
141+ assert.equal(pageStateBytes("not base64!"), null);
142+});
+37−7
99 *
1010 * It reconnects with backoff when the socket drops, and says where it is
1111 * (`status`) so the page can show "Offline, changes will sync".
12+ *
13+ * Given the document as the page's data carries it (the state the room
14+ * last saved), it starts from that: the editor opens on it at once, and
15+ * the room's answer to our state vector is only what changed since. Any
16+ * edit made before the room answers stays in the document and goes out
17+ * in that same exchange (sync step 2), so nothing typed early is lost.
1218 * Browser-only.
1319 */
1420 import type { FoliosLiveEvent } from "@g1t/contracts";
1824 import * as syncProtocol from "y-protocols/sync";
1925 import * as Y from "yjs";
2026
21−import { openLive } from "../../lib/live-socket";
22−import { heldOpen } from "../../lib/notify-store";
27+// With extensions, so provider.test.ts runs under Node as the other tests do.
28+import { openLive } from "../../lib/live-socket.ts";
29+import { heldOpen } from "../../lib/notify-store.ts";
2330
2431 const MESSAGE_SYNC = 0;
2532 const MESSAGE_AWARENESS = 1;
2734
2835 export type LiveStatus = "connecting" | "synced" | "offline" | "closed";
2936
37+/** The document a page carries (base64 of a Yjs update), as bytes; null when it carries none or it is not base64. */
38+export function pageStateBytes(state: string | null | undefined): Uint8Array | null {
39+ if (!state) return null;
40+ try {
41+ return Uint8Array.from(atob(state), (c) => c.charCodeAt(0));
42+ } catch {
43+ return null;
44+ }
45+}
46+
3047 export class FolioProvider {
3148 readonly doc: Y.Doc;
3249 readonly awareness: awarenessProtocol.Awareness;
3350 status: LiveStatus = "connecting";
51+ /** Whether the document started from the page's saved state (so the editor need not wait for the room). */
52+ readonly seeded: boolean;
3453 private socket: WebSocket | null = null;
3554 private attempts = 0;
3655 /** When the current connection opened; the backoff starts over only once one holds. */
4362 private readonly statusListeners = new Set<(status: LiveStatus) => void>();
4463 private readonly eventListeners = new Set<(event: FoliosLiveEvent) => void>();
4564
46− constructor(
47− private readonly url: string,
48− doc?: Y.Doc,
49− ) {
50− this.doc = doc ?? new Y.Doc();
65+ private readonly url: string;
66+
67+ constructor(url: string, options: { doc?: Y.Doc; state?: Uint8Array | null } = {}) {
68+ this.url = url;
69+ this.doc = options.doc ?? new Y.Doc();
70+ let seeded = false;
71+ if (options.state?.byteLength) {
72+ try {
73+ // No origin of our own: the seed is the room's work, not an edit to send it.
74+ Y.applyUpdate(this.doc, options.state, "seed");
75+ seeded = true;
76+ } catch (error) {
77+ console.error("artifacts: the saved document could not be read; waiting for the room", error);
78+ }
79+ }
80+ this.seeded = seeded;
5181 this.awareness = new awarenessProtocol.Awareness(this.doc);
5282 this.doc.on("update", this.onDocUpdate);
5383 this.awareness.on("update", this.onAwarenessUpdate);
+11−2
6868 <div className="min-w-0 grow">
6969 <Crumbs items={crumbs} />
7070 </div>
71− {status === "offline" && (
71+ {status === "offline" ? (
7272 <Hint label="Your changes are kept and sync when the connection is back.">
73− <span className="flex items-center gap-1 text-xs text-warn" tabIndex={0}>
73+ <span className="flex items-center gap-1 text-xs text-warn" tabIndex={0} data-live={status}>
7474 <WifiOff size={13} /> <span className="max-sm:hidden">Offline, changes will sync</span>
7575 </span>
7676 </Hint>
77+ ) : (
78+ // A quiet dot: green once the room is in step with this page, faint
79+ // until then. The page is readable and editable either way; what is
80+ // typed before the room answers goes to it then (provider.ts).
81+ <Hint label={status === "synced" ? "Live: everyone's changes show as they happen." : status === "closed" ? "No longer live." : "Connecting to the live document…"}>
82+ <span className="flex size-8 shrink-0 items-center justify-center" tabIndex={0} aria-label={status === "synced" ? "Live" : status === "closed" ? "Not live" : "Connecting"} data-live={status}>
83+ <span className={`size-2 rounded-full ${status === "synced" ? "bg-success" : status === "closed" ? "bg-line" : "bg-faint/50 animate-pulse"}`} aria-hidden="true" />
84+ </span>
85+ </Hint>
7786 )}
7887 {others.length > 0 && (
7988 <span className="flex items-center -space-x-1.5 max-sm:hidden" aria-label={`${others.length} others here`}>
+2−1
2020 import { foliosRequest, useFoliosAction, useFoliosData } from "./actions";
2121 import { RepoDocsDialog } from "./doc/code";
2222 import { FOLIO_KIND_UI, FolioGlyph, KindIcon } from "./kinds";
23+import { warmOnIntent } from "./list";
2324 import { SpaceIcon } from "./parts";
2425
2526 const ROW = "group flex h-8 items-center gap-1.5 rounded-md pr-1 text-[0.8125rem] transition-colors";
122123 >
123124 <ChevronRight size={13} className={`transition-transform ${expanded ? "rotate-90" : ""}`} />
124125 </button>
125− <NavLink to={`/${slug}/-/artifacts/${folioSlug(item.title, item.id)}`} prefetch="intent" className="flex min-w-0 grow items-center gap-1.5">
126+ <NavLink to={`/${slug}/-/artifacts/${folioSlug(item.title, item.id)}`} prefetch="intent" {...warmOnIntent(item)} className="flex min-w-0 grow items-center gap-1.5">
126127 <span className="flex w-4 shrink-0 justify-center">
127128 <FolioGlyph folio={item} size={14} />
128129 </span>
+28−0
1717 viaToken = on;
1818 }
1919
20+/** A ticket a page's loader minted with the page, as routes/notify/ticket.ts would have. */
21+export type OfferedTicket = { ticket: string; expires_at: string };
22+
23+const offered = new Map<string, OfferedTicket>();
24+
25+/**
26+ * A ticket minted on the server with the page (the root loader's for the
27+ * feed, an artifact's for its room), so the first socket opens without
28+ * first asking `/-/live/ticket`: one round trip less before anything is
29+ * live. Each is used once, and never past its minute.
30+ */
31+export function offerTicket(path: string, ticket: OfferedTicket | null | undefined): void {
32+ if (ticket) offered.set(path, ticket);
33+}
34+
35+/** The offered ticket for `path`, if one is left and still good. */
36+export function takeOfferedTicket(path: string, now = Date.now()): string | null {
37+ const found = offered.get(path);
38+ if (!found) return null;
39+ offered.delete(path);
40+ return Date.parse(found.expires_at) - now > 5_000 ? found.ticket : null;
41+}
42+
2043 /** `wss://<this site><path>?<params>`, with a ticket when one was given. */
2144 export function liveAddress(path: string, params: Record<string, string | null | undefined>, ticket: string | null): string {
2245 const url = new URL(path, location.href);
5881 open(liveAddress(path, params(), null));
5982 return;
6083 }
84+ const minted = takeOfferedTicket(path);
85+ if (minted) {
86+ open(liveAddress(path, params(), minted));
87+ return;
88+ }
6189 void ticketFor(path).then((ticket) => {
6290 if (!cancelled()) open(liveAddress(path, params(), ticket));
6391 });
+20−3
22
33 import type { User } from "@g1t/contracts";
44
5−import { getViewer } from "./session.server";
5+import { getViewer, roleIn } from "./session.server";
66 import { identity } from "./services.server";
7−import { ticketViewer } from "./socket-ticket";
8−import { websiteUser } from "./website-token";
7+import { issueTicket, socketPath, ticketViewer } from "./socket-ticket";
8+import { bearerToken, websiteUser } from "./website-token";
99
1010 let isolateSecret: string | null = null;
1111
2525 }
2626
2727 /**
28+ * A socket ticket minted with a page, for a page opened with an access
29+ * token: what `GET /-/live/ticket?path=` would answer, so the page's
30+ * first socket opens without that round trip (lib/live-socket.ts
31+ * `offerTicket`). Null for a session (its sockets carry the cookie) and
32+ * for a path that is not one of the site's sockets, or not the viewer's
33+ * workspace's.
34+ */
35+export async function socketTicketFor(context: Parameters<typeof getViewer>[0], request: Request, path: string): Promise<{ ticket: string; expires_at: string } | null> {
36+ const viewer = getViewer(context);
37+ const token = bearerToken(request);
38+ if (!viewer || !token || !viewer.token?.website) return null;
39+ const socket = socketPath(path);
40+ if (!socket || (socket.workspace && !roleIn(viewer, socket.workspace))) return null;
41+ return issueTicket(ticketSecret(), { token, userId: viewer.id, path: socket.path });
42+}
43+
44+/**
2845 * Who opens a live socket: the session or token the request carries, as
2946 * on any page, or else the person a socket ticket was made for
3047 * (lib/socket-ticket.ts), whose token is checked again now.
+17−1
33
44 import type { User, Viewer } from "@g1t/contracts";
55
6−import { liveAddress, openLive, setLiveViaToken } from "./live-socket.ts";
6+import { liveAddress, offerTicket, openLive, setLiveViaToken, takeOfferedTicket } from "./live-socket.ts";
77 import { TICKET_PARAM, TICKET_ROUTE, TICKET_SECONDS, issueTicket, openTicket, socketPath, ticketViewer } from "./socket-ticket.ts";
88 import { tokenVerdict, websiteUser } from "./website-token.ts";
99
156156 openLive(CHAT, () => ({}), () => (late = true), () => true);
157157 await new Promise((resolve) => setTimeout(resolve, 10));
158158 assert.equal(late, false);
159+
160+ // A ticket the page's loader minted opens the first socket with no
161+ // request; it is used once, and not when its minute is nearly up.
162+ asked.length = 0;
163+ offerTicket(CHAT, { ticket: "st1.minted", expires_at: new Date(Date.now() + 50_000).toISOString() });
164+ const minted: string[] = [];
165+ openLive(CHAT, () => ({ channel: "ch_2" }), (address) => minted.push(address), () => false);
166+ assert.deepEqual(minted, ["wss://g1t.sh/acme/-/chat/live?channel=ch_2&ticket=st1.minted"], "synchronously, from the offered ticket");
167+ assert.deepEqual(asked, []);
168+ const again = await new Promise<string>((resolve) => openLive(CHAT, () => ({ channel: "ch_2" }), resolve, () => false));
169+ assert.equal(again, "wss://g1t.sh/acme/-/chat/live?channel=ch_2&ticket=st1.abc", "the next socket asks, as the offer was used");
170+ assert.deepEqual(asked, [`/-/live/ticket?path=${encodeURIComponent(CHAT)}`]);
171+ offerTicket(CHAT, { ticket: "st1.stale", expires_at: new Date(Date.now() + 2_000).toISOString() });
172+ assert.equal(takeOfferedTicket(CHAT), null, "an offer about to expire is not used");
173+ offerTicket(CHAT, null);
174+ assert.equal(takeOfferedTicket(CHAT), null);
159175 } finally {
160176 setLiveViaToken(false);
161177 Object.defineProperty(globalThis, "location", { value: was.location, configurable: true });
+9−2
5050 import { RELOADED_KEY, RELOAD_GIVE_UP_MS, clientNavigated, reloadFixes, reloadedBefore } from "./lib/stale-build";
5151 import { useNonce } from "./lib/nonce";
5252 import { isNeedsSignIn } from "./lib/website-token";
53−import { setLiveViaToken } from "./lib/live-socket";
53+import { offerTicket, setLiveViaToken } from "./lib/live-socket";
54+import { socketTicketFor } from "./lib/socket-ticket.server";
5455 import { LiveNotifications } from "./components/notifications/live-notifications";
5556 import { THEME_COLOR, THEME_COOKIE, readTheme, themeAttribute, useThemeChoice } from "./lib/theme";
5657
102103 return {
103104 user,
104105 shell,
106+ // For a page opened with an access token: the feed socket's ticket,
107+ // minted here so the feed opens without asking for one (lib/live-socket.ts).
108+ liveTicket: user?.token?.website ? await socketTicketFor(context, request, "/-/live") : null,
105109 // Whether they folded the sidebar away, so the page is drawn that way from the start.
106110 sidebarClosed: sidebarClosed(readCookie(cookies, SIDEBAR_COOKIE)),
107111 // Their Appearance, so the page is drawn in it from the first byte (lib/theme.ts).
342346 const root = loaded ?? (inBrowser ? lastRoot : undefined);
343347 const user = root?.user;
344348 // A page opened with an access token signs its live sockets in with tickets (lib/live-socket.ts).
345− if (inBrowser) setLiveViaToken(Boolean(user?.token?.website));
349+ if (inBrowser) {
350+ setLiveViaToken(Boolean(user?.token?.website));
351+ offerTicket("/-/live", root?.liveTicket);
352+ }
346353 const { pathname, search } = useLocation();
347354 // Drawn around the error page too: a 404 keeps the sidebar out of a
348355 // project or workspace the viewer cannot see.
+62−9
11 import { FOLIO_KIND_NOUNS, folioIdFrom, type DocRole, type Folio, type FolioPage } from "@g1t/contracts";
22 import { Lock } from "lucide-react";
33 import { Suspense, useCallback, useEffect, useState } from "react";
4−import { Link, data, redirect, useNavigate } from "react-router";
4+import { type HtmlLinkDescriptor, Link, data, redirect, useNavigate } from "react-router";
5+import editorChunk from "virtual:g1t-editor-chunk";
56
67 import type { Route } from "./+types/folio";
78 import { foliosRequest } from "../../../components/folios/actions";
89 import { EditorSkeleton } from "../../../components/folios/doc/editor-skeleton";
910 import { FOLIO_KIND_UI } from "../../../components/folios/kinds";
10−import type { LiveStatus } from "../../../components/folios/provider";
11+import { FolioProvider, type LiveStatus, pageStateBytes } from "../../../components/folios/provider";
1112 import { FolioHeader, type Presence } from "../../../components/folios/shell";
1213 import { ErrorText } from "../../../components/ui";
1314 import { Button } from "../../../components/ui/button";
15+import { type OfferedTicket, offerTicket } from "../../../lib/live-socket";
1416 import { page as pageMeta } from "../../../lib/meta";
1517 import { folios } from "../../../lib/services.server";
1618 import { requireUser, roleIn } from "../../../lib/session.server";
19+import { socketTicketFor } from "../../../lib/socket-ticket.server";
1720
1821 export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
1922 const f = loaded && "page" in loaded ? loaded.page.folio : null;
2023 return pageMeta(args, { title: `${f ? `${f.icon ? `${f.icon} ` : ""}${f.title || "Untitled"}` : "Artifact"} · ${params.owner} · g1t`, description: f?.excerpt, type: "article" });
2124 }
2225
23−type Loaded = { page: FolioPage } | { denied: string };
26+/**
27+ * The doc editor's code and what it imports, fetched with the page rather
28+ * than after the page's own code has run and asked for them. The server
29+ * knows the files (vite.config.ts `editorChunk`); the browser reads them
30+ * back from the page the server sent, so both render the same links.
31+ */
32+function editorFiles(): string[] {
33+ if (editorChunk.length) return editorChunk;
34+ if (typeof document === "undefined") return [];
35+ return [...document.querySelectorAll('link[rel="modulepreload"][data-doc-editor]')].map((link) => link.getAttribute("href") ?? "").filter(Boolean);
36+}
37+
38+export const links: Route.LinksFunction = () => editorFiles().map((href) => ({ rel: "modulepreload", href, "data-doc-editor": "" }) as unknown as HtmlLinkDescriptor);
2439
40+/** The live socket's path in this workspace (lib/socket-ticket.ts `socketPath`). */
41+const livePath = (slug: string) => `/${slug}/-/artifacts/live`;
42+
43+type Loaded = { page: FolioPage; ticket: OfferedTicket | null } | { denied: string };
44+
2545 export async function loader({ params, context, request }: Route.LoaderArgs) {
2646 const viewer = requireUser(context, request);
2747 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
2848 const id = folioIdFrom(params.folio);
2949 if (!id) throw data(null, { status: 404 });
30− const found = await folios.page(params.owner.toLowerCase(), viewer, id);
50+ const slug = params.owner.toLowerCase();
51+ // A page opened with an access token gets its room's socket ticket with
52+ // the page, so the socket opens without asking for one (lib/live-socket.ts).
53+ const [found, ticket] = await Promise.all([folios.page(slug, viewer, id), socketTicketFor(context, request, livePath(slug))]);
3154 if (!found.ok) {
3255 // One they can't read and one that doesn't exist look the same: both can be asked for.
3356 if (found.error.code === "not_found" || found.error.code === "forbidden") return data<Loaded>({ denied: id }, { status: 403 });
3659 // An old address (renamed since) goes to the current one.
3760 const url = new URL(request.url);
3861 if (url.pathname !== found.value.folio.path) throw redirect(`${found.value.folio.path}${url.search}`);
39− return { page: found.value } as Loaded;
62+ return { page: found.value, ticket } as Loaded;
4063 }
4164
4265 /** One artifact: the shared header, then its kind's page (a doc's editor, for now). */
4366 export default function FolioView({ loaderData, params }: Route.ComponentProps) {
4467 const slug = params.owner.toLowerCase();
4568 if ("denied" in loaderData) return <NeedAccess slug={slug} id={loaderData.denied} />;
46− return <Opened slug={slug} page={loaderData.page} />;
69+ return <Opened slug={slug} page={loaderData.page} ticket={loaderData.ticket} />;
4770 }
4871
49−function Opened({ slug, page }: { slug: string; page: FolioPage }) {
72+/**
73+ * The page connects to the folio's room as soon as it hydrates, before
74+ * the body's code (and the editor's, far larger) has arrived, and seeds
75+ * the document from the state the page carried, so the editor opens on
76+ * it at once and the room sends only what changed since. The room never
77+ * gates the page: the text is readable from the first byte, the editor
78+ * works before the room answers, and the header's dot says when it has.
79+ */
80+function useLive(slug: string, page: FolioPage, ticket: OfferedTicket | null): { live: FolioProvider | null; status: LiveStatus } {
81+ const [live, setLive] = useState<FolioProvider | null>(null);
82+ const [status, setStatus] = useState<LiveStatus>("connecting");
83+ const id = page.folio.id;
84+ const state = page.state;
85+ useEffect(() => {
86+ offerTicket(livePath(slug), ticket);
87+ const scheme = window.location.protocol === "https:" ? "wss" : "ws";
88+ const provider = new FolioProvider(`${scheme}://${window.location.host}${livePath(slug)}?folio=${encodeURIComponent(id)}`, { state: pageStateBytes(state) });
89+ const off = provider.onStatus(setStatus);
90+ setLive(provider);
91+ return () => {
92+ off();
93+ provider.destroy();
94+ setLive(null);
95+ };
96+ // The same folio keeps its connection through a revalidation; only another folio reconnects.
97+ // eslint-disable-next-line react-hooks/exhaustive-deps
98+ }, [slug, id]);
99+ return { live, status };
100+}
101+
102+function Opened({ slug, page, ticket }: { slug: string; page: FolioPage; ticket: OfferedTicket | null }) {
50103 const navigate = useNavigate();
51104 const [folio, setFolio] = useState<Folio>(page.folio);
52105 useEffect(() => setFolio(page.folio), [page.folio]);
53106 const [role, setRole] = useState<DocRole>(page.folio.viewer_role);
54107 useEffect(() => setRole(page.folio.viewer_role), [page.folio.viewer_role, page.folio.id]);
55108 const [presence, setPresence] = useState<Presence[]>([]);
56− const [status, setStatus] = useState<LiveStatus>("connecting");
109+ const { live, status } = useLive(slug, page, ticket);
57110 const [showComments, setShowComments] = useState(false);
58111 const [error, setError] = useState<string | null>(null);
59112 const onRole = useCallback((next: DocRole | null) => next && setRole(next), []);
84137 </div>
85138 }
86139 >
87− <Body slug={slug} page={page} folio={folio} role={role} showComments={showComments} onPresence={setPresence} onStatus={setStatus} onFolio={setFolio} onRole={onRole} onError={setError} />
140+ <Body slug={slug} page={page} folio={folio} live={live} role={role} showComments={showComments} onPresence={setPresence} onFolio={setFolio} onRole={onRole} onError={setError} />
88141 </Suspense>
89142 ) : (
90143 <p className="mx-auto mt-16 max-w-md text-center text-sm text-muted">This is a {FOLIO_KIND_NOUNS[folio.kind]}. Opening {ui.label.toLowerCase()} here is coming soon.</p>
+9−0
1+/**
2+ * The doc editor's chunk and what it imports, as the browser build wrote
3+ * them (vite.config.ts `editorChunk`): their addresses in the server
4+ * build, none in the browser build and in development.
5+ */
6+declare module "virtual:g1t-editor-chunk" {
7+ const files: string[];
8+ export default files;
9+}
+4−1
2323 "~/*": ["./app/*"]
2424 },
2525 "esModuleInterop": true,
26− "resolveJsonModule": true
26+ "resolveJsonModule": true,
27+ // Browser modules tested under Node (`npm test`) name the files they
28+ // import with their extension, as Node needs (components/folios/provider.ts).
29+ "allowImportingTsExtensions": true
2730 }
2831 }
+57−4
11 import { reactRouter } from "@react-router/dev/vite";
22 import { cloudflare } from "@cloudflare/vite-plugin";
33 import tailwindcss from "@tailwindcss/vite";
4−import { defineConfig } from "vite";
4+import { defineConfig, type Plugin } from "vite";
55
6+/**
7+ * The doc editor's chunk and everything it imports, so a doc's page can
8+ * ask the browser to fetch them with the page (`<link rel="modulepreload">`,
9+ * routes/workspace/folios/folio.tsx `links`) rather than after the page's
10+ * own code has run and asked for them. The editor is only ever imported
11+ * lazily (it runs in the browser alone), so no route lists it among its
12+ * modules; this notes its files as the browser build writes them, for the
13+ * server build that follows in the same run. In development there are no
14+ * such files, and the page adds no links.
15+ */
16+function editorChunk(): Plugin {
17+ const id = "virtual:g1t-editor-chunk";
18+ let files: string[] = [];
19+ return {
20+ name: "g1t-editor-chunk",
21+ resolveId(source) {
22+ return source === id ? `\0${id}` : null;
23+ },
24+ load(source) {
25+ return source === `\0${id}` ? `export default ${JSON.stringify(files)};` : null;
26+ },
27+ generateBundle(_options, bundle) {
28+ if (this.environment.name !== "client") return;
29+ const chunks = Object.values(bundle).filter((output) => output.type === "chunk");
30+ const editor = chunks.find((chunk) => chunk.moduleIds.some((moduleId) => moduleId.replace(/\\/g, "/").endsWith("/app/components/folios/doc/editor.tsx")));
31+ if (!editor) return;
32+ // The chunk first, then what it statically imports, each once.
33+ const seen = new Set<string>();
34+ const walk = (fileName: string) => {
35+ if (seen.has(fileName)) return;
36+ seen.add(fileName);
37+ const chunk = chunks.find((c) => c.fileName === fileName);
38+ for (const imported of chunk?.imports ?? []) walk(imported);
39+ };
40+ walk(editor.fileName);
41+ files = [...seen].map((fileName) => `/${fileName}`);
42+ },
43+ };
44+}
45+
646 export default defineConfig({
747 plugins: [
848 cloudflare({ viteEnvironment: { name: "ssr" } }),
949 tailwindcss(),
1050 reactRouter(),
51+ editorChunk(),
1152 ],
1253 resolve: {
1354 tsconfigPaths: true,
1657 rolldownOptions: {
1758 output: {
1859 codeSplitting: {
19− // Icons are shared by nearly every page, a few hundred bytes each:
20− // one file for all of them, not a request per icon.
21− groups: [{ name: "icons", test: /[\\/]node_modules[\\/]lucide-react[\\/]/ }],
60+ groups: [
61+ // Icons are shared by nearly every page, a few hundred bytes each:
62+ // one file for all of them, not a request per icon.
63+ { name: "icons", test: /[\\/]node_modules[\\/]lucide-react[\\/]/, priority: 2 },
64+ // The app's own components and helpers that several routes
65+ // share: grouped by which routes use them, and small groups
66+ // merged into their neighbours, so a page fetches a few files
67+ // of them rather than one per module. A signed-in page fetched
68+ // 124 files, most of them a kilobyte or two, each a request and
69+ // a `modulepreload` to parse. Modules one route alone uses stay
70+ // with that route. Packages are left to automatic splitting: a
71+ // group over them merged what the editor needs with what the
72+ // diagram renderers need, and every doc fetched both.
73+ { name: "app", test: /[\\/]apps[\\/]web[\\/]app[\\/](components|lib)[\\/]/, minShareCount: 2, entriesAware: true, entriesAwareMergeThreshold: 24_000, priority: 1 },
74+ ],
2275 },
2376 },
2477 },
+79−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { openD1, sessionConstraint, timedD1, type D1Timing } from "./d1.ts";
5+
6+/** A binding that answers every query after a tick, and remembers what batch was given. */
7+function fakeDb() {
8+ const given: object[][] = [];
9+ const statement = (query: string, binds: unknown[] = []) => ({
10+ query,
11+ binds,
12+ bind: (...args: unknown[]) => statement(query, args),
13+ all: async () => ({ results: [{ query, binds }] }),
14+ first: async () => ({ query, binds }),
15+ run: async () => ({ success: true }),
16+ raw: async () => [[query]],
17+ });
18+ const db = {
19+ prepare: (query: string) => statement(query),
20+ batch: async (statements: object[]) => {
21+ given.push(statements);
22+ return statements.map(() => ({ success: true }));
23+ },
24+ withSession: (constraint?: string) => ({ ...db, constraint, getBookmark: () => "bm-1" }),
25+ };
26+ return { db, given };
27+}
28+
29+test("timedD1 counts each statement's trip and each batch, and keeps bindings", async () => {
30+ const { db, given } = fakeDb();
31+ const timing: D1Timing = { trips: 0, ms: 0 };
32+ const timed = timedD1(db, timing);
33+ const first = await timed.prepare("SELECT ?").bind(1).first();
34+ assert.deepEqual(first, { query: "SELECT ?", binds: [1] });
35+ await timed.prepare("SELECT 2").all();
36+ await timed.prepare("UPDATE t").run();
37+ await timed.prepare("SELECT 3").raw();
38+ assert.equal(timing.trips, 4);
39+ const a = timed.prepare("A").bind("x");
40+ const b = timed.prepare("B");
41+ await timed.batch([a, b]);
42+ assert.equal(timing.trips, 5, "a batch is one trip however many statements it carries");
43+ // The binding got its own statements back, not the counting wrappers.
44+ assert.equal(given.length, 1);
45+ assert.deepEqual(
46+ given[0]!.map((s) => (s as { query: string; binds: unknown[] }).query),
47+ ["A", "B"],
48+ );
49+ assert.deepEqual((given[0]![0] as { binds: unknown[] }).binds, ["x"]);
50+ assert.ok(timing.ms >= 0);
51+});
52+
53+test("openD1 reports the service time, the database time and the bookmark", async () => {
54+ const { db } = fakeDb();
55+ const opened = openD1(db, new Request("https://service/rpc/x", { headers: { "x-d1-bookmark": "first-unconstrained" } }));
56+ await opened.db.prepare("SELECT 1").all();
57+ await opened.db.prepare("SELECT 2").all();
58+ const answered = opened.finish(Response.json({ ok: true }));
59+ const timing = answered.headers.get("server-timing") ?? "";
60+ assert.match(timing, /^svc;dur=\d+;desc="session", db;dur=\d+;desc="2 round trips"$/);
61+ assert.equal(answered.headers.get("x-d1-bookmark"), "bm-1");
62+ assert.deepEqual(await answered.json(), { ok: true });
63+});
64+
65+test("openD1 without a session reads the primary and says so, with no db metric when nothing was read", async () => {
66+ const { db } = fakeDb();
67+ const opened = openD1(db, new Request("https://service/rpc/x"));
68+ const answered = opened.finish(new Response("ok"));
69+ assert.equal(answered.headers.get("server-timing"), `svc;dur=${/\d+/.exec(answered.headers.get("server-timing") ?? "")?.[0]};desc="primary"`);
70+ assert.equal(answered.headers.get("x-d1-bookmark"), null);
71+});
72+
73+test("sessionConstraint takes the two first-* words and a bookmark, and starts anything else on the primary", () => {
74+ assert.equal(sessionConstraint(null), null);
75+ assert.equal(sessionConstraint("first-primary"), "first-primary");
76+ assert.equal(sessionConstraint("first-unconstrained"), "first-unconstrained");
77+ assert.equal(sessionConstraint("0000abc-DEF"), "0000abc-DEF");
78+ assert.equal(sessionConstraint("not a bookmark!"), "first-primary");
79+});
+57−5
3636 /** A D1 binding, as far as sessions need it. */
3737 type SessionCapable = { withSession(constraintOrBookmark?: string): { getBookmark(): string | null } };
3838
39+/** How long a request waited on D1, and in how many round trips. */
40+export type D1Timing = { trips: number; ms: number };
41+
42+/** The methods of a prepared statement that go to the database. */
43+const TRIPS = new Set(["all", "first", "run", "raw"]);
44+
45+/**
46+ * `db` with every round trip counted into `timing`: each `all`, `first`,
47+ * `run` and `raw` of a statement, and each `batch`. Statements given to
48+ * `batch` are unwrapped, so the binding sees its own. The `db;dur` metric
49+ * crates/kit/src/d1.rs reports for the Rust services, for the TypeScript
50+ * ones; the site reads both (apps/web/app/lib/perf.ts `databaseTime`).
51+ */
52+export function timedD1<D extends object>(db: D, timing: D1Timing): D {
53+ const raw = new WeakMap<object, object>();
54+ const trip = async <T>(work: () => Promise<T>): Promise<T> => {
55+ const from = Date.now();
56+ try {
57+ return await work();
58+ } finally {
59+ timing.trips += 1;
60+ timing.ms += Date.now() - from;
61+ }
62+ };
63+ const statement = (s: object): object => {
64+ const wrapped = new Proxy(s, {
65+ get(target, prop) {
66+ const value = Reflect.get(target, prop, target);
67+ if (typeof value !== "function") return value;
68+ if (prop === "bind") return (...args: unknown[]) => statement(value.apply(target, args));
69+ if (typeof prop === "string" && TRIPS.has(prop)) return (...args: unknown[]) => trip(() => value.apply(target, args));
70+ return value.bind(target);
71+ },
72+ });
73+ raw.set(wrapped, s);
74+ return wrapped;
75+ };
76+ return new Proxy(db, {
77+ get(target, prop) {
78+ const value = Reflect.get(target, prop, target);
79+ if (typeof value !== "function") return value;
80+ if (prop === "prepare") return (query: string) => statement(value.call(target, query));
81+ if (prop === "batch") return (statements: object[]) => trip(() => value.call(target, statements.map((s) => raw.get(s) ?? s)));
82+ return value.bind(target);
83+ },
84+ });
85+}
86+
3987 /**
4088 * The database for an RPC `request`: a session, seen as the binding, when
4189 * the caller asked for one (it answers `prepare` and `batch`, all a request
42− * path uses), the binding itself otherwise. `finish` adds the bookmark and
43− * the time taken to the answer.
90+ * path uses), the binding itself otherwise, with its round trips counted.
91+ * `finish` adds the bookmark, the time taken and the database time to the
92+ * answer.
4493 */
45−export function openD1<D extends SessionCapable>(db: D, request: Request): { db: D; finish(response: Response): Response } {
94+export function openD1<D extends SessionCapable>(db: D, request: Request): { db: D; timing: D1Timing; finish(response: Response): Response } {
4695 const started = Date.now();
4796 const asked = sessionConstraint(request.headers.get(BOOKMARK_HEADER));
4897 const session = asked ? db.withSession(asked) : null;
98+ const timing: D1Timing = { trips: 0, ms: 0 };
4999 return {
50− db: (session ?? db) as D,
100+ db: timedD1((session ?? db) as D, timing),
101+ timing,
51102 finish(response) {
52103 const answered = new Response(response.body, response);
53− answered.headers.append("server-timing", `svc;dur=${Date.now() - started};desc="${session ? "session" : "primary"}"`);
104+ const db = timing.trips ? `, db;dur=${timing.ms};desc="${timing.trips} round trip${timing.trips === 1 ? "" : "s"}"` : "";
105+ answered.headers.append("server-timing", `svc;dur=${Date.now() - started};desc="${session ? "session" : "primary"}"${db}`);
54106 const bookmark = session?.getBookmark();
55107 if (bookmark) answered.headers.set(BOOKMARK_HEADER, bookmark);
56108 return answered;
+7−0
270270 folio: Folio;
271271 /** Its text rendition when last saved (a doc's Markdown): what shows until the live editor loads. */
272272 text: string;
273+ /**
274+ * The document as last saved (a Yjs update, base64), so the editor
275+ * opens from it at once and syncs the difference with its room; null
276+ * when none is kept yet or it is too large to carry, and the editor
277+ * waits for the room.
278+ */
279+ state: string | null;
273280 /** The docs it sits under, from the top, that the viewer can read. */
274281 breadcrumbs: FolioRef[];
275282 /** What sits under it (a doc's sub-pages) that the viewer can read. */
+7−0
1+-- The document itself, saved beside its text rendition each time a folio's
2+-- room saves (src/persist.ts `saveFolio`): the Yjs state the page hands the
3+-- browser, so a doc opens from it before its room answers, and what an
4+-- emptied room is refilled with. NULL until the first save after this
5+-- migration, and for a document too large for a row; either way the page
6+-- waits for the room, as every page did before.
7+ALTER TABLE folios ADD COLUMN state BLOB;
+2−0
4040 edited_at: string;
4141 trashed_at: string | null;
4242 trashed_by: string | null;
43+ /** The saved Yjs state (migration 0006), only when a read asks for it. */
44+ state?: ArrayBuffer | number[] | null;
4345 };
4446
4547 /** Every column but the text, as lists read them. */
+13−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { cleanCover, cleanIcon, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, sharedTops, subtreeHeight, treeNodes } from "./list.ts";
4+import { MAX_PAGE_STATE, cleanCover, cleanIcon, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, pageState, sharedTops, subtreeHeight, treeNodes } from "./list.ts";
5+
6+test("a page carries the saved document as base64, and none when there is none or it is too large", () => {
7+ assert.equal(pageState(null), null);
8+ assert.equal(pageState(new Uint8Array(0).buffer), null);
9+ const bytes = new Uint8Array(70_000).map((_, i) => i % 251);
10+ const carried = pageState(bytes.buffer);
11+ assert.ok(carried);
12+ assert.deepEqual(Uint8Array.from(atob(carried!), (c) => c.charCodeAt(0)), bytes);
13+ // D1 may hand a BLOB back as an array of numbers.
14+ assert.equal(pageState([104, 105]), btoa("hi"));
15+ assert.equal(pageState(new Uint8Array(MAX_PAGE_STATE + 1).buffer), null);
16+});
517
618 test("titles, icons, covers and sources are cleaned", () => {
719 assert.equal(cleanTitle(" Q4 roadmap \n"), "Q4 roadmap");
+16−0
1616 export const MAX_LIMIT = 100;
1717 /** A note on an edit, a template's description: at most this long. */
1818 export const MAX_NOTE = 500;
19+/**
20+ * The largest saved document a page carries to the browser (base64, in
21+ * the page's data). Past it the editor waits for the room, as it did for
22+ * every doc before the state was kept.
23+ */
24+export const MAX_PAGE_STATE = 512 * 1024;
25+
26+/** A folio's saved Yjs state as the page carries it: base64, or null when none is kept or it is too large. */
27+export function pageState(state: ArrayBuffer | ArrayLike<number> | null | undefined): string | null {
28+ if (!state) return null;
29+ const bytes = new Uint8Array(state as ArrayBuffer);
30+ if (!bytes.byteLength || bytes.byteLength > MAX_PAGE_STATE) return null;
31+ let binary = "";
32+ for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
33+ return btoa(binary);
34+}
1935
2036 export function cleanTitle(title: unknown, max = MAX_TITLE): string {
2137 return [...String(title ?? "").replace(/\s+/g, " ").trim()].slice(0, max).join("");
+9−4
230230
231231 /**
232232 * Names the folio and its kind, and fills an empty document: from a
233− * Yjs state (a duplicate), or the kind's seed from text or a spec (a
234− * template, an agent's new folio, or blank).
233+ * Yjs state (a duplicate, or what D1 saved), or the kind's seed from
234+ * text or a spec (a template, an agent's new folio, or blank). Returns
235+ * whether the document has content afterwards: called with nothing to
236+ * fill it with, an empty room says false and the service reads what
237+ * was saved, so the usual open costs one call and no D1 read.
235238 */
236− async ensure(init: { folio_id: string; kind: FolioKind; workspace_slug: string; text?: string | null; spec?: unknown; state?: Uint8Array | null }): Promise<void> {
239+ async ensure(init: { folio_id: string; kind: FolioKind; workspace_slug: string; text?: string | null; spec?: unknown; state?: Uint8Array | null }): Promise<boolean> {
237240 this.setMeta("folio_id", init.folio_id);
238241 this.setMeta("kind", init.kind);
239242 if (init.workspace_slug) this.setMeta("workspace_slug", init.workspace_slug);
240243 const doc = this.load();
241244 const model = this.model();
242− if (!model.isEmpty(doc)) return;
245+ if (!model.isEmpty(doc)) return true;
246+ if (init.text === undefined && init.spec === undefined && init.state == null) return false;
243247 // No origin: filling a new room is its "created" version (written by the service), nobody's edit.
244248 if (init.state) Y.applyUpdate(doc, init.state);
245249 else doc.transact(() => model.seed(doc, { text: init.text ?? null, spec: init.spec }));
250+ return true;
246251 }
247252
248253 /** The folio in its agent form. */
+48−26
110110 } from "./access-store.ts";
111111 import { agentMayFind, agentReach, audienceRule, type AgentReach, type AudienceRule } from "./agents.ts";
112112 import { publishFolioEvent } from "./events.ts";
113−import { MAX_DEPTH, cleanCover, cleanIcon, cleanNote, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, sharedTops, slugOf, subtreeHeight, treeNodes } from "./list.ts";
113+import { MAX_DEPTH, cleanCover, cleanIcon, cleanNote, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, pageState, sharedTops, slugOf, subtreeHeight, treeNodes } from "./list.ts";
114114 import { REQUEST_RECIPIENTS, claimAccessRequest } from "./requests.ts";
115115 import type { FolioRoom } from "./room.ts";
116116 import { builtinFolioTemplate, builtinFolioTemplates } from "./templates.ts";
205205 );
206206 }
207207
208− /** The room, named and given its kind and (when empty) its saved text. */
208+ /**
209+ * The room, named and given its kind. A room that is empty (new, or its
210+ * storage gone) is filled from what D1 saved: the document's state when
211+ * there is one, so it stays the very document every reader was given
212+ * (`page` hands the same state to the browser), else its text. The
213+ * common case, a room with the document already, costs one call.
214+ */
209215 private async ready(workspace: Workspace, row: FolioRow) {
210216 const room = this.room(row.id);
211− let text = row.text;
212− if (!text) text = (await this.db.prepare("SELECT text FROM folios WHERE id = ?").bind(row.id).first<{ text: string }>())?.text ?? "";
213− await room.ensure({ folio_id: row.id, kind: row.kind, workspace_slug: workspace.slug, text });
217+ const named = { folio_id: row.id, kind: row.kind, workspace_slug: workspace.slug };
218+ if (await room.ensure(named)) return room;
219+ const saved = await this.db.prepare("SELECT text, state FROM folios WHERE id = ?").bind(row.id).first<{ text: string; state: ArrayBuffer | number[] | null }>();
220+ await room.ensure({ ...named, text: saved?.text ?? row.text ?? "", state: saved?.state ? new Uint8Array(saved.state as ArrayBuffer) : null });
214221 return room;
215222 }
216223
221228 if (!found.ok) return found;
222229 const workspace = found.value;
223230 const user = viewer!;
224− await this.who.ensureDefault(workspace, user);
225− const person = await this.who.viewerPerson(workspace, user);
231+ // Their teams (identity), the spaces (D1) and the General check (D1,
232+ // once per isolate) start together: one round, not three.
233+ const [person] = await Promise.all([this.who.viewerPerson(workspace, user), this.who.ensureDefault(workspace, user), this.who.allSpaces(workspace)]);
234+ // Read again only when General was just made (which forgets the spaces).
226235 const spaces = await this.who.spacesFor(workspace, person);
227236 return ok({ workspace, viewer: user, key: userKey(user), person, spaces, spaceById: new Map(spaces.map((s) => [s.row.id, s])), owner: this.who.viewerOwner(user, workspace.slug) });
228237 }
243252 * read it at all. `opening` counts as opening its link (the `folio`
244253 * read and the live socket), which is what makes a link folio readable.
245254 */
246− private async open(ctx: Ctx, folioId: unknown, need: DocRole, options: { trashed?: boolean; opening?: boolean; text?: boolean } = {}): Promise<Result<{ row: FolioRow; role: DocRole; found: Ancestry }>> {
247− const columns = options.text ? FOLIO_COLUMNS.replace("'' AS text", "text") : FOLIO_COLUMNS;
255+ private async open(ctx: Ctx, folioId: unknown, need: DocRole, options: { trashed?: boolean; opening?: boolean; text?: boolean; state?: boolean } = {}): Promise<Result<{ row: FolioRow; role: DocRole; found: Ancestry }>> {
256+ let columns = options.text ? FOLIO_COLUMNS.replace("'' AS text", "text") : FOLIO_COLUMNS;
257+ if (options.state) columns += ", state";
248258 const row = await this.db.prepare(`SELECT ${columns} FROM folios WHERE id = ? AND workspace_id = ?`).bind(String(folioId ?? ""), ctx.workspace.id).first<FolioRow>();
249259 if (!row) return fail("not_found", "No such artifact.");
250260 if (row.trashed_at && !options.trashed) return fail("not_found", "That artifact is in the trash.");
274284 const readable = rows.filter((r) => roles.get(r.id));
275285 if (!readable.length) return [];
276286 const ids = readable.map((r) => r.id);
277− const [favorites, counts, kids, stale] = await Promise.all([
287+ // The people (identity, the slow part) with the rows' D1 reads, not after them.
288+ const [favorites, counts, kids, stale, people] = await Promise.all([
278289 this.db.prepare("SELECT folio_id FROM folio_favorites WHERE user_id = ? AND folio_id IN (SELECT value FROM json_each(?))").bind(ctx.viewer.id, json(ids)).all<{ folio_id: string }>(),
279290 this.db.prepare("SELECT folio_id, COUNT(*) AS n FROM folio_grants WHERE folio_id IN (SELECT value FROM json_each(?)) GROUP BY folio_id").bind(json(ids)).all<{ folio_id: string; n: number }>(),
280291 this.db.prepare("SELECT DISTINCT parent_id FROM folios WHERE parent_id IN (SELECT value FROM json_each(?)) AND trashed_at IS NULL").bind(json(ids)).all<{ parent_id: string }>(),
281292 this.staleIds(ids),
293+ this.who.profiles(
294+ ctx.workspace,
295+ readable.flatMap((r) => [r.owner, r.created_by, ...(r.edited_by ? [r.edited_by] : [])]),
296+ ),
282297 ]);
283− const people = await this.who.profiles(
284− ctx.workspace,
285− readable.flatMap((r) => [r.owner, r.created_by, ...(r.edited_by ? [r.edited_by] : [])]),
286− );
287298 const fav = new Set(favorites.results.map((f) => f.folio_id));
288299 const shared = new Map(counts.results.map((c) => [c.folio_id, c.n]));
289300 const parents = new Set(kids.results.map((k) => k.parent_id));
431442 }
432443
433444 async sidebar(a: Args): Promise<Result<FoliosSidebar>> {
434− const found = await this.ctx(a.workspace, a.viewer);
445+ // The spaces they joined need only who they are: read with the context, not after it.
446+ const [found, joined] = await Promise.all([
447+ this.ctx(a.workspace, a.viewer),
448+ this.db
449+ .prepare("SELECT space_id FROM space_joins WHERE user_id = ?")
450+ .bind(String(a.viewer?.id ?? ""))
451+ .all<{ space_id: string }>(),
452+ ]);
435453 if (!found.ok) return found;
436454 const ctx = found.value;
437− const joins = new Set(
438− (await this.db.prepare("SELECT space_id FROM space_joins WHERE user_id = ?").bind(ctx.viewer.id).all<{ space_id: string }>()).results.map((r) => r.space_id),
439− );
455+ const joins = new Set(joined.results.map((r) => r.space_id));
440456 // Joined open spaces (General always), team spaces of theirs, Members-only spaces they're in.
441457 const shown = ctx.spaces.filter((s) => s.role && !s.row.archived_at && (s.row.kind !== "workspace" || s.row.is_default || joins.has(s.row.id)));
442458 const keys = personKeys(ctx.person);
480496 ]);
481497 const all = [...spaceRows.results, ...privateRows.results, ...sharedRows.results, ...favoriteRows.results];
482498 const unique = [...new Map(all.map((r) => [r.id, r])).values()];
483− const { roles } = await this.roles(ctx, unique);
499+ // Roles and staleness together: staleness is asked of every tree row, and read only for the readable ones.
500+ const [{ roles }, staleAll] = await Promise.all([this.roles(ctx, unique), this.staleIds([...spaceRows.results, ...privateRows.results].map((r) => r.id))]);
484501 const can = (r: FolioRow) => !!roles.get(r.id);
485502 const inSpaces = spaceRows.results.filter(can);
486503 const mine = privateRows.results.filter(can);
487− const stale = await this.staleIds([...inSpaces, ...mine].map((r) => r.id));
504+ const stale = new Set([...inSpaces, ...mine].map((r) => r.id).filter((id) => staleAll.has(id)));
488505 const elsewhere = new Set([...inSpaces, ...mine].map((r) => r.id));
489506 const spaceCounts = new Map<string, number>();
490507 for (const r of inSpaces) spaceCounts.set(r.space_id!, (spaceCounts.get(r.space_id!) ?? 0) + 1);
541558 const found = await this.ctx(a.workspace, a.viewer);
542559 if (!found.ok) return found;
543560 const ctx = found.value;
544− const opened = await this.open(ctx, a.folio_id, "view", { trashed: true, opening: true, text: true });
561+ const opened = await this.open(ctx, a.folio_id, "view", { trashed: true, opening: true, text: true, state: true });
545562 if (!opened.ok) return opened;
546563 const { row, role } = opened.value;
547564 const at = now();
552569 .run(),
553570 );
554571 const above = row.path.split("/").filter((id) => id && id !== row.id);
555− const [aboveRows, childRows, linkRows] = await Promise.all([
572+ // The ancestors' and the parents' rows are already known: the folio
573+ // itself, its suggestions, and what sits around it, all in one round.
574+ const known = { roles: new Map([[row.id, role]]), found: opened.value.found };
575+ const [aboveRows, childRows, linkRows, [folio], suggestions] = await Promise.all([
556576 foliosById(this.db, above),
557577 this.db.prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE parent_id = ? AND trashed_at IS NULL ORDER BY position LIMIT 200`).bind(row.id).all<FolioRow>(),
558578 this.db
559579 .prepare(`SELECT ${folioColumns("f")} FROM folio_links l JOIN folios f ON f.id = l.from_folio WHERE l.to_folio = ? AND f.workspace_id = ? AND f.trashed_at IS NULL LIMIT 200`)
560580 .bind(row.id, ctx.workspace.id)
561581 .all<FolioRow>(),
582+ this.toFolios(ctx, [row], known),
583+ row.kind === "doc" ? this.openSuggestions(ctx, row) : Promise.resolve([] as FolioSuggestion[]),
562584 ]);
563585 const parents = above.map((id) => aboveRows.get(id)).filter((r): r is FolioRow => !!r);
564586 const others = [...parents, ...childRows.results, ...linkRows.results.filter((r) => r.id !== row.id)];
565587 const { roles } = await this.roles(ctx, others);
566588 const readable = (list: FolioRow[]) => list.filter((r) => roles.get(r.id)).map((r) => this.ref(ctx.workspace.slug, r));
567− const [folio] = await this.toFolios(ctx, [row], { roles: new Map([[row.id, role]]), found: opened.value.found });
568589 return ok({
569590 folio: folio!,
570591 text: row.text,
592+ state: pageState(row.state),
571593 breadcrumbs: readable(parents),
572594 children: readable(childRows.results),
573595 backlinks: readable(linkRows.results.filter((r) => r.id !== row.id)),
574− suggestions: row.kind === "doc" ? await this.openSuggestions(ctx, row) : [],
596+ suggestions,
575597 });
576598 }
577599
24492471 const { row, role } = opened.value;
24502472 if (row.trashed_at) return new Response("That artifact is in the trash\n", { status: 410 });
24512473 if (!kindModel(row.kind)) return new Response("That kind of artifact isn't here yet\n", { status: 409 });
2452− const room = await this.ready(ctx.workspace, row);
2453− const member = (await this.who.profiles(ctx.workspace, [ctx.key])).get(ctx.key)!;
2474+ // The room (one call, when it has the document) and who this is (identity) together.
2475+ const [room, member] = await Promise.all([this.ready(ctx.workspace, row), this.who.profiles(ctx.workspace, [ctx.key]).then((people) => people.get(ctx.key)!)]);
24542476 const headers = new Headers(request.headers);
24552477 headers.delete(DOCS_VIEWER_HEADER);
24562478 headers.set(ROOM_MEMBER_HEADER, JSON.stringify({ folio_id: row.id, workspace_slug: ctx.workspace.slug, key: ctx.key, member, role }));
+5−0
191191 const last = input.editors[input.editors.length - 1] ?? null;
192192 const statements: D1PreparedStatement[] = [];
193193 const preview = r.preview ? JSON.stringify(r.preview) : null;
194+ // The document itself, so a page opens from it before its room answers
195+ // (folios/service.ts `page`), and an emptied room is refilled with the
196+ // very same document (`ready`). Too large for a row: none is kept, and
197+ // the page waits for the room as before.
198+ statements.push(db.prepare("UPDATE folios SET state = ? WHERE id = ?").bind(input.state.byteLength <= MAX_VERSION_STATE ? input.state : null, folio.id));
194199 // A folio made from text that already reads as the kind renders it still needs its card.
195200 if (!changed && preview !== folio.preview) statements.push(db.prepare("UPDATE folios SET preview = ? WHERE id = ?").bind(preview, folio.id));
196201 if (changed) {
+33−2
5757
5858 export const now = () => new Date().toISOString();
5959
60+/**
61+ * Kept across requests in this isolate: the workspace behind a slug for
62+ * half a minute (identity answers it in ~80 ms, and every folio call
63+ * starts with it), and which workspaces already have their General
64+ * space (made once, never unmade). A rename reaches the old slug within
65+ * that half minute, which is what the site's own redirect allows.
66+ */
67+const WORKSPACE_TTL_MS = 30_000;
68+const workspaces = new Map<string, { at: number; value: Promise<Workspace | null> }>();
69+const defaultsMade = new Set<string>();
70+
71+/** For tests: forgets everything kept across requests. */
72+export function forgetKept(): void {
73+ workspaces.clear();
74+ defaultsMade.clear();
75+}
76+
6077 export function rulesOf(space: Pick<Space, "row" | "members">): SpaceRules {
6178 return { kind: space.row.kind, team: space.row.team, default_role: space.row.default_role, members: space.members };
6279 }
83100 const key = String(slug ?? "").toLowerCase();
84101 let found = this.workspaces.get(key);
85102 if (!found) {
86− found = identityClient(this.env.IDENTITY).getWorkspace(key).catch(() => null);
103+ const kept = workspaces.get(key);
104+ if (kept && Date.now() - kept.at < WORKSPACE_TTL_MS) found = kept.value;
105+ else {
106+ found = identityClient(this.env.IDENTITY).getWorkspace(key).catch(() => null);
107+ workspaces.set(key, { at: Date.now(), value: found });
108+ // Only an answer is kept: a miss or a failure is asked again next time.
109+ void found.then((w) => {
110+ if (!w) workspaces.delete(key);
111+ });
112+ }
87113 this.workspaces.set(key, found);
88114 }
89115 return found;
246272
247273 /** Makes the workspace's General space, once. */
248274 async ensureDefault(workspace: Workspace, viewer: User): Promise<void> {
275+ if (defaultsMade.has(workspace.id)) return;
249276 const db = this.env.DB;
250277 const found = await db.prepare("SELECT id FROM spaces WHERE workspace_id = ? AND is_default = 1").bind(workspace.id).first<{ id: string }>();
251− if (found) return;
278+ if (found) {
279+ defaultsMade.add(workspace.id);
280+ return;
281+ }
252282 const taken = new Set((await db.prepare("SELECT slug FROM spaces WHERE workspace_id = ?").bind(workspace.id).all<{ slug: string }>()).results.map((r) => r.slug));
253283 await db
254284 .prepare(
256286 )
257287 .bind(newId("spc"), workspace.id, freeSlug("general", taken), userKey(viewer), now())
258288 .run();
289+ defaultsMade.add(workspace.id);
259290 this.forgetSpaces();
260291 }
261292