diff --git a/CHANGELOG.md b/CHANGELOG.md index 147db408288..a470d7a7330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## vNEXT (not yet released) +## v3.19.3 + +### `@liveblocks/client` + +- Fix unexpected disconnects that could happen while receiving large or + long-running streaming responses from the server (e.g. when loading a large + initial storage state). + ## v3.19.2 ### `@liveblocks/client` diff --git a/docs/pages/get-started/nextjs-comments-ai.mdx b/docs/pages/get-started/nextjs-comments-ai.mdx index 9de0e66eb69..acdff9b8fed 100644 --- a/docs/pages/get-started/nextjs-comments-ai.mdx +++ b/docs/pages/get-started/nextjs-comments-ai.mdx @@ -23,7 +23,7 @@ your Next.js `/app` directory application. Have a Comments app ready - To add AI replies to comment thread, you first need to have a Liveblocks + To add AI replies to a comment thread, you first need to have a Liveblocks Comments app set up with secret key authentication and resolved users. Open up your app, or set up comments if you haven’t already. @@ -278,7 +278,7 @@ export async function handleAiCommentReply(data: { system: `You are a helpful assistant replying inside a Liveblocks comment thread. - Reply concisely and to the point. - - Reply in plain text. Do not use markdown. + - You can use inline markdown. - Your user ID is ${AI_USER_INFO.id}.`, messages, }); @@ -336,7 +336,7 @@ export async function handleAiCommentReply(data: { Complete! You now have an AI agent capable of replying to mentions in comment threads. - When it’s mentioned in a acomment, it’ll leave a placeholder comment, and + When it’s mentioned in a comment, it’ll leave a placeholder comment, and edit it after generating a response. diff --git a/packages/liveblocks-chat-sdk-adapter/package.json b/packages/liveblocks-chat-sdk-adapter/package.json index fa51b8b3a2a..cb72574a41b 100644 --- a/packages/liveblocks-chat-sdk-adapter/package.json +++ b/packages/liveblocks-chat-sdk-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/chat-sdk-adapter", - "version": "3.19.2", + "version": "3.19.3", "description": "Liveblocks adapter for the Chat SDK.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-client/package.json b/packages/liveblocks-client/package.json index e98bf299088..25af1249f1c 100644 --- a/packages/liveblocks-client/package.json +++ b/packages/liveblocks-client/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/client", - "version": "3.19.2", + "version": "3.19.3", "description": "A client that lets you interact with Liveblocks servers. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-core/package.json b/packages/liveblocks-core/package.json index 4d58c7601eb..7420d3745a6 100644 --- a/packages/liveblocks-core/package.json +++ b/packages/liveblocks-core/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/core", - "version": "3.19.2", + "version": "3.19.3", "description": "Private internals for Liveblocks. DO NOT import directly from this package!", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/liveblocks-core/src/connection.ts b/packages/liveblocks-core/src/connection.ts index a375f8e29f2..82ad4b33bed 100644 --- a/packages/liveblocks-core/src/connection.ts +++ b/packages/liveblocks-core/src/connection.ts @@ -4,7 +4,7 @@ import type { Observable } from "./lib/EventSource"; import { makeBufferableEventSource, makeEventSource } from "./lib/EventSource"; import * as console from "./lib/fancy-console"; import type { BuiltinEvent, Patchable, Target } from "./lib/fsm"; -import { FSM } from "./lib/fsm"; +import { FSM, IGNORE } from "./lib/fsm"; import type { Json } from "./lib/Json"; import { tryParseJson, withTimeout } from "./lib/utils"; import { ServerMsgCode } from "./protocol/ServerMsg"; @@ -98,7 +98,7 @@ type Event = | { type: "NAVIGATOR_OFFLINE" } // e.g. browser goes offline // Events that the connection manager will internally deal with - | { type: "PONG" } + | { type: "ALIVE" } // Previously called "PONG", but widened to include any socket activity | { type: "EXPLICIT_SOCKET_ERROR"; event: IWebSocketEvent } | { type: "EXPLICIT_SOCKET_CLOSE"; event: IWebSocketCloseEvent } @@ -171,8 +171,9 @@ const BACKOFF_DELAYS_SLOW = [2_000, 30_000, 60_000, 300_000] as const; /** * The client will send a PING to the server every 30 seconds, after which it - * must receive a PONG back within the next 2 seconds. If that doesn't happen, - * this is interpreted as an implicit connection loss event. + * must receive a PONG (or any other sign of activity) back within the next + * 2 seconds. If nothing arrives in that window, the connection is treated as + * implicitly lost. */ const HEARTBEAT_INTERVAL = 30_000; const PONG_TIMEOUT = 2_000; @@ -302,8 +303,8 @@ function enableTracing(machine: FSM) { machine.events.didExitState.subscribe(({ state, durationMs }) => log(`Exited ${state} after ${durationMs.toFixed(0)}ms`) ), - machine.events.didIgnoreEvent.subscribe((e) => - log("Ignored event", e.type, e, "(current state won't handle it)") + machine.events.didIgnoreUnexpectedEvent.subscribe((e) => + log("Ignored unexpected event", e.type, e, "(no transition declared)") ), ]; return () => { @@ -503,10 +504,14 @@ function createConnectionStateMachine( const onSocketClose = (event: IWebSocketCloseEvent) => machine.send({ type: "EXPLICIT_SOCKET_CLOSE", event }); - const onSocketMessage = (event: IWebSocketMessageEvent) => - event.data === "pong" - ? machine.send({ type: "PONG" }) - : onMessage.notify(event); + const onSocketMessage = (event: IWebSocketMessageEvent) => { + // Every inbound message counts as activity, not just explicit PONGs + machine.send({ type: "ALIVE" }); + + if (event.data !== "pong") { + onMessage.notify(event); + } + }; function teardownSocket(socket: IWebSocketInstance | null) { if (socket) { @@ -764,18 +769,25 @@ function createConnectionStateMachine( effect: [increaseBackoffDelay, logPrematureErrorOrCloseEvent(err)], }; } - ); + ) + .addTransitions("@connecting.busy", { + // The socket message listener is attached during @connecting.busy (see + // onEnterAsync above), so server frames (most notably the actor-id + // handshake) can fire onSocketMessage and emit a ALIVE before we + // reach @ok.*. That's fine. Heartbeat only matters in @ok.*. + ALIVE: IGNORE, + }); // // Configure the @ok.* states // // Keeps a heartbeat alive with the server whenever in the @ok.* state group. // 30 seconds after entering the "@ok.connected" state, it will emit - // a heartbeat, and awaits a PONG back that should arrive within 2 seconds. - // If this happens, then it transitions back to normal "connected" state, and - // the cycle repeats. If the PONG is not received timely, then we interpret - // it as an implicit connection loss, and transition to reconnect (throw away - // this socket, and open a new one). + // a heartbeat, and awaits a PONG (or any other sign of activity) back that + // should arrive within 2 seconds. If this happens, it transitions back to + // "@ok.connected" and the cycle repeats. If nothing arrives in time, we + // interpret it as an implicit connection loss and transition to reconnect + // (throw away this socket, and open a new one). // const sendHeartbeat: Target = { @@ -799,6 +811,7 @@ function createConnectionStateMachine( .addTransitions("@ok.connected", { NAVIGATOR_OFFLINE: maybeHeartbeat, // Don't take the browser's word for it when it says it's offline. Do a ping/pong to make sure. WINDOW_GOT_FOCUS: sendHeartbeat, + ALIVE: IGNORE, }); machine.addTransitions("@idle.zombie", { @@ -827,7 +840,7 @@ function createConnectionStateMachine( }; }) - .addTransitions("@ok.awaiting-pong", { PONG: "@ok.connected" }) + .addTransitions("@ok.awaiting-pong", { ALIVE: "@ok.connected" }) .addTimedTransition("@ok.awaiting-pong", PONG_TIMEOUT, { target: "@connecting.busy", // Log implicit connection loss and drop the current open socket @@ -844,7 +857,7 @@ function createConnectionStateMachine( EXPLICIT_SOCKET_ERROR: (_, context) => { if (context.socket?.readyState === 1 /* WebSocket.OPEN */) { // TODO Do we need to forward this error to the client? - return null; /* Do not leave OK state, socket is still usable */ + return IGNORE; /* Do not leave OK state, socket is still usable */ } return { diff --git a/packages/liveblocks-core/src/lib/__tests__/fsm.test.ts b/packages/liveblocks-core/src/lib/__tests__/fsm.test.ts index 5b94a4a9314..758e5ebcd1d 100644 --- a/packages/liveblocks-core/src/lib/__tests__/fsm.test.ts +++ b/packages/liveblocks-core/src/lib/__tests__/fsm.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from "vitest"; -import { distance, FSM, patterns } from "../fsm"; +import { distance, FSM, IGNORE, patterns } from "../fsm"; import { wait } from "../utils"; async function failAfter(ms: number): Promise { @@ -555,7 +555,7 @@ describe("finite state machine", () => { GO: () => n++ % 2 === 0 ? "one" // Transition if n is even - : null, // Otherwise, do nothing + : IGNORE, // Otherwise, do nothing }) .start(); @@ -572,6 +572,136 @@ describe("finite state machine", () => { expect(fsm.currentState).toEqual("one"); }); + describe("IGNORE sentinel", () => { + test("static IGNORE keeps state unchanged", () => { + const fsm = new FSM({}) + .addState("foo") + .addTransitions("foo", { GO: IGNORE }) + .start(); + + expect(fsm.currentState).toEqual("foo"); + fsm.send({ type: "GO" }); + expect(fsm.currentState).toEqual("foo"); + }); + + test("static IGNORE fires no observable notifications", () => { + const didReceive = vi.fn(); + const willTransition = vi.fn(); + const didIgnoreUnexpected = vi.fn(); + + const fsm = new FSM({}) + .addState("foo") + .addTransitions("foo", { GO: IGNORE }) + .start(); + + fsm.events.didReceiveEvent.subscribe(didReceive); + fsm.events.willTransition.subscribe(willTransition); + fsm.events.didIgnoreUnexpectedEvent.subscribe(didIgnoreUnexpected); + + fsm.send({ type: "GO" }); + + expect(didReceive).not.toHaveBeenCalled(); + expect(willTransition).not.toHaveBeenCalled(); + expect(didIgnoreUnexpected).not.toHaveBeenCalled(); + }); + + test("missing transition still fires didIgnoreUnexpectedEvent (control)", () => { + const didIgnoreUnexpected = vi.fn(); + + // Declare GO somewhere so the FSM accepts it as a known event type, + // but not in state "foo" — so sending GO in "foo" goes unhandled. + const fsm = new FSM({}) + .addState("foo") + .addState("bar") + .addTransitions("bar", { GO: "foo" }) + .start(); + + fsm.events.didIgnoreUnexpectedEvent.subscribe(didIgnoreUnexpected); + + expect(fsm.currentState).toEqual("foo"); + fsm.send({ type: "GO" }); + expect(didIgnoreUnexpected).toHaveBeenCalledTimes(1); + }); + + test("dynamic IGNORE (function returning IGNORE) is silent except for didReceiveEvent", () => { + const didReceive = vi.fn(); + const willTransition = vi.fn(); + const didIgnoreUnexpected = vi.fn(); + + const fsm = new FSM({}) + .addState("foo") + .addTransitions("foo", { GO: () => IGNORE }) + .start(); + + fsm.events.didReceiveEvent.subscribe(didReceive); + fsm.events.willTransition.subscribe(willTransition); + fsm.events.didIgnoreUnexpectedEvent.subscribe(didIgnoreUnexpected); + + fsm.send({ type: "GO" }); + + // We had to invoke the function to learn it returns IGNORE. + expect(didReceive).toHaveBeenCalledTimes(1); + // But no transition happened, and the event is not "unexpected". + expect(willTransition).not.toHaveBeenCalled(); + expect(didIgnoreUnexpected).not.toHaveBeenCalled(); + expect(fsm.currentState).toEqual("foo"); + }); + + test("mixing IGNORE with real transitions in the same mapping", () => { + const fsm = new FSM({}) + .addState("foo") + .addState("bar") + .addTransitions("foo", { GO: "bar", PING: IGNORE }) + .start(); + + fsm.send({ type: "PING" }); + expect(fsm.currentState).toEqual("foo"); // silent no-op + + fsm.send({ type: "GO" }); + expect(fsm.currentState).toEqual("bar"); // normal transition + }); + + test("IGNORE works via wildcard pattern for a state group", () => { + const didIgnoreUnexpected = vi.fn(); + + const fsm = new FSM({}) + .addState("group.one") + .addState("group.two") + .addState("other") + .addTransitions("group.*", { PING: IGNORE, GO: "other" }) + .start(); + + fsm.events.didIgnoreUnexpectedEvent.subscribe(didIgnoreUnexpected); + + expect(fsm.currentState).toEqual("group.one"); + fsm.send({ type: "PING" }); + expect(fsm.currentState).toEqual("group.one"); + expect(didIgnoreUnexpected).not.toHaveBeenCalled(); + + // Switch to group.two by adding a transition path; reuse "GO". + // To exercise PING in group.two we need to be there first. + // Build a separate FSM for that leg to keep this test focused. + const fsm2 = new FSM({}) + .addState("group.two") + .addTransitions("group.*", { PING: IGNORE }) + .start(); + const didIgnoreUnexpected2 = vi.fn(); + fsm2.events.didIgnoreUnexpectedEvent.subscribe(didIgnoreUnexpected2); + fsm2.send({ type: "PING" }); + expect(fsm2.currentState).toEqual("group.two"); + expect(didIgnoreUnexpected2).not.toHaveBeenCalled(); + }); + + test("declaring IGNORE for an already-declared event still throws", () => { + expect(() => + new FSM({}) + .addState("foo") + .addTransitions("foo", { GO: "foo" }) + .addTransitions("foo", { GO: IGNORE }) + ).toThrow(/transition already exists/); + }); + }); + describe("time-based transitions", () => { test("time-based transitions", () => { vi.useFakeTimers(); diff --git a/packages/liveblocks-core/src/lib/fsm.ts b/packages/liveblocks-core/src/lib/fsm.ts index 2f4d19de7db..18bf79e667a 100644 --- a/packages/liveblocks-core/src/lib/fsm.ts +++ b/packages/liveblocks-core/src/lib/fsm.ts @@ -28,6 +28,19 @@ export type AsyncErrorEvent = { export type BaseEvent = { readonly type: string }; export type BuiltinEvent = TimerEvent | AsyncOKEvent | AsyncErrorEvent; +/** + * Sentinel target value declaring "this event is intentionally a silent + * no-op in this state". Use as a static mapping value + * (`{ EVENT: IGNORE }`) or as a return value from a target function. + * + * Unlike a missing transition (which fires `didIgnoreUnexpectedEvent`), + * an IGNORE'd event is fully silent: no state change, no effects, and + * no observable notifications. The static form is also cheap — the + * dispatcher short-circuits before invoking any transition machinery. + */ +export const IGNORE: unique symbol = Symbol("fsm.ignore"); +export type IGNORE = typeof IGNORE; + export type Patchable = Readonly & { patch(patch: Partial): void; }; @@ -44,7 +57,7 @@ export type TargetFn< > = ( event: TEvent, context: Readonly -) => TState | TargetObject | null; +) => TState | TargetObject | IGNORE; export type Effect = ( context: Patchable, @@ -209,13 +222,13 @@ export class FSM< #allowedTransitions: Map< TState, - Map> + Map | IGNORE> >; readonly #eventHub: { readonly didReceiveEvent: EventSource; readonly willTransition: EventSource<{ from: TState; to: TState }>; - readonly didIgnoreEvent: EventSource; + readonly didIgnoreUnexpectedEvent: EventSource; readonly willExitState: EventSource; readonly didEnterState: EventSource; readonly didExitState: EventSource<{ @@ -227,7 +240,13 @@ export class FSM< public readonly events: { readonly didReceiveEvent: Observable; readonly willTransition: Observable<{ from: TState; to: TState }>; - readonly didIgnoreEvent: Observable; + /** + * Fires when an event is sent to a state that has no transition + * declared for it. Use this to surface programmer-error or unexpected + * runtime conditions. Events deliberately declared as `IGNORE` (either + * statically or via a `TargetFn` returning `IGNORE`) are silent here. + */ + readonly didIgnoreUnexpectedEvent: Observable; readonly willExitState: Observable; readonly didEnterState: Observable; readonly didExitState: Observable<{ @@ -334,7 +353,7 @@ export class FSM< this.#eventHub = { didReceiveEvent: makeEventSource(), willTransition: makeEventSource(), - didIgnoreEvent: makeEventSource(), + didIgnoreUnexpectedEvent: makeEventSource(), willExitState: makeEventSource(), didEnterState: makeEventSource(), didExitState: makeEventSource(), @@ -342,7 +361,8 @@ export class FSM< this.events = { didReceiveEvent: this.#eventHub.didReceiveEvent.observable, willTransition: this.#eventHub.willTransition.observable, - didIgnoreEvent: this.#eventHub.didIgnoreEvent.observable, + didIgnoreUnexpectedEvent: + this.#eventHub.didIgnoreUnexpectedEvent.observable, willExitState: this.#eventHub.willExitState.observable, didEnterState: this.#eventHub.didEnterState.observable, didExitState: this.#eventHub.didExitState.observable, @@ -493,14 +513,19 @@ export class FSM< * `context` params to conditionally decide which next state to transition * to. * - * If you set it to `null`, then the transition will be explicitly forbidden - * and throw an error. If you don't define a target for a transition, then - * such events will get ignored. + * If you don't define a target for a transition, the event is treated + * as unhandled in this state: `didIgnoreUnexpectedEvent` fires and the + * state does not change. + * + * To declare an event as an intentional silent no-op in this state, use + * the {@link IGNORE} sentinel — either statically (`{ EVENT: IGNORE }`) + * or as a return value from a target function. IGNORE'd events do not + * fire `didIgnoreUnexpectedEvent`. */ public addTransitions( nameOrPattern: TState | Wildcard, mapping: { - [E in TEvent as E["type"]]?: Target | null; + [E in TEvent as E["type"]]?: Target | IGNORE; } ): this { if (this.#runningState !== RunningState.NOT_STARTED_YET) { @@ -523,11 +548,19 @@ export class FSM< const target = target_ as | Target - | null + | IGNORE | undefined; this.#knownEventTypes.add(type); - if (target !== undefined) { + if (target === undefined) { + continue; + } + + if (target === IGNORE) { + // Store the sentinel as-is so the dispatcher can short-circuit + // before any transition machinery runs. + map.set(type, IGNORE); + } else { const targetFn = typeof target === "function" ? target : () => target; map.set(type, targetFn); } @@ -568,7 +601,7 @@ export class FSM< #getTargetFn( eventName: TEvent["type"] - ): TargetFn | undefined { + ): TargetFn | IGNORE | undefined { return this.#allowedTransitions.get(this.currentState)?.get(eventName); } @@ -670,13 +703,16 @@ export class FSM< return; } - const targetFn = this.#getTargetFn(event.type); - if (targetFn !== undefined) { - return this.#transition(event, targetFn); - } else { - // Ignore the event otherwise - this.#eventHub.didIgnoreEvent.notify(event); + const entry = this.#getTargetFn(event.type); + if (entry === IGNORE) { + // Explicit silent no-op: short-circuit before any notification fires. + return; + } + if (entry !== undefined) { + return this.#transition(event, entry); } + // No transition declared for this event in this state — surface it. + this.#eventHub.didIgnoreUnexpectedEvent.notify(event); } #transition( @@ -691,9 +727,10 @@ export class FSM< const nextTarget = targetFn(event, this.#currentContext.current); let nextState: TState; let effects: Effect[] | undefined = undefined; - if (nextTarget === null) { - // Do not transition - this.#eventHub.didIgnoreEvent.notify(event); + if (nextTarget === IGNORE) { + // Target function chose to ignore this event in this state — silent + // no-op. `didReceiveEvent` already fired above, since we had to + // invoke the function to find out. return; } diff --git a/packages/liveblocks-emails/package.json b/packages/liveblocks-emails/package.json index 2185fe8d04e..784752e0812 100644 --- a/packages/liveblocks-emails/package.json +++ b/packages/liveblocks-emails/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/emails", - "version": "3.19.2", + "version": "3.19.3", "description": "A set of functions and utilities to make sending emails based on Liveblocks notification events easy. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-node-lexical/package.json b/packages/liveblocks-node-lexical/package.json index 336a680031d..bf11452e6b5 100644 --- a/packages/liveblocks-node-lexical/package.json +++ b/packages/liveblocks-node-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node-lexical", - "version": "3.19.2", + "version": "3.19.3", "description": "A server-side utility that lets you modify lexical documents hosted in Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-node-prosemirror/package.json b/packages/liveblocks-node-prosemirror/package.json index 581236082c5..d82debea806 100644 --- a/packages/liveblocks-node-prosemirror/package.json +++ b/packages/liveblocks-node-prosemirror/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node-prosemirror", - "version": "3.19.2", + "version": "3.19.3", "description": "A server-side utility that lets you modify prosemirror and tiptap documents hosted in Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-node/package.json b/packages/liveblocks-node/package.json index 68097dc0942..675ad720619 100644 --- a/packages/liveblocks-node/package.json +++ b/packages/liveblocks-node/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node", - "version": "3.19.2", + "version": "3.19.3", "description": "A server-side utility that lets you set up a Liveblocks authentication endpoint. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-blocknote/package.json b/packages/liveblocks-react-blocknote/package.json index 0b81e81d598..1ec985569a4 100644 --- a/packages/liveblocks-react-blocknote/package.json +++ b/packages/liveblocks-react-blocknote/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-blocknote", - "version": "3.19.2", + "version": "3.19.3", "description": "An integration of BlockNote + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-flow/package.json b/packages/liveblocks-react-flow/package.json index db615484ecc..2053252c1b9 100644 --- a/packages/liveblocks-react-flow/package.json +++ b/packages/liveblocks-react-flow/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-flow", - "version": "3.19.2", + "version": "3.19.3", "description": "An integration of React Flow to enable collaboration and realtime cursors with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-lexical/package.json b/packages/liveblocks-react-lexical/package.json index 22190cbf55b..cea83905ea7 100644 --- a/packages/liveblocks-react-lexical/package.json +++ b/packages/liveblocks-react-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-lexical", - "version": "3.19.2", + "version": "3.19.3", "description": "An integration of Lexical + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-tiptap/package.json b/packages/liveblocks-react-tiptap/package.json index 6060b3aca6f..3c19dabc7bd 100644 --- a/packages/liveblocks-react-tiptap/package.json +++ b/packages/liveblocks-react-tiptap/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-tiptap", - "version": "3.19.2", + "version": "3.19.3", "description": "An integration of TipTap + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react-ui/package.json b/packages/liveblocks-react-ui/package.json index 01d3a1f27ec..745d7cce0a1 100644 --- a/packages/liveblocks-react-ui/package.json +++ b/packages/liveblocks-react-ui/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-ui", - "version": "3.19.2", + "version": "3.19.3", "description": "A set of React pre-built components for the Liveblocks products. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-react/package.json b/packages/liveblocks-react/package.json index 25308842a4a..470b21f0652 100644 --- a/packages/liveblocks-react/package.json +++ b/packages/liveblocks-react/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react", - "version": "3.19.2", + "version": "3.19.3", "description": "A set of React hooks and providers to use Liveblocks declaratively. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-redux/package.json b/packages/liveblocks-redux/package.json index 59dcd9b31d8..17fba93f580 100644 --- a/packages/liveblocks-redux/package.json +++ b/packages/liveblocks-redux/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/redux", - "version": "3.19.2", + "version": "3.19.3", "description": "A store enhancer to integrate Liveblocks into Redux stores. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-server/package.json b/packages/liveblocks-server/package.json index ada1a940b24..5ce6ede9a48 100644 --- a/packages/liveblocks-server/package.json +++ b/packages/liveblocks-server/package.json @@ -72,7 +72,7 @@ "@liveblocks/core": "3.18.0", "async-mutex": "^0.4.0", "decoders": "^2.9.0", - "itertools": "^2.3.2", + "itertools": "^2.7.1", "js-base64": "^3.7.5", "nanoid": "^3", "yjs": "^13.6.10" diff --git a/packages/liveblocks-server/src/Room.ts b/packages/liveblocks-server/src/Room.ts index 22e6df3adc5..15e8dc8e476 100644 --- a/packages/liveblocks-server/src/Room.ts +++ b/packages/liveblocks-server/src/Room.ts @@ -18,6 +18,7 @@ import type { BaseUserMeta, Brand, + CompactNode, IUserInfo, Json, JsonObject, @@ -25,7 +26,6 @@ import type { import { assertNever, ClientMsgCode, - nodeStreamToCompactNodes, OpCode, raise, ServerMsgCode as CoreServerMsgCode, @@ -34,7 +34,7 @@ import { } from "@liveblocks/core"; import { Mutex, tryAcquire } from "async-mutex"; import { array, formatInline } from "decoders"; -import { chunked } from "itertools"; +import { chunkedByCost } from "itertools"; import { nanoid } from "nanoid"; import type { Guid } from "~/decoders"; @@ -74,9 +74,11 @@ import { UniqueMap } from "./lib/UniqueMap"; import { feedFailureServerMsg, feedRequestFailed } from "./protocol/feedErrors"; import { FeedMsgCode, FeedRequestErrorCode } from "./protocol/feedMessages"; import { ProtocolVersion } from "./protocol/ProtocolVersion"; -import type { Feed, FeedMessage, LeasedSession } from "./types"; +import type { Feed, FeedMessage, jstring, LeasedSession } from "./types"; import { isLeasedSessionExpired, makeRoomStateMsg } from "./utils"; +const MB = 1024 * 1024; + const messagesDecoder = array(clientMsgDecoder); // Temporary patch @@ -96,6 +98,29 @@ const BLACK_HOLE = new Logger([ /* No targets, i.e. black hole logger */ ]); +// Stream pre-built CompactNode tuple strings into raw STORAGE_CHUNK frames +// (no parse/re-stringify of jdata). Each node is its own atomic unit for +// chunkedByCost, so a single oversized row (≤ 2 MB CF limit) is the worst +// case any output frame can exceed MAX_SIZE by. +function groupNodesForWebSocketMessages( + input: Iterable> +): Iterable[]> { + // Cap each outgoing WebSocket message at 16 MB. CF's 32 MB message size + // limit only applies inbound, so the real constraint here is memory + // pressure: the worker has 128 MB total, and stringifying a 16 MB message + // costs roughly that much again in transient allocations. + const MAX_SIZE = 16 * MB; + + // Chunk to form ideal message sizes, per chunk: + // chunks 0..2 → 1 MB each (fast start) + // chunks 3..9 → 2, 3, 4, 5, 6, 7, 8 MB (slowly ramp up) + // chunks 10+ → 8 MB (steady state) + const idealSize = (chunkIndex: number): number => + chunkIndex < 3 ? 1 * MB : Math.min(8, chunkIndex - 1) * MB; + + return chunkedByCost(input, (tup) => tup.length, MAX_SIZE, idealSize); +} + export type LoadingState = "initial" | "loading" | "loaded"; export type ActorID = Brand; /** Number of milliseconds since Unix epoch. */ @@ -109,7 +134,6 @@ export type Millis = Brand; */ export type SessionKey = Brand; -export type PreSerializedServerMsg = Brand; type ClientMsg = | GenericClientMsg | FetchFeedsClientMsg @@ -138,10 +162,8 @@ function collectSideEffects() { }; } -function serialize( - msgs: ServerMsg | readonly ServerMsg[] -): PreSerializedServerMsg { - return JSON.stringify(msgs) as PreSerializedServerMsg; +function serialize(msgs: ServerMsg | readonly ServerMsg[]): jstring { + return JSON.stringify(msgs) as jstring; } export function ackIgnoredOp(opId: string): IgnoredOp { @@ -297,7 +319,7 @@ export class BrowserSession { return sent; } - send(serverMsg: ServerMsg | ServerMsg[] | PreSerializedServerMsg): number { + send(serverMsg: ServerMsg | ServerMsg[] | jstring): number { const data = typeof serverMsg === "string" ? serverMsg : serialize(serverMsg); const sent = this.#_socket.send(data); @@ -391,18 +413,6 @@ type RoomOptions = { storage?: IStorageDriver; logger?: Logger; - /** - * Whether to allow streaming storage responses. Only safe with drivers - * that can guarantee that no Ops from other clients can get interleaved - * between the chunk generation until the last chunk has been sent. - * Defaults to true, but is notably NOT safe to use from DOS-KV backends. - * - * @deprecated Only existed to support the DOS-KV backend, which is gone. - * All remaining drivers are streaming-safe; this flag should be removed - * and the streaming path made unconditional. - */ - allowStreaming?: boolean; - // YYY Restructure these hooks to all take a single `event` param hooks?: { /** Customize which incoming messages from a client are allowed or disallowed. */ @@ -526,14 +536,12 @@ export class Room { }; readonly #_debug: boolean; - readonly #_allowStreaming: boolean; constructor(meta: RM, options?: RoomOptions) { const driver = options?.storage ?? makeNewInMemoryDriver(); this.meta = meta; this.driver = driver; this.logger = options?.logger ?? BLACK_HOLE; - this.#_allowStreaming = options?.allowStreaming ?? true; this.hooks = { isClientMsgAllowed: options?.hooks?.isClientMsgAllowed ?? @@ -673,14 +681,14 @@ export class Room { } public async createBackendSession_experimental(): Promise< - [session: BackendSession, outgoingMessages: PreSerializedServerMsg[]] + [session: BackendSession, outgoingMessages: jstring[]] > { const ticket = (await this.createTicket()) as Ticket; - const capturedServerMsgs: PreSerializedServerMsg[] = []; + const capturedServerMsgs: jstring[] = []; const stub = { send: (data) => { if (typeof data === "string") { - capturedServerMsgs.push(data as PreSerializedServerMsg); + capturedServerMsgs.push(data as jstring); } return 0; }, @@ -1508,8 +1516,9 @@ export class Room { // - Messages to reply back to the current sender (i.e. acks and rejections) const toFanOut: ServerMsg[] = []; const toReply: ServerMsg[] = []; - const replyImmediately = (msg: ServerMsg | ServerMsg[]) => - void session.send(msg); + const replyImmediately = ( + msg: ServerMsg | ServerMsg[] | jstring + ) => void session.send(msg); const scheduleFanOut = (msg: ServerMsg) => void toFanOut.push(msg); const scheduleReply = (msg: ServerMsg) => void toReply.push(msg); @@ -1565,8 +1574,18 @@ export class Room { const toReplyImmediately: ServerMsg[] = []; const toReplyAfter: ServerMsg[] = []; - const replyImmediately = (msg: ServerMsg | ServerMsg[]) => { - if (Array.isArray(msg)) { + const replyImmediately = ( + msg: ServerMsg | ServerMsg[] | jstring + ) => { + if (typeof msg === "string") { + // Pre-serialized payload: flush any pending typed messages first so + // wire order matches call order, then send the raw string directly. + if (toReplyImmediately.length > 0) { + session.send(toReplyImmediately); + toReplyImmediately.length = 0; + } + session.send(msg); + } else if (Array.isArray(msg)) { for (const m of msg) { toReplyImmediately.push(m); } @@ -1608,7 +1627,9 @@ export class Room { private async handleOne( session: BrowserSession, msg: ClientMsg, - replyImmediately: (msg: ServerMsg | ServerMsg[]) => void, + replyImmediately: ( + msg: ServerMsg | ServerMsg[] | jstring + ) => void, scheduleFanOut: (msg: ServerMsg) => void, scheduleReply: (msg: ServerMsg) => void, ctx: C | undefined, @@ -1642,32 +1663,21 @@ export class Room { case ClientMsgCode.FETCH_STORAGE: { if (session.version >= ProtocolVersion.V8) { - if (this.#_allowStreaming) { - const NODES_PER_CHUNK = 250; // = arbitrary! Could be tuned later - - for (const chunk of chunked( - nodeStreamToCompactNodes(this.storage.loadedDriver.iter_nodes()), - NODES_PER_CHUNK - )) { - // NOTE: We don't take a storage snapshot here, because this - // iteration is happening synchronously, so consistency of the - // current document automatically guaranteed. If we ever make - // this streaming asynchronous, however, we need to take - // a storage snapshot to guarantee document consistency. - replyImmediately({ - type: ServerMsgCode.STORAGE_CHUNK, - nodes: chunk, - }); - } - } else { - replyImmediately({ - type: ServerMsgCode.STORAGE_CHUNK, - nodes: Array.from( - nodeStreamToCompactNodes(this.storage.loadedDriver.iter_nodes()) - ), - }); + // + // Turn storage nodes into WebSocket messages. They get chunked so + // that each individual WebSocket message won't be too big, but the + // total number of WebSocket messages needed is kept in check. + // + // NOTE: We use iter_nodes_optimized() here to avoid the JS overhead + // of parsing, mutating, serializing, and measuring each individual + // node, which can add up in large documents. + // + const rawStream = this.storage.loadedDriver.iter_nodes_optimized(); + for (const chunk of groupNodesForWebSocketMessages(rawStream)) { + const frame = + `{"type":${ServerMsgCode.STORAGE_CHUNK},"nodes":[${chunk.join(",")}]}` as jstring; + replyImmediately(frame); } - replyImmediately({ type: ServerMsgCode.STORAGE_STREAM_END }); } else { replyImmediately({ diff --git a/packages/liveblocks-server/src/decoders/feedMetadata.ts b/packages/liveblocks-server/src/decoders/feedMetadata.ts index 09d87e6f78e..94a4fb96f90 100644 --- a/packages/liveblocks-server/src/decoders/feedMetadata.ts +++ b/packages/liveblocks-server/src/decoders/feedMetadata.ts @@ -74,7 +74,9 @@ const feedMetadataRecordForCreate = record( * Optional feed metadata on create (WebSocket ADD_FEED, HTTP POST …/feeds). * Same rules as createMetadataDecoder / room metadata on create (no null values). */ -export const optionalFeedMetadataDecoder = optional(feedMetadataRecordForCreate); +export const optionalFeedMetadataDecoder = optional( + feedMetadataRecordForCreate +); /** * Full metadata object for update (WebSocket UPDATE_FEED, HTTP PATCH …/feeds/:id). diff --git a/packages/liveblocks-server/src/index.ts b/packages/liveblocks-server/src/index.ts index dc7be8d4a17..95d74512d81 100644 --- a/packages/liveblocks-server/src/index.ts +++ b/packages/liveblocks-server/src/index.ts @@ -37,6 +37,7 @@ export * from "~/Room"; export type { Feed, FeedMessage, + jstring, LeasedSession, NodeMap, NodeStream, diff --git a/packages/liveblocks-server/src/interfaces/IStorageDriver.ts b/packages/liveblocks-server/src/interfaces/IStorageDriver.ts index 8c3358228be..2440c651aa4 100644 --- a/packages/liveblocks-server/src/interfaces/IStorageDriver.ts +++ b/packages/liveblocks-server/src/interfaces/IStorageDriver.ts @@ -17,6 +17,7 @@ import type { Awaitable, + CompactNode, Json, JsonObject, PlainLsonObject, @@ -28,7 +29,7 @@ import type { import type { YDocId } from "~/decoders/y-types"; import type { Logger } from "~/lib/Logger"; -import type { Feed, FeedMessage, LeasedSession, Pos } from "~/types"; +import type { Feed, FeedMessage, jstring, LeasedSession, Pos } from "~/types"; /** * Options for listing feeds with pagination and filtering. @@ -127,6 +128,28 @@ export interface IStorageDriverNodeAPI { */ iter_nodes(): Iterable; + /** + * Yield each node as a pre-built `CompactNode` JSON tuple string, ready to + * be emitted directly into a STORAGE_CHUNK wire frame without JSON.parse() + * or JSON.stringify()'ing overhead. Implementations MUST produce text whose + * parsed shape exactly matches the `CompactNode` union type from + * @liveblocks/core. The emitted shapes are: + * + * - Root node: '["root",]' + * - OBJECT / REGISTER: '["0:1",0,"root","a",]' + * - LIST / MAP: '["0:2",1,"0:1","b"]' + * + * Invariant (implementations MUST uphold; asserted by `_generateFullTestSuite`): + * + * iter_nodes_optimized().map(JSON.parse) + * ≡ nodeStreamToCompactNodes(iter_nodes()) + * + * i.e. parsing each yielded string yields the same sequence of CompactNodes + * that the canonical `iter_nodes()` + `nodeStreamToCompactNodes()` path + * would produce. + */ + iter_nodes_optimized(): Iterable>; + /** * Return true iff a node with the given id exists. Must return true for "root". */ diff --git a/packages/liveblocks-server/src/plugins/InMemoryDriver.ts b/packages/liveblocks-server/src/plugins/InMemoryDriver.ts index 83d623e7ac9..b407e7a2330 100644 --- a/packages/liveblocks-server/src/plugins/InMemoryDriver.ts +++ b/packages/liveblocks-server/src/plugins/InMemoryDriver.ts @@ -18,6 +18,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/require-await */ import type { + CompactNode, Json, JsonObject, NodeMap, @@ -28,7 +29,13 @@ import type { SerializedObject, SerializedRootObject, } from "@liveblocks/core"; -import { asPos, CrdtType, isRootStorageNode, nn } from "@liveblocks/core"; +import { + asPos, + CrdtType, + isRootStorageNode, + nn, + nodeStreamToCompactNodes, +} from "@liveblocks/core"; import { ifilter, imap } from "itertools"; import type { YDocId } from "~/decoders/y-types"; @@ -46,7 +53,7 @@ import type { import { NestedMap } from "~/lib/NestedMap"; import { quote } from "~/lib/text"; import { makeInMemorySnapshot } from "~/makeInMemorySnapshot"; -import type { Feed, FeedMessage, Pos } from "~/types"; +import type { Feed, FeedMessage, jstring, Pos } from "~/types"; function buildRevNodes(nodeStream: NodeStream) { const result = new NestedMap(); @@ -650,6 +657,18 @@ export class InMemoryDriver implements IStorageDriver { */ iter_nodes: () => nodes as NodeStream, + /** + * Yield each node as a pre-built CompactNode JSON tuple string. + * + * This implementation IS the canonical reference for the invariant: + * iter_nodes_optimized` ≡ `nodeStreamToCompactNodes(iter_nodes()) + */ + *iter_nodes_optimized() { + for (const compact of nodeStreamToCompactNodes(nodes as NodeStream)) { + yield JSON.stringify(compact) as jstring; + } + }, + /** * Return true iff a node with the given id exists. Must return true for "root". */ diff --git a/packages/liveblocks-server/src/types.ts b/packages/liveblocks-server/src/types.ts index fe93c8bc1b4..0cd1e2c4962 100644 --- a/packages/liveblocks-server/src/types.ts +++ b/packages/liveblocks-server/src/types.ts @@ -18,6 +18,38 @@ import type { asPos, IUserInfo, Json, SerializedCrdt } from "@liveblocks/core"; export type Pos = ReturnType; + +declare const fromType: unique symbol; + +/** + * A string known to be a valid JSON-encoded value. Use this whenever a + * `string` already carries JSON, to keep that fact visible in the type system + * (instead of letting it look like a freeform `string`). + * + * Optionally parameterised by the *parsed* shape: `jstring` + * means "a string whose `JSON.parse` result is a `CompactNode`". Without + * a type argument, defaults to `jstring` (any JSON value). + * + * At storage boundaries (e.g. reading `jdata` from SQLite), `as jstring` + * is acceptable — we trust the bytes are valid JSON because we wrote them as + * JSON ourselves. + * + * For example, these are valid JSON strings: + * - '0', '1', '2', '3', '3.14', etc. + * - 'null', 'true', 'false' + * - '{"foo":1}', '{ "foo": 1 }' (spaces are fine) + * - '[]', '[1,2, 3]', '[[]]', etc. + * - '["hi",{}]' + * - '"foo"' + * + * But these are not: + * - 'foo' + * - '1,2,3' + * - '{' + * - '[1,2,3,]' or '{"foo":1},' (trailing commas are not valid) + */ +export type jstring = string & { readonly [fromType]: J }; + export type NodeTuple = [ id: string, value: T, diff --git a/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts b/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts index aa4c7366813..61140e2b8b0 100644 --- a/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts +++ b/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts @@ -62,6 +62,7 @@ import { makePosition, nanoid, nn, + nodeStreamToCompactNodes, OpCode, raise, } from "@liveblocks/core"; @@ -723,10 +724,8 @@ export function generateArbitraries() { }, intent: () => - fc.oneof( - { arbitrary: fc.constant(undefined), weight: 10 }, - fc.constant("set" as const) - ), + // ~10:1 bias between undefined vs "set" (towards undefined) + fc.option(fc.constant(undefined), { freq: 11, nil: "set" as const }), opId: () => fc.stringMatching(/^[0-9]+:[0-9]+$/), @@ -2069,6 +2068,37 @@ export function generateFullTestSuite(config: { ) )); + test("iter_nodes_optimized agrees with iter_nodes (cross-driver parity)", () => + runTest(async (driver) => + fc.assert( + fc.asyncProperty( + arb.nodeMap(), + + async (entries) => { + await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); + const db = await driver.load_nodes_api(blackHole); + await write_nodes(db, entries as NodeStream); + + // Parse the wire tuples back into CompactNodes and compare + // against what the canonical nodeStreamToCompactNodes would + // produce from iter_nodes(). Catches any divergence between the + // optimized SQL path and the readable JS path (string encoding, + // escaping, missing fields, type coercion, etc.). + const fromWire = Array.from(db.iter_nodes_optimized()).map( + (t) => JSON.parse(t) as unknown + ); + const fromIter = Array.from( + nodeStreamToCompactNodes(db.iter_nodes()) + ) as unknown[]; + const byId = (a: unknown, b: unknown) => + (a as [string])[0].localeCompare((b as [string])[0]); + + expect(fromWire.sort(byId)).toEqual(fromIter.sort(byId)); + } + ) + ) + )); + test("delete_nodes", () => runTest(async (driver) => fc.assert( diff --git a/packages/liveblocks-yjs/package.json b/packages/liveblocks-yjs/package.json index 1ba427d0029..56cb84ad84e 100644 --- a/packages/liveblocks-yjs/package.json +++ b/packages/liveblocks-yjs/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/yjs", - "version": "3.19.2", + "version": "3.19.3", "description": "Integrate your existing or new Yjs documents with Liveblocks.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/packages/liveblocks-zustand/package.json b/packages/liveblocks-zustand/package.json index d2047f134a2..343ba065f34 100644 --- a/packages/liveblocks-zustand/package.json +++ b/packages/liveblocks-zustand/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/zustand", - "version": "3.19.2", + "version": "3.19.3", "description": "A middleware for Zustand to automatically synchronize your stores with Liveblocks. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "author": "Liveblocks Inc.", diff --git a/tools/liveblocks-cli/package.json b/tools/liveblocks-cli/package.json index 58fc5fff207..cbc0abb59ce 100644 --- a/tools/liveblocks-cli/package.json +++ b/tools/liveblocks-cli/package.json @@ -40,7 +40,7 @@ "eslint-plugin-license-header": "^0.9.0", "eslint-plugin-simple-import-sort": "^13.0.0", "fast-check": "^3.23.2", - "itertools": "^2.3.2", + "itertools": "^2.7.1", "prettier": "^3.3.2", "publint": "^0.3.17", "typescript": "^5.9.3" diff --git a/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts b/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts index 87097a073af..7b088167e7b 100644 --- a/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts +++ b/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts @@ -17,6 +17,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import type { + CompactNode, IUserInfo, Json, JsonObject, @@ -27,13 +28,19 @@ import type { SerializedObject, SerializedRootObject, } from "@liveblocks/core"; -import { asPos, CrdtType, nn } from "@liveblocks/core"; +import { + asPos, + CrdtType, + nn, + nodeStreamToCompactNodes, +} from "@liveblocks/core"; import type { Feed, FeedMessage, IReadableSnapshot, IStorageDriver, IStorageDriverNodeAPI, + jstring, LeasedSession, ListFeedMessagesOptions, ListFeedMessagesResult, @@ -63,17 +70,17 @@ function tryParseJson( type StorageNodesRow = { node_id: string; - crdt_json: string; + crdt_json: jstring; }; type MetadataRow = { key: string; - jval: string; + jval: jstring; }; type RoomInfoRow = { setting: string; - jval: string; + jval: jstring; }; type YdocsRow = { @@ -84,7 +91,7 @@ type YdocsRow = { type FeedRow = { feed_id: string; - jmetadata: string; + jmetadata: jstring; created_at: number; updated_at: number; }; @@ -92,16 +99,16 @@ type FeedRow = { type FeedMessageRow = { feed_id: string; message_id: string; - jdata: string; + jdata: jstring; created_at: number; updated_at: number; }; type LeasedSessionRow = { session_id: string; - jpresence: string; // JSON + jpresence: jstring; updated_at: number; // timestamp in milliseconds - juserinfo: string; // JSON (IUserInfo) + juserinfo: jstring; // IUserInfo ttl: number; // milliseconds actor_id: number; }; @@ -616,6 +623,20 @@ export class BunSQLiteDriver implements IStorageDriver { */ iter_nodes: () => nodes.entries() as NodeStream, + /** + * Yield each node as a pre-built CompactNode JSON tuple string. + * Schema here stores nodes as a single JSON blob per row, so there's + * no SQL-level optimization to apply — wrap iter_nodes() and let the + * core converter produce the tuple shape. + */ + *iter_nodes_optimized() { + for (const compact of nodeStreamToCompactNodes( + nodes.entries() as NodeStream + )) { + yield JSON.stringify(compact) as jstring; + } + }, + /** * Return true iff a node with the given id exists. Must return true for "root". */ diff --git a/tools/liveblocks-cli/test/devserver/rest-api/ydoc.test.ts b/tools/liveblocks-cli/test/devserver/rest-api/ydoc.test.ts index b0aaf8b5fdd..a312c1faede 100644 --- a/tools/liveblocks-cli/test/devserver/rest-api/ydoc.test.ts +++ b/tools/liveblocks-cli/test/devserver/rest-api/ydoc.test.ts @@ -90,9 +90,9 @@ describe("PUT /v2/rooms//ydoc", () => { expect(resp.status).toBe(200); // The connected session should have received an UPDATE_YDOC broadcast - const broadcastMsgs = received.slice(initialMsgCount).map( - (s) => JSON.parse(s) as { type?: number } - ); + const broadcastMsgs = received + .slice(initialMsgCount) + .map((s) => JSON.parse(s) as { type?: number }); const updateYdocCount = broadcastMsgs.filter( (m) => m.type === ServerMsgCode.UPDATE_YDOC ).length; diff --git a/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts b/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts index ef634e821ad..66f5dcba26c 100644 --- a/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts +++ b/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts @@ -62,6 +62,7 @@ import { makePosition, nanoid, nn, + nodeStreamToCompactNodes, OpCode, raise, } from "@liveblocks/core"; @@ -729,10 +730,8 @@ export function generateArbitraries() { }, intent: () => - fc.oneof( - { arbitrary: fc.constant(undefined), weight: 10 }, - fc.constant("set" as const) - ), + // ~10:1 bias between undefined vs "set" (towards undefined) + fc.option(fc.constant(undefined), { freq: 11, nil: "set" as const }), opId: () => fc.stringMatching(/^[0-9]+:[0-9]+$/), @@ -2075,6 +2074,37 @@ export function generateFullTestSuite(config: { ) )); + test("iter_nodes_optimized agrees with iter_nodes (cross-driver parity)", () => + runTest(async (driver) => + fc.assert( + fc.asyncProperty( + arb.nodeMap(), + + async (entries) => { + await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); + const db = await driver.load_nodes_api(blackHole); + await write_nodes(db, entries as NodeStream); + + // Parse the wire tuples back into CompactNodes and compare + // against what the canonical nodeStreamToCompactNodes would + // produce from iter_nodes(). Catches any divergence between the + // optimized SQL path and the readable JS path (string encoding, + // escaping, missing fields, type coercion, etc.). + const fromWire = Array.from(db.iter_nodes_optimized()).map( + (t) => JSON.parse(t) as unknown + ); + const fromIter = Array.from( + nodeStreamToCompactNodes(db.iter_nodes()) + ) as unknown[]; + const byId = (a: unknown, b: unknown) => + (a as [string])[0].localeCompare((b as [string])[0]); + + expect(fromWire.sort(byId)).toEqual(fromIter.sort(byId)); + } + ) + ) + )); + test("delete_nodes", () => runTest(async (driver) => fc.assert(