From 08a43225d285b50cf4ff91ac11d5be68f768d39b Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Thu, 28 May 2026 12:43:04 +0200 Subject: [PATCH 1/2] Server-side fix for concurrent LiveList.push() ordering (PR 1768) Original commit: 02dd4f0da9f4952864a978766bc636dbcc716557 --- packages/liveblocks-server/package.json | 2 +- packages/liveblocks-server/src/Storage.ts | 82 +++++++--- packages/liveblocks-server/src/decoders/Op.ts | 19 ++- .../src/interfaces/IStorageDriver.ts | 7 + .../src/plugins/InMemoryDriver.ts | 18 +++ .../test/plugins/_generateFullTestSuite.ts | 83 +++++++++- .../test/storage/push.test.ts | 118 ++++++++++++++ .../liveblocks-server/test/storage/utils.ts | 147 +----------------- packages/liveblocks-server/vitest.config.ts | 4 +- tools/liveblocks-cli/CHANGELOG.md | 9 ++ tools/liveblocks-cli/README.md | 19 +-- tools/liveblocks-cli/package.json | 4 +- .../src/dev-server/db/BunSQLiteDriver.ts | 18 +++ tools/liveblocks-cli/src/dev-server/index.ts | 38 +++-- .../liveblocks-cli/test/devserver/cmd.test.ts | 15 ++ .../test/plugins/_generateFullTestSuite.ts | 83 +++++++++- 16 files changed, 461 insertions(+), 205 deletions(-) create mode 100644 packages/liveblocks-server/test/storage/push.test.ts diff --git a/packages/liveblocks-server/package.json b/packages/liveblocks-server/package.json index 2902ddc72f1..73b5cd51981 100644 --- a/packages/liveblocks-server/package.json +++ b/packages/liveblocks-server/package.json @@ -69,7 +69,7 @@ }, "sideEffects": false, "dependencies": { - "@liveblocks/core": "3.18.0", + "@liveblocks/core": "3.20.0-pre1", "async-mutex": "^0.4.0", "decoders": "^2.9.0", "itertools": "^2.7.1", diff --git a/packages/liveblocks-server/src/Storage.ts b/packages/liveblocks-server/src/Storage.ts index fb3663decc2..19990c26f03 100644 --- a/packages/liveblocks-server/src/Storage.ts +++ b/packages/liveblocks-server/src/Storage.ts @@ -239,34 +239,26 @@ export class Storage { op: CreateOp & HasOpId, node: SerializedChild ): Promise { - let fix: FixOp | undefined; - // The default intent, when not explicitly provided, is to insert, not set, // into the list. - const intent: "insert" | "set" = op.intent ?? "insert"; + const intent: "insert" | "set" | "push" = op.intent ?? "insert"; // istanbul ignore else if (intent === "insert") { - const insertedParentKey = await this.insertIntoList(op.id, node); - - // If the inserted parent key is different from the input, it means there - // was a conflict and the node has been inserted in an alternative free - // list position. We should broadcast a modified Op to all clients that - // has the modified position, and send a "fix" op back to the originating - // client. - if (insertedParentKey !== node.parentKey) { - op = { ...op, parentKey: insertedParentKey }; - fix = { - type: OpCode.SET_PARENT_KEY, - id: op.id, - parentKey: insertedParentKey, - }; - return accept(op, fix); - } - - // No conflict, node got inserted as intended - return accept(op); + // Insert at the client's preferred position, resolving any collision to a + // nearby free slot. + return this.acceptAndFix( + op, + node, + await this.insertIntoList(op.id, node) + ); + } else if (intent === "push") { + // Server-authoritative append: place the node after the authoritative + // end of the list (see `appendToList`), regardless of the client's preference. + return this.acceptAndFix(op, node, await this.appendToList(op.id, node)); } else if (intent === "set") { + let fix: FixOp | undefined; + // The intent here is to "set", not insert, into the list, replacing the // existing item that @@ -310,6 +302,25 @@ export class Storage { } } + /** + * Accept a freshly placed list item. If the server chose a different + * position in the end (conflict resolution), broadcast only the corrected Op + * to all clients and send a "fix" op back to the originating client. + */ + private acceptAndFix( + op: CreateOp & HasOpId, + node: SerializedChild, + finalKey: string + ): ApplyOpResult { + if (finalKey !== node.parentKey) { + return accept( + { ...op, parentKey: finalKey }, + { type: OpCode.SET_PARENT_KEY, id: op.id, parentKey: finalKey } + ); + } + return accept(op); + } + private async applyDeleteObjectKeyOp( op: DeleteObjectKeyOp & HasOpId ): Promise { @@ -378,6 +389,33 @@ export class Storage { return node.parentKey; } + /** + * Server-authoritative append: places the node strictly after every existing + * sibling under its list parent. If the client's preferred key already sorts + * after the current last sibling it's kept as-is (guaranteed free, since + * it's beyond the max); otherwise the node is placed right after the last + * sibling. Because Ops are processed serially, the chosen key is always + * free, so concurrent pushes never collide. + * + * Returns the final key that was used for the insertion. + */ + private async appendToList( + id: string, + node: SerializedChild + ): Promise { + const lastPos = this.loadedDriver.get_last_sibling(node.parentId); + const preferredPos = asPos(node.parentKey); + const finalKey = + lastPos === undefined || preferredPos > lastPos + ? preferredPos + : makePosition(lastPos); + await this.loadedDriver.set_child( + id, + finalKey !== node.parentKey ? { ...node, parentKey: finalKey } : node + ); + return finalKey; + } + /** * Tries to move a node to the given position under the same parent. If * a conflicting sibling node already exist at this position, it will use diff --git a/packages/liveblocks-server/src/decoders/Op.ts b/packages/liveblocks-server/src/decoders/Op.ts index 2bf5ce5b34a..7cd2e212883 100644 --- a/packages/liveblocks-server/src/decoders/Op.ts +++ b/packages/liveblocks-server/src/decoders/Op.ts @@ -17,7 +17,14 @@ import { OpCode } from "@liveblocks/core"; import type { Decoder } from "decoders"; -import { constant, object, optional, string, taggedUnion } from "decoders"; +import { + constant, + object, + oneOf, + optional, + string, + taggedUnion, +} from "decoders"; import type { ClientWireOp, @@ -35,6 +42,8 @@ import { jsonObjectYolo, jsonYolo } from "./jsonYolo"; type HasOpId = { opId: string }; +const intent = oneOf(["set", "push"] as const); + const updateObjectOp: Decoder = object({ type: constant(OpCode.UPDATE_OBJECT), opId: string, @@ -49,7 +58,7 @@ const createObjectOp: Decoder = object({ parentId: string, parentKey: string, data: jsonObjectYolo, - intent: optional(constant("set")), + intent: optional(intent), deletedId: optional(string), }); @@ -59,7 +68,7 @@ const createListOp: Decoder = object({ id: string, parentId: string, parentKey: string, - intent: optional(constant("set")), + intent: optional(intent), deletedId: optional(string), }); @@ -69,7 +78,7 @@ const createMapOp: Decoder = object({ id: string, parentId: string, parentKey: string, - intent: optional(constant("set")), + intent: optional(intent), deletedId: optional(string), }); @@ -80,7 +89,7 @@ const createRegisterOp: Decoder = object({ parentId: string, parentKey: string, data: jsonYolo, - intent: optional(constant("set")), + intent: optional(intent), deletedId: optional(string), }); diff --git a/packages/liveblocks-server/src/interfaces/IStorageDriver.ts b/packages/liveblocks-server/src/interfaces/IStorageDriver.ts index 2440c651aa4..e5a195881ba 100644 --- a/packages/liveblocks-server/src/interfaces/IStorageDriver.ts +++ b/packages/liveblocks-server/src/interfaces/IStorageDriver.ts @@ -175,6 +175,13 @@ export interface IStorageDriverNodeAPI { */ get_next_sibling(parentId: string, pos: Pos): Pos | undefined; + /** + * Return the position of the last (rightmost) child under parentId, or + * undefined if the node has no children. Positions compare + * lexicographically. + */ + get_last_sibling(parentId: string): Pos | undefined; + /** * Insert a child node with the given id. * diff --git a/packages/liveblocks-server/src/plugins/InMemoryDriver.ts b/packages/liveblocks-server/src/plugins/InMemoryDriver.ts index b407e7a2330..a3da51221e5 100644 --- a/packages/liveblocks-server/src/plugins/InMemoryDriver.ts +++ b/packages/liveblocks-server/src/plugins/InMemoryDriver.ts @@ -502,6 +502,18 @@ export class InMemoryDriver implements IStorageDriver { return nextPos; } + function get_last_sibling(parentId: string): Pos | undefined { + let lastPos: Pos | undefined; + // Find the largest position under this parent + for (const siblingKey of revNodes.keysAt(parentId)) { + const siblingPos = asPos(siblingKey); + if (lastPos === undefined || siblingPos > lastPos) { + lastPos = siblingPos; + } + } + return lastPos; + } + /** * Inserts a node in the storage tree, deleting any nodes that already exist * under this key (including all of its children), if any. @@ -694,6 +706,12 @@ export class InMemoryDriver implements IStorageDriver { */ get_next_sibling, + /** + * Return the position of the last (rightmost) child under parentId, or + * undefined if the node has no children. + */ + get_last_sibling, + /** * Insert a child node with the given id. * diff --git a/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts b/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts index 61140e2b8b0..0b501457aba 100644 --- a/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts +++ b/packages/liveblocks-server/test/plugins/_generateFullTestSuite.ts @@ -438,7 +438,7 @@ export function createObjectOp( parentId: string, parentKey: string, data: Partial, - intent?: "set", + intent?: "set" | "push", deletedId?: string, opId = nanoid() ): CreateObjectOp & HasOpId { @@ -458,7 +458,7 @@ export function createListOp( id: string, parentId: string, parentKey: string, - intent?: "set", + intent?: "set" | "push", deletedId?: string, opId = nanoid() ): CreateListOp & HasOpId { @@ -478,7 +478,7 @@ export function createRegisterOp( parentId: string, parentKey: string, data: Json, - intent?: "set", + intent?: "set" | "push", deletedId?: string, opId = nanoid() ): CreateRegisterOp & HasOpId { @@ -498,7 +498,7 @@ export function createMapOp( id: string, parentId: string, parentKey: string, - intent?: "set", + intent?: "set" | "push", deletedId?: string, opId = nanoid() ): CreateMapOp & HasOpId { @@ -1589,6 +1589,81 @@ export function generateFullTestSuite(config: { expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe(undefined); })); + test("get_last_sibling: returns undefined for empty parent", () => + runTest(async (driver) => { + await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); + const db = await driver.load_nodes_api(blackHole); + + expect(db.get_last_sibling("root")).toBe(undefined); + expect(db.get_last_sibling("non-existing")).toBe(undefined); + })); + + test("get_last_sibling: returns the rightmost position", () => + runTest(async (driver) => { + await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); + const db = await driver.load_nodes_api(blackHole); + + await db.set_child("0:0", { + type: CrdtType.LIST, + parentId: "root", + parentKey: "myList", + }); + await db.set_child("0:1", { + type: CrdtType.REGISTER, + parentId: "0:0", + parentKey: FIRST_POSITION, + data: "item1", + }); + expect(db.get_last_sibling("0:0")).toBe(FIRST_POSITION); + + // Insert later positions out of order; the rightmost one wins + await db.set_child("0:3", { + type: CrdtType.REGISTER, + parentId: "0:0", + parentKey: THIRD_POSITION, + data: "item3", + }); + await db.set_child("0:2", { + type: CrdtType.REGISTER, + parentId: "0:0", + parentKey: SECOND_POSITION, + data: "item2", + }); + expect(db.get_last_sibling("0:0")).toBe(THIRD_POSITION); + })); + + test("get_last_sibling: updates after delete", () => + runTest(async (driver) => { + await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); + const db = await driver.load_nodes_api(blackHole); + + await db.set_child("0:0", { + type: CrdtType.LIST, + parentId: "root", + parentKey: "myList", + }); + await db.set_child("0:1", { + type: CrdtType.REGISTER, + parentId: "0:0", + parentKey: FIRST_POSITION, + data: "item1", + }); + await db.set_child("0:2", { + type: CrdtType.REGISTER, + parentId: "0:0", + parentKey: SECOND_POSITION, + data: "item2", + }); + + expect(db.get_last_sibling("0:0")).toBe(SECOND_POSITION); + + await db.delete_node("0:2"); + expect(db.get_last_sibling("0:0")).toBe(FIRST_POSITION); + + await db.delete_node("0:1"); + expect(db.get_last_sibling("0:0")).toBe(undefined); + })); + test("move: changes parentKey of node", () => runTest(async (driver) => { await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); diff --git a/packages/liveblocks-server/test/storage/push.test.ts b/packages/liveblocks-server/test/storage/push.test.ts new file mode 100644 index 00000000000..f89783da947 --- /dev/null +++ b/packages/liveblocks-server/test/storage/push.test.ts @@ -0,0 +1,118 @@ +/** + * Copyright (c) Liveblocks Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { makePosition, OpCode } from "@liveblocks/core"; +import { describe, expect, test } from "vitest"; + +import { createRegisterOp } from "~test/plugins/_generateFullTestSuite"; + +import { list, register, rootObj, runWithStorage } from "./utils"; + +const FIRST = makePosition(); +const SECOND = makePosition(FIRST); +const THIRD = makePosition(SECOND); + +// Server-authoritative append for push-tagged CREATE ops. The server +// ignores the client-guessed parentKey and places the node after the current +// last sibling, sending a SET_PARENT_KEY fix when that differs from the guess. +describe("push intent — server-authoritative append", () => { + test("keeps the guessed position when it already sorts after the tail (no fix)", () => + runWithStorage( + [rootObj(), list("0:1", "root", "list")], + async ({ storage, loadedDriver }) => { + // Empty list; the client guessed THIRD. It already sorts after every + // existing sibling (there are none), so the server keeps it as-is + // rather than pointlessly relocating it to the canonical first slot. + const [res] = await storage.applyOps([ + createRegisterOp("1:0", "0:1", THIRD, "a", "push"), + ]); + + expect(loadedDriver.get_child_at("0:1", THIRD)).toBe("1:0"); + if (!res || res.action !== "accepted") { + throw new Error("expected the push op to be accepted"); + } + expect(res.fix).toBeUndefined(); + } + )); + + test("does not send a fix when the guessed position already matches the tail", () => + runWithStorage( + [ + rootObj(), + list("0:1", "root", "list"), + register("0:2", "0:1", FIRST, "a"), + ], + async ({ storage, loadedDriver }) => { + // Client knows about "a" and guesses SECOND, which is also where the + // server appends — no correction needed. + const [res] = await storage.applyOps([ + createRegisterOp("1:0", "0:1", SECOND, "b", "push"), + ]); + + expect(loadedDriver.get_child_at("0:1", SECOND)).toBe("1:0"); + if (!res || res.action !== "accepted") { + throw new Error("expected the push op to be accepted"); + } + expect(res.fix).toBeUndefined(); + } + )); + + test("a push guessing the head appends after the last sibling, never between", () => + runWithStorage( + [ + rootObj(), + list("0:1", "root", "list"), + register("0:2", "0:1", FIRST, "a"), + register("0:3", "0:1", SECOND, "b"), + ], + async ({ storage, loadedDriver }) => { + // Stale guess of the head position; the server appends after "b". + const [res] = await storage.applyOps([ + createRegisterOp("1:0", "0:1", FIRST, "c", "push"), + ]); + + expect(loadedDriver.get_child_at("0:1", THIRD)).toBe("1:0"); + if (!res || res.action !== "accepted") { + throw new Error("expected the push op to be accepted"); + } + expect(res.fix).toEqual({ + type: OpCode.SET_PARENT_KEY, + id: "1:0", + parentKey: THIRD, + }); + } + )); + + test("concurrent pushes all guessing the head settle in arrival order", () => + runWithStorage( + [rootObj(), list("0:1", "root", "list")], + async ({ storage, loadedDriver }) => { + // Three independent clients each guess the head position. Applied + // serially (as the room mutex guarantees), each appends after the + // previous one — strictly increasing keys, no wedge. + await storage.applyOps([ + createRegisterOp("1:0", "0:1", FIRST, "a", "push"), + createRegisterOp("2:0", "0:1", FIRST, "b", "push"), + createRegisterOp("3:0", "0:1", FIRST, "c", "push"), + ]); + + expect(loadedDriver.get_child_at("0:1", FIRST)).toBe("1:0"); + expect(loadedDriver.get_child_at("0:1", SECOND)).toBe("2:0"); + expect(loadedDriver.get_child_at("0:1", THIRD)).toBe("3:0"); + } + )); +}); diff --git a/packages/liveblocks-server/test/storage/utils.ts b/packages/liveblocks-server/test/storage/utils.ts index 9334c3669d9..0ffd4e20cfb 100644 --- a/packages/liveblocks-server/test/storage/utils.ts +++ b/packages/liveblocks-server/test/storage/utils.ts @@ -19,28 +19,16 @@ import type { Json, JsonObject, ListStorageNode, - MapStorageNode, NodeMap, NodeStream, - ObjectStorageNode, RegisterStorageNode, SerializedCrdt, SerializedRootObject, } from "@liveblocks/core"; -import { CrdtType, OpCode } from "@liveblocks/core"; +import { CrdtType } from "@liveblocks/core"; import type { Logger } from "~/lib/Logger"; import { makeNewInMemoryDriver } from "~/plugins/InMemoryDriver"; -import type { - CreateListOp, - CreateMapOp, - CreateObjectOp, - CreateRegisterOp, - DeleteCrdtOp, - DeleteObjectKeyOp, - SetParentKeyOp, - UpdateObjectOp, -} from "~/protocol"; import { Storage } from "~/Storage"; import { selfCheck } from "~test/plugins/_generateFullTestSuite"; @@ -48,15 +36,6 @@ export function rootObj(data: JsonObject = {}): ["root", SerializedRootObject] { return ["root", { type: CrdtType.OBJECT, data }]; } -export function obj( - id: string, - data: JsonObject, - parentId: string, - parentKey: string -): ObjectStorageNode { - return [id, { type: CrdtType.OBJECT, data, parentId, parentKey }]; -} - export function list( id: string, parentId: string, @@ -72,21 +51,6 @@ export function list( ]; } -export function map( - id: string, - parentId: string, - parentKey: string -): MapStorageNode { - return [ - id, - { - type: CrdtType.MAP, - parentId, - parentKey, - }, - ]; -} - export function register( id: string, parentId: string, @@ -104,115 +68,6 @@ export function register( ]; } -export function updateObjectOp( - id: string, - data: Partial -): UpdateObjectOp { - return { - type: OpCode.UPDATE_OBJECT, - data, - id, - }; -} - -export function createObjectOp( - id: string, - parentId: string, - parentKey: string, - data: Partial, - intent?: "set", - deletedId?: string -): CreateObjectOp { - return { - type: OpCode.CREATE_OBJECT, - data, - id, - parentId, - parentKey, - intent, - deletedId, - }; -} - -export function createListOp( - id: string, - parentId: string, - parentKey: string, - intent?: "set", - deletedId?: string -): CreateListOp { - return { - type: OpCode.CREATE_LIST, - id, - parentId, - parentKey, - intent, - deletedId, - }; -} - -export function createRegisterOp( - id: string, - parentId: string, - parentKey: string, - data: Json, - intent?: "set", - deletedId?: string -): CreateRegisterOp { - return { - type: OpCode.CREATE_REGISTER, - id, - parentId, - parentKey, - data, - intent, - deletedId, - }; -} - -export function createMapOp( - id: string, - parentId: string, - parentKey: string, - intent?: "set", - deletedId?: string, - opId?: string -): CreateMapOp { - return { - type: OpCode.CREATE_MAP, - opId, - id, - parentId, - parentKey, - intent, - deletedId, - }; -} - -export function deleteCrdtOp(id: string, opId?: string): DeleteCrdtOp { - return { - type: OpCode.DELETE_CRDT, - opId, - id, - }; -} - -export function setParentKeyOp(id: string, parentKey: string): SetParentKeyOp { - return { - id, - type: OpCode.SET_PARENT_KEY, - parentKey, - }; -} - -export function deleteObjectKeyOp(id: string, key: string): DeleteObjectKeyOp { - return { - id, - type: OpCode.DELETE_OBJECT_KEY, - key, - }; -} - /** * Helper to create a Storage instance backed by the current driver. Writes * the initial nodes (which can contain invalid/corrupted data) to the diff --git a/packages/liveblocks-server/vitest.config.ts b/packages/liveblocks-server/vitest.config.ts index e273ee62f1a..97e7706cc4e 100644 --- a/packages/liveblocks-server/vitest.config.ts +++ b/packages/liveblocks-server/vitest.config.ts @@ -1,9 +1,11 @@ -import { defineConfig } from "vitest/config"; +import { defaultExclude, defineConfig } from "vitest/config"; export default defineConfig({ resolve: { tsconfigPaths: true }, test: { + exclude: [...defaultExclude, "**/dist/**"], + // Will avoid having to put import `describe`, `test`, `expect`, etc in // every test file. globals: true, diff --git a/tools/liveblocks-cli/CHANGELOG.md b/tools/liveblocks-cli/CHANGELOG.md index b46dac71c48..c207c83d2e7 100644 --- a/tools/liveblocks-cli/CHANGELOG.md +++ b/tools/liveblocks-cli/CHANGELOG.md @@ -1,5 +1,14 @@ ## vNEXT (not yet released) +## v1.5.0 + +- Add `--random-port` (`-P`) flag to `liveblocks dev`: bind a random free port + instead of an explicit port number. With `--cmd` (`-c`), the chosen port is + exposed to the command via `LIVEBLOCKS_DEV_SERVER_PORT`. Ideal for CI (no port + collisions ever). +- Fix `LiveList.push()` so concurrent pushes from multiple clients no longer + settle out of order. + ## v1.4.1 - Fix: `client.getOrCreateRoom()` no longer errors when the room already exists, diff --git a/tools/liveblocks-cli/README.md b/tools/liveblocks-cli/README.md index 918bdc633ef..6b713ec0279 100644 --- a/tools/liveblocks-cli/README.md +++ b/tools/liveblocks-cli/README.md @@ -14,15 +14,16 @@ npx liveblocks dev Options: -| Flag | Description | Default | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------- | -| `--port`, `-p` | Port to listen on. | `1153` | -| `--host` | Host to bind to. | `localhost` | -| `--cmd`, `-c` | Run a one-off command against a fresh server instance, then shut down. Does not affect your local data in `.liveblocks/`. | | -| `--ci` | Start a fresh server instance on every boot, ideal for CI. | | -| `--no-check` | Skip project setup check on start. | Checks by default | -| `--verbose`, `-v` | Show verbose output. | | -| `--help`, `-h` | Show help. | | +| Flag | Description | Default | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------- | +| `--port`, `-p ` | Port to listen on. | `1153` | +| `--random-port`, `-P` | Bind a random free port. The chosen port is exposed via the `LIVEBLOCKS_DEV_SERVER_PORT` env var. Ideal for CI. | | +| `--host ` | Host to bind to. | `localhost` | +| `--cmd`, `-c ` | Run a one-off command against a fresh server instance, then shut down. Does not affect your local data in `.liveblocks/`. | | +| `--ci` | Start a fresh server instance on every boot, ideal for CI. | | +| `--no-check` | Skip project setup check on start. | Checks by default | +| `--verbose`, `-v` | Show verbose output. | | +| `--help`, `-h` | Show help. | | By default, the dev server scans your project on startup for common Liveblocks call sites (``, `createClient()`, `new Liveblocks()`) and diff --git a/tools/liveblocks-cli/package.json b/tools/liveblocks-cli/package.json index 3dbdb76e1ba..577e47c2166 100644 --- a/tools/liveblocks-cli/package.json +++ b/tools/liveblocks-cli/package.json @@ -46,10 +46,10 @@ "typescript": "^5.9.3" }, "dependencies": { - "@liveblocks/core": "3.18.0", + "@liveblocks/core": "3.20.0-pre1", "@liveblocks/query-parser": "workspace:^", "@liveblocks/server": "workspace:^", - "@liveblocks/zenrouter": "^1.0.17", + "@liveblocks/zenrouter": "^1.0.18", "decoders": "^2.9.0", "js-base64": "^3.7.5", "yjs": "^13.6.10" diff --git a/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts b/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts index 7b088167e7b..0c1ac7b17c7 100644 --- a/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts +++ b/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts @@ -462,6 +462,18 @@ export class BunSQLiteDriver implements IStorageDriver { return nextPos; } + function get_last_sibling(parentId: string): Pos | undefined { + let lastPos: Pos | undefined; + // Find the largest position under this parent + for (const siblingKey of revNodes.keysAt(parentId)) { + const siblingPos = asPos(siblingKey); + if (lastPos === undefined || siblingPos > lastPos) { + lastPos = siblingPos; + } + } + return lastPos; + } + /** * Inserts a node in the storage tree, deleting any nodes that already exist * under this key (including all of its children), if any. @@ -662,6 +674,12 @@ export class BunSQLiteDriver implements IStorageDriver { */ get_next_sibling, + /** + * Return the position of the last (rightmost) child under parentId, or + * undefined if the node has no children. + */ + get_last_sibling, + /** * Insert a child node with the given id. * diff --git a/tools/liveblocks-cli/src/dev-server/index.ts b/tools/liveblocks-cli/src/dev-server/index.ts index 014594927b2..da23d1d97a1 100644 --- a/tools/liveblocks-cli/src/dev-server/index.ts +++ b/tools/liveblocks-cli/src/dev-server/index.ts @@ -122,6 +122,7 @@ function shellEscape(arg: string): string { type Options = { port: string; + "random-port": boolean; host?: string; cmd?: string; help: boolean; @@ -138,6 +139,7 @@ const dev: SubCommand = { argv, { port: { type: "string", short: "p", default: DEFAULT_PORT.toString() }, + "random-port": { type: "boolean", short: "P", default: false }, host: { type: "string" }, cmd: { type: "string", short: "c" }, help: { type: "boolean", short: "h", default: false }, @@ -169,6 +171,9 @@ const dev: SubCommand = { console.log(); console.log("Options:"); console.log(` --port, -p Port to listen on (default: ${DEFAULT_PORT})`); // prettier-ignore + console.log(" --random-port, -P Bind a random free port instead of --port (no collisions,"); // prettier-ignore + console.log(" ever). With --cmd, the chosen port is exposed to the command"); // prettier-ignore + console.log(" via LIVEBLOCKS_DEV_SERVER_PORT. Ideal for CI."); // prettier-ignore console.log(" --host Host to bind to (default: localhost)"); console.log(" --cmd, -c Run a one-off command against a fresh server instance, then"); // prettier-ignore console.log(" shut down. Does not affect your local data in .liveblocks/."); // prettier-ignore @@ -197,19 +202,23 @@ const dev: SubCommand = { options["no-check"] = true; } - // Precedence: CLI flag > env var > default - const port = - parsePort(options.port) ?? - parsePort(process.env.LIVEBLOCKS_DEVSERVER_PORT) ?? - DEFAULT_PORT; + // With --random-port, bind to port 0 so the OS hands us a guaranteed-free + // port at bind time (no collisions, ever). Otherwise the precedence is: + // CLI flag > env var > default. The actually-bound port is read back from + // `server.port` after binding and used for everything downstream. + const requestedPort = options["random-port"] + ? 0 + : (parsePort(options.port) ?? + parsePort(process.env.LIVEBLOCKS_DEVSERVER_PORT) ?? + DEFAULT_PORT); const hostname = options.host || process.env.LIVEBLOCKS_DEVSERVER_HOST || "localhost"; const ephemeralPath = ephemeral ? Rooms.useEphemeralStorage() : null; - if (await isPortInUse(port, hostname)) { + if (requestedPort !== 0 && (await isPortInUse(requestedPort, hostname))) { console.error( - `Port ${port} is already in use.\nIs another dev server already running?` + `Port ${requestedPort} is already in use.\nIs another dev server already running?` ); process.exit(1); } @@ -217,10 +226,15 @@ const dev: SubCommand = { let server: Bun.Server; let verbose = false; + // The port to bind on. Starts as the requested port (0 for --random-port) + // and gets pinned to the OS-assigned port after the first bind, so a + // reboot() rebinds the same port instead of drawing a fresh random one. + let listenPort = requestedPort; + function createServer() { - return Bun.serve({ + const newServer = Bun.serve({ hostname, - port, + port: listenPort, async fetch(req, server) { // WebSocket bypass - handle upgrades directly @@ -373,6 +387,8 @@ const dev: SubCommand = { // }, }, }); + listenPort = newServer.port!; + return newServer; } server = createServer(); @@ -417,7 +433,7 @@ const dev: SubCommand = { env: { ...process.env, LIVEBLOCKS_DEV_SERVER_HOST: hostname, - LIVEBLOCKS_DEV_SERVER_PORT: String(port), + LIVEBLOCKS_DEV_SERVER_PORT: String(server.port), }, }); @@ -438,7 +454,7 @@ const dev: SubCommand = { process.exit(code); } else { // Check if the current project is configured to use the local dev server - const baseUrl = `http://${hostname}:${port}`; + const baseUrl = `http://${hostname}:${server.port}`; const configIssues = options["no-check"] ? [] : await checkLiveblocksSetup(baseUrl); // prettier-ignore // ----------------------------------------------------------------------- diff --git a/tools/liveblocks-cli/test/devserver/cmd.test.ts b/tools/liveblocks-cli/test/devserver/cmd.test.ts index dd4009c0b3d..fa080576851 100644 --- a/tools/liveblocks-cli/test/devserver/cmd.test.ts +++ b/tools/liveblocks-cli/test/devserver/cmd.test.ts @@ -131,6 +131,21 @@ describe("liveblocks dev -c", () => { expect(stdout).toContain("LIVEBLOCKS_DEV_SERVER_PORT=7777"); }); + test("--random-port (-P) injects a random free port instead of the default", async () => { + const { stdout, exitCode } = await runDevCommand([ + "-P", + "-c", + "env | grep LIVEBLOCKS_DEV_SERVER_PORT", + ]); + expect(exitCode).toBe(0); + + const match = stdout.match(/LIVEBLOCKS_DEV_SERVER_PORT=(\d+)/); + const port = match ? Number(match[1]) : undefined; + expect(port).toBeGreaterThan(0); + // A free port was picked by the OS, not the static default. + expect(port).not.toBe(1153); + }); + test("does not change cwd of child process", async () => { const { stdout, exitCode } = await runDevCommand([ "-p", diff --git a/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts b/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts index 66f5dcba26c..9802f7eeede 100644 --- a/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts +++ b/tools/liveblocks-cli/test/plugins/_generateFullTestSuite.ts @@ -444,7 +444,7 @@ export function createObjectOp( parentId: string, parentKey: string, data: Partial, - intent?: "set", + intent?: "set" | "push", deletedId?: string, opId = nanoid() ): CreateObjectOp & HasOpId { @@ -464,7 +464,7 @@ export function createListOp( id: string, parentId: string, parentKey: string, - intent?: "set", + intent?: "set" | "push", deletedId?: string, opId = nanoid() ): CreateListOp & HasOpId { @@ -484,7 +484,7 @@ export function createRegisterOp( parentId: string, parentKey: string, data: Json, - intent?: "set", + intent?: "set" | "push", deletedId?: string, opId = nanoid() ): CreateRegisterOp & HasOpId { @@ -504,7 +504,7 @@ export function createMapOp( id: string, parentId: string, parentKey: string, - intent?: "set", + intent?: "set" | "push", deletedId?: string, opId = nanoid() ): CreateMapOp & HasOpId { @@ -1595,6 +1595,81 @@ export function generateFullTestSuite(config: { expect(db.get_next_sibling("0:0", FIRST_POSITION)).toBe(undefined); })); + test("get_last_sibling: returns undefined for empty parent", () => + runTest(async (driver) => { + await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); + const db = await driver.load_nodes_api(blackHole); + + expect(db.get_last_sibling("root")).toBe(undefined); + expect(db.get_last_sibling("non-existing")).toBe(undefined); + })); + + test("get_last_sibling: returns the rightmost position", () => + runTest(async (driver) => { + await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); + const db = await driver.load_nodes_api(blackHole); + + await db.set_child("0:0", { + type: CrdtType.LIST, + parentId: "root", + parentKey: "myList", + }); + await db.set_child("0:1", { + type: CrdtType.REGISTER, + parentId: "0:0", + parentKey: FIRST_POSITION, + data: "item1", + }); + expect(db.get_last_sibling("0:0")).toBe(FIRST_POSITION); + + // Insert later positions out of order; the rightmost one wins + await db.set_child("0:3", { + type: CrdtType.REGISTER, + parentId: "0:0", + parentKey: THIRD_POSITION, + data: "item3", + }); + await db.set_child("0:2", { + type: CrdtType.REGISTER, + parentId: "0:0", + parentKey: SECOND_POSITION, + data: "item2", + }); + expect(db.get_last_sibling("0:0")).toBe(THIRD_POSITION); + })); + + test("get_last_sibling: updates after delete", () => + runTest(async (driver) => { + await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); + const db = await driver.load_nodes_api(blackHole); + + await db.set_child("0:0", { + type: CrdtType.LIST, + parentId: "root", + parentKey: "myList", + }); + await db.set_child("0:1", { + type: CrdtType.REGISTER, + parentId: "0:0", + parentKey: FIRST_POSITION, + data: "item1", + }); + await db.set_child("0:2", { + type: CrdtType.REGISTER, + parentId: "0:0", + parentKey: SECOND_POSITION, + data: "item2", + }); + + expect(db.get_last_sibling("0:0")).toBe(SECOND_POSITION); + + await db.delete_node("0:2"); + expect(db.get_last_sibling("0:0")).toBe(FIRST_POSITION); + + await db.delete_node("0:1"); + expect(db.get_last_sibling("0:0")).toBe(undefined); + })); + test("move: changes parentKey of node", () => runTest(async (driver) => { await driver.DANGEROUSLY_reset_nodes(EMPTY_DOC); From 2c7ef1480b57930437ff83dfdbeeefcd38e8af53 Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Thu, 28 May 2026 10:44:50 +0000 Subject: [PATCH 2/2] Bump to 1.5.0 Original commit: 5c960b3575a2fed704a7fb0ca86fc621360be44c --- packages/liveblocks-server/package.json | 2 +- tools/liveblocks-cli/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/liveblocks-server/package.json b/packages/liveblocks-server/package.json index 73b5cd51981..d478a1eb0c4 100644 --- a/packages/liveblocks-server/package.json +++ b/packages/liveblocks-server/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/server", - "version": "1.4.2-pre1", + "version": "1.5.0", "description": "Liveblocks backend server foundation.", "type": "module", "main": "./dist/index.js", diff --git a/tools/liveblocks-cli/package.json b/tools/liveblocks-cli/package.json index 577e47c2166..f0bbcfa0523 100644 --- a/tools/liveblocks-cli/package.json +++ b/tools/liveblocks-cli/package.json @@ -1,6 +1,6 @@ { "name": "liveblocks", - "version": "1.4.2-pre1", + "version": "1.5.0", "description": "Liveblocks command line interface", "type": "module", "bin": {