Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/liveblocks-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
},
"sideEffects": false,
"dependencies": {
"@liveblocks/core": "v3.21.0-private3",
"@liveblocks/core": "3.23.0-file1",
"async-mutex": "^0.4.0",
"decoders": "^2.9.0",
"itertools": "^2.7.1",
Expand Down
12 changes: 11 additions & 1 deletion packages/liveblocks-server/src/Storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ function nodeFromCreateChildOp(op: CreateOp): SerializedChild {
data: op.data,
};

case OpCode.CREATE_FILE:
return {
type: CrdtType.FILE,
parentId: op.parentId,
parentKey: op.parentKey,
data: op.data,
};

// istanbul ignore next
default:
return assertNever(op, "Unknown op code");
Expand Down Expand Up @@ -176,6 +184,7 @@ export class Storage {
case OpCode.CREATE_MAP:
case OpCode.CREATE_REGISTER:
case OpCode.CREATE_OBJECT:
case OpCode.CREATE_FILE:
return this.applyCreateOp(op);

case OpCode.UPDATE_OBJECT:
Expand Down Expand Up @@ -255,7 +264,8 @@ export class Storage {
return this.createChildAsListItem(op, node);

case CrdtType.REGISTER:
// It's illegal for registers to have children
case CrdtType.FILE:
// It's illegal for leaf nodes to have children
return ignore(op);

// istanbul ignore next
Expand Down
26 changes: 26 additions & 0 deletions packages/liveblocks-server/src/decoders/Op.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,19 @@ import { OpCode } from "@liveblocks/core";
import type { Decoder } from "decoders";
import {
constant,
number,
object,
oneOf,
optional,
sized,
startsWith,
string,
taggedUnion,
} from "decoders";

import type {
ClientWireOp,
CreateFileOp,
CreateListOp,
CreateMapOp,
CreateObjectOp,
Expand All @@ -43,6 +47,11 @@ import { jsonObjectYolo, jsonYolo } from "./jsonYolo";
type HasOpId = { opId: string };

const intent = oneOf(["set", "push"] as const);
const storageFileId = sized(startsWith("fl_"), { size: 24 });
const fileSize = number.refine(
(value) => Number.isSafeInteger(value) && value >= 0,
"Must be a valid file size"
);

const updateObjectOp: Decoder<UpdateObjectOp & HasOpId> = object({
type: constant(OpCode.UPDATE_OBJECT),
Expand Down Expand Up @@ -93,6 +102,22 @@ const createRegisterOp: Decoder<CreateRegisterOp & HasOpId> = object({
deletedId: optional(string),
});

const createFileOp: Decoder<CreateFileOp & HasOpId> = object({
type: constant(OpCode.CREATE_FILE),
opId: string,
id: string,
parentId: string,
parentKey: string,
data: object({
id: storageFileId,
name: string,
size: fileSize,
mimeType: string,
}),
intent: optional(intent),
deletedId: optional(string),
});

const deleteCrdtOp: Decoder<DeleteCrdtOp & HasOpId> = object({
type: constant(OpCode.DELETE_CRDT),
opId: string,
Expand All @@ -119,6 +144,7 @@ export const op: Decoder<ClientWireOp> = taggedUnion("type", {
[OpCode.CREATE_LIST]: createListOp,
[OpCode.CREATE_MAP]: createMapOp,
[OpCode.CREATE_REGISTER]: createRegisterOp,
[OpCode.CREATE_FILE]: createFileOp,
[OpCode.DELETE_CRDT]: deleteCrdtOp,
[OpCode.SET_PARENT_KEY]: setParentKeyOp,
[OpCode.DELETE_OBJECT_KEY]: deleteObjectKeyOp,
Expand Down
4 changes: 4 additions & 0 deletions packages/liveblocks-server/src/formats/LossyJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ function buildNode(snapshot: IReadableSnapshot, id: string): Json {
return buildList(snapshot, id);
} else if (node.type === CrdtType.MAP) {
return buildMap(snapshot, id);
} else if (node.type === CrdtType.FILE) {
return node.data;
} else {
return node.data;
}
Expand Down Expand Up @@ -120,6 +122,8 @@ function* emit(snapshot: IReadableSnapshot, id: string): StringGen {
yield* emitMap(snapshot, id);
} else if (node.type === CrdtType.REGISTER) {
yield JSON.stringify(node.data);
} else if (node.type === CrdtType.FILE) {
yield JSON.stringify(node.data);
}
}

Expand Down
21 changes: 20 additions & 1 deletion packages/liveblocks-server/src/formats/PlainLson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
ObjectStorageNode,
PlainLson,
PlainLsonFields,
PlainLsonFile,
PlainLsonList,
PlainLsonMap,
PlainLsonObject,
Expand All @@ -44,7 +45,7 @@ function generateId(state: { clock: number }) {

function isSpecialPlainLsonValue(
value: PlainLson
): value is PlainLsonObject | PlainLsonMap | PlainLsonList {
): value is PlainLsonObject | PlainLsonMap | PlainLsonList | PlainLsonFile {
return isJsonObject(value) && value.liveblocksType !== undefined;
}

Expand Down Expand Up @@ -72,6 +73,18 @@ function* iterJson(
yield* iterMap(key, data.data, parent, state);
return;

case "LiveFile":
yield [
generateId(state),
{
type: CrdtType.FILE,
data: data.data,
parentId: parent[0],
parentKey: key,
},
];
return;

// istanbul ignore next
default:
assertNever(data, "Unknown `liveblocksType` field");
Expand Down Expand Up @@ -231,6 +244,8 @@ function buildNode(snapshot: IReadableSnapshot, id: string): PlainLson {
return buildList(snapshot, id);
} else if (node.type === CrdtType.MAP) {
return buildMap(snapshot, id);
} else if (node.type === CrdtType.FILE) {
return { liveblocksType: "LiveFile", data: node.data };
} else {
// TEMPORARY: `?? null` is only here to project legacy KV rooms that
// contain data-less registers (under a LiveMap, representing `null`
Expand Down Expand Up @@ -308,6 +323,10 @@ function* emit(snapshot: IReadableSnapshot, id: string): StringGen {
// TEMPORARY: see buildNode — remove `?? null` once all rooms are
// migrated to SQLite.
yield JSON.stringify(node.data ?? null);
} else if (node.type === CrdtType.FILE) {
yield '{"liveblocksType":"LiveFile","data":';
yield JSON.stringify(node.data);
yield "}";
}
}

Expand Down
10 changes: 8 additions & 2 deletions packages/liveblocks-server/src/plugins/InMemoryDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,11 @@ function buildReverseLookup(nodes: NodeMap) {
}
}

if (node.type !== CrdtType.REGISTER) {
const isLeafNode =
node.type === CrdtType.REGISTER || node.type === CrdtType.FILE;
if (!isLeafNode) {
queue.push(...revNodes.valuesAt(nodeId));
} else {
} else if (node.type === CrdtType.REGISTER) {
const parent = nodes.get(node.parentId);
if (parent?.type === CrdtType.OBJECT) {
continue;
Expand Down Expand Up @@ -637,6 +639,10 @@ export class InMemoryDriver implements IStorageDriver {
throw new Error("Cannot add register under object");
}

if (parentNode.type === CrdtType.FILE) {
throw new Error("Cannot add child under file");
}

const conflictingSiblingId = revNodes.get(node.parentId, node.parentKey);
if (conflictingSiblingId !== id) {
// Conflict!
Expand Down
1 change: 1 addition & 0 deletions packages/liveblocks-server/src/protocol/vNEXT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type { RoomStateServerMsg, ServerMsg } from "@liveblocks/core";
// All Op types
export type {
ClientWireOp,
CreateFileOp,
CreateListOp,
CreateMapOp,
CreateObjectOp,
Expand Down
115 changes: 115 additions & 0 deletions packages/liveblocks-server/test/decoders/ClientMsg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* 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 <https://www.gnu.org/licenses/>.
*/

import { ClientMsgCode, OpCode } from "@liveblocks/core";
import { describe, expect, test } from "vitest";

import { clientMsgDecoder } from "~/decoders";

describe("clientMsgDecoder", () => {
test("accepts valid CREATE_FILE storage file ids", () => {
expect(() =>
clientMsgDecoder.verify({
type: ClientMsgCode.UPDATE_STORAGE,
ops: [
{
type: OpCode.CREATE_FILE,
opId: "op-1",
id: "1:0",
parentId: "root",
parentKey: "cover",
data: {
id: "fl_123456789012345678901",
name: "cover.png",
size: 123,
mimeType: "image/png",
},
},
],
})
).not.toThrow();
});

test("accepts a zero-byte CREATE_FILE", () => {
expect(() =>
clientMsgDecoder.verify({
type: ClientMsgCode.UPDATE_STORAGE,
ops: [
{
type: OpCode.CREATE_FILE,
opId: "op-1",
id: "1:0",
parentId: "root",
parentKey: "empty",
data: {
id: "fl_123456789012345678901",
name: "empty.txt",
size: 0,
mimeType: "text/plain",
},
},
],
})
).not.toThrow();
});

test("rejects invalid CREATE_FILE storage file ids", () => {
expect(() =>
clientMsgDecoder.verify({
type: ClientMsgCode.UPDATE_STORAGE,
ops: [
{
type: OpCode.CREATE_FILE,
opId: "op-1",
id: "1:0",
parentId: "root",
parentKey: "cover",
data: {
id: "file_123",
name: "cover.png",
size: 123,
mimeType: "image/png",
},
},
],
})
).toThrow();
});

test.each([-1, 1.5])("rejects invalid CREATE_FILE size %s", (size) => {
expect(() =>
clientMsgDecoder.verify({
type: ClientMsgCode.UPDATE_STORAGE,
ops: [
{
type: OpCode.CREATE_FILE,
opId: "op-1",
id: "1:0",
parentId: "root",
parentKey: "cover",
data: {
id: "fl_123456789012345678901",
name: "cover.png",
size,
mimeType: "image/png",
},
},
],
})
).toThrow();
});
});
19 changes: 19 additions & 0 deletions packages/liveblocks-server/test/formats/LossyJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ describe("Serialization of nodes (to LossyJson format)", () => {
expect(json).toEqual({});
});

test("LiveFile", () => {
const file = {
id: "fl_123",
name: "brief.pdf",
size: 42,
mimeType: "application/pdf",
};
// prettier-ignore
const snapshot = makeSnapshot([
["root", { data: {}, type: CrdtType.OBJECT }],
["si:1", { data: file, parentId: "root", parentKey: "file", type: CrdtType.FILE }],
]);

const json = snapshotToLossyJson(snapshot);
expect(json).toEqual({
file,
});
});

test("With root node", () => {
// prettier-ignore
const snapshot = makeSnapshot([
Expand Down
28 changes: 28 additions & 0 deletions packages/liveblocks-server/test/formats/PlainLson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,34 @@ describe("Serialization of nodes (to PlainLson format)", () => {
);
});

test("LiveFile", () => {
const file = {
id: "fl_123",
name: "brief.pdf",
size: 42,
mimeType: "application/pdf",
};
// prettier-ignore
const nodes: StorageNode[] = [
["root", { data: {}, type: CrdtType.OBJECT }],
["si:1", { data: file, parentId: "root", parentKey: "file", type: CrdtType.FILE }],
];

const plainLson = snapshotToPlainLson(makeSnapshot(nodes));
expect(plainLson).toEqual({
liveblocksType: "LiveObject",
data: {
file: {
liveblocksType: "LiveFile",
data: file,
},
},
});

const convertedNodes = plainLsonToNodeMap(plainLson);
expect(convertedNodes).toEqual(new Map<string, SerializedCrdt>(nodes));
});

test("With root node", () => {
// prettier-ignore
const nodes: StorageNode[] = [
Expand Down
Loading
Loading