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(