diff --git a/packages/liveblocks-server/package.json b/packages/liveblocks-server/package.json index a04ee119451..ed335e3df27 100644 --- a/packages/liveblocks-server/package.json +++ b/packages/liveblocks-server/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/server", - "version": "1.3.0", + "version": "1.4.0", "description": "Liveblocks backend server foundation.", "type": "module", "main": "./dist/index.js", diff --git a/tools/liveblocks-cli/CHANGELOG.md b/tools/liveblocks-cli/CHANGELOG.md index 25ee9a06a3f..87e3c0bb09f 100644 --- a/tools/liveblocks-cli/CHANGELOG.md +++ b/tools/liveblocks-cli/CHANGELOG.md @@ -1,5 +1,11 @@ ## vNEXT (not yet released) +- ... + +## v1.4.0 + +- Add support for `client.mutateStorage()` (from `@liveblocks/node`) + ## v1.3.0 - Add feeds support (`feeds:write` permission) diff --git a/tools/liveblocks-cli/package.json b/tools/liveblocks-cli/package.json index b967fc6b014..73ed5f51176 100644 --- a/tools/liveblocks-cli/package.json +++ b/tools/liveblocks-cli/package.json @@ -1,6 +1,6 @@ { "name": "liveblocks", - "version": "1.3.0", + "version": "1.4.0", "description": "Liveblocks command line interface", "type": "module", "bin": { diff --git a/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts b/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts index 3688601cc5d..53efd562dbf 100644 --- a/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts +++ b/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts @@ -15,16 +15,18 @@ * along with this program. If not, see . */ -import type { JsonObject, PlainLsonObject } from "@liveblocks/core"; +import type { Json, JsonObject, PlainLsonObject } from "@liveblocks/core"; import { QueryParser } from "@liveblocks/query-parser"; import type { Guid, Logger, YDocId } from "@liveblocks/server"; import { jsonObjectYolo, ROOT_YDOC_ID, snapshotToLossyJson_eager, + snapshotToNodeStream, snapshotToPlainLson_eager, + transientClientMsgDecoder, } from "@liveblocks/server"; -import { json, ZenRouter } from "@liveblocks/zenrouter"; +import { json, ndjsonStream, ZenRouter } from "@liveblocks/zenrouter"; import { array, constant, @@ -36,6 +38,7 @@ import { record, string, } from "decoders"; +import { chain } from "itertools"; import { Base64 } from "js-base64"; import * as Y from "yjs"; @@ -601,6 +604,50 @@ zen.route("POST /v2/rooms//feeds//messages", ({ p }) => { }); }); +zen.route( + "POST /v2/rooms//request-storage-mutation", + + async ({ p }) => { + if (!Rooms.getRoom(p.roomId)) { + throw ROOM_NOT_FOUND(p.roomId); + } + + const room = Rooms.getRoomInstance(p.roomId); + await room.load(); + + const actor = await room.driver.next_actor(); + const snapshot = room.storage.loadedDriver.get_snapshot(false); + return ndjsonStream( + chain([{ actor }], snapshotToNodeStream(snapshot)) + ); + } +); + +zen.route( + "POST /v2/rooms//send-message", + + object({ messages: array(transientClientMsgDecoder) }), + + async ({ p, body }) => { + if (!Rooms.getRoom(p.roomId)) { + throw ROOM_NOT_FOUND(p.roomId); + } + + const room = Rooms.getRoomInstance(p.roomId); + await room.load(); + + const [session, capturedServerMsgs] = + await room.createBackendSession_experimental(); + + await room.processClientMsgFromBackendSession(session, body.messages); + + return new Response(`{"messages":[${capturedServerMsgs.join(",")}]}`, { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); + } +); + /** * ------------------------------------------------------------ * NOT IMPLEMENTED ROUTES diff --git a/tools/liveblocks-cli/test/devserver/rest-api/storage-mutation.test.ts b/tools/liveblocks-cli/test/devserver/rest-api/storage-mutation.test.ts new file mode 100644 index 00000000000..6b5a3a844c9 --- /dev/null +++ b/tools/liveblocks-cli/test/devserver/rest-api/storage-mutation.test.ts @@ -0,0 +1,229 @@ +/** + * 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 { ClientMsgCode, CrdtType, OpCode } from "@liveblocks/core"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + +import * as Rooms from "~/dev-server/db/rooms"; +import { zen } from "~/dev-server/routes/rest-api"; + +const BASE = "http://localhost"; +const AUTH = { Authorization: "Bearer sk_localdev" }; + +async function api( + method: string, + path: string, + body?: unknown +): Promise { + const headers: Record = { ...AUTH }; + const init: RequestInit = { method, headers }; + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + return zen.fetch(new Request(`${BASE}${path}`, init)); +} + +function parseNdjson(text: string): unknown[] { + return text + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as unknown); +} + +describe("REST API - storage mutation", () => { + beforeAll(() => { + Rooms.useEphemeralStorage(); + }); + + afterAll(() => { + Rooms.cleanup(); + }); + + describe("POST /v2/rooms//request-storage-mutation", () => { + test("returns 404 for non-existent room", async () => { + const resp = await api( + "POST", + "/v2/rooms/nonexistent/request-storage-mutation" + ); + expect(resp.status).toBe(404); + }); + + test("returns actor and empty root for new room", async () => { + await api("POST", "/v2/rooms", { id: "mut-empty" }); + + const resp = await api( + "POST", + "/v2/rooms/mut-empty/request-storage-mutation" + ); + expect(resp.status).toBe(200); + expect(resp.headers.get("content-type")).toContain( + "application/x-ndjson" + ); + + const lines = parseNdjson(await resp.text()); + + // First line: frontmatter with actor + expect(lines[0]).toEqual({ actor: expect.any(Number) as unknown }); + + // Second line: root node with empty data + expect(lines[1]).toEqual(["root", { type: CrdtType.OBJECT, data: {} }]); + }); + + test("returns storage nodes for room with data", async () => { + await api("POST", "/v2/rooms", { id: "mut-with-data" }); + await api("POST", "/v2/rooms/mut-with-data/storage", { + liveblocksType: "LiveObject", + data: { + name: "Alice", + scores: { liveblocksType: "LiveList", data: [10, 20] }, + }, + }); + + const resp = await api( + "POST", + "/v2/rooms/mut-with-data/request-storage-mutation" + ); + expect(resp.status).toBe(200); + + const lines = parseNdjson(await resp.text()); + expect(lines[0]).toEqual({ actor: expect.any(Number) as unknown }); + + // Should have root + at least one child node + expect(lines.length).toBeGreaterThan(2); + + // Root should contain "name" in its data + const root = lines[1] as [string, { type: number; data: object }]; + expect(root[0]).toBe("root"); + expect(root[1].type).toBe(CrdtType.OBJECT); + expect(root[1].data).toMatchObject({ name: "Alice" }); + }); + + test("returns incrementing actor IDs", async () => { + await api("POST", "/v2/rooms", { id: "mut-actors" }); + + const resp1 = await api( + "POST", + "/v2/rooms/mut-actors/request-storage-mutation" + ); + const lines1 = parseNdjson(await resp1.text()); + const actor1 = (lines1[0] as { actor: number }).actor; + + const resp2 = await api( + "POST", + "/v2/rooms/mut-actors/request-storage-mutation" + ); + const lines2 = parseNdjson(await resp2.text()); + const actor2 = (lines2[0] as { actor: number }).actor; + + expect(actor2).toBeGreaterThan(actor1); + }); + }); + + describe("POST /v2/rooms//send-message", () => { + test("returns 404 for non-existent room", async () => { + const resp = await api("POST", "/v2/rooms/nonexistent/send-message", { + messages: [], + }); + expect(resp.status).toBe(404); + }); + + test("applies UPDATE_STORAGE ops to room storage", async () => { + await api("POST", "/v2/rooms", { id: "mut-send" }); + + // First, get an actor ID via request-storage-mutation + const mutResp = await api( + "POST", + "/v2/rooms/mut-send/request-storage-mutation" + ); + const lines = parseNdjson(await mutResp.text()); + const { actor } = lines[0] as { actor: number }; + + // Send an UPDATE_STORAGE message that sets a key on root + const resp = await api("POST", "/v2/rooms/mut-send/send-message", { + messages: [ + { + type: ClientMsgCode.UPDATE_STORAGE, + ops: [ + { + type: OpCode.UPDATE_OBJECT, + id: "root", + data: { greeting: "hello" }, + opId: `${actor}:0`, + }, + ], + }, + ], + }); + + expect(resp.status).toBe(200); + const body = (await resp.json()) as { messages: unknown[] }; + expect(body).toHaveProperty("messages"); + + // Verify the storage was actually updated + const storageResp = await api("GET", "/v2/rooms/mut-send/storage"); + const storage = (await storageResp.json()) as { + liveblocksType: string; + data: Record; + }; + expect(storage.data).toMatchObject({ greeting: "hello" }); + }); + + test("applies CreateObject ops", async () => { + await api("POST", "/v2/rooms", { id: "mut-create" }); + + const mutResp = await api( + "POST", + "/v2/rooms/mut-create/request-storage-mutation" + ); + const lines = parseNdjson(await mutResp.text()); + const { actor } = lines[0] as { actor: number }; + + // Create a child object under root + const resp = await api("POST", "/v2/rooms/mut-create/send-message", { + messages: [ + { + type: ClientMsgCode.UPDATE_STORAGE, + ops: [ + { + type: OpCode.CREATE_OBJECT, + id: `${actor}:0`, + parentId: "root", + parentKey: "child", + data: { value: 42 }, + opId: `${actor}:1`, + }, + ], + }, + ], + }); + + expect(resp.status).toBe(200); + + // Verify via GET storage + const storageResp = await api("GET", "/v2/rooms/mut-create/storage"); + const storage = (await storageResp.json()) as { + liveblocksType: string; + data: Record; + }; + expect(storage.data.child).toMatchObject({ + liveblocksType: "LiveObject", + data: { value: 42 }, + }); + }); + }); +});