Skip to content

Commit 0741ebc

Browse files
loro-crdt schema validation
1 parent 0f3e430 commit 0741ebc

9 files changed

Lines changed: 212 additions & 14 deletions

File tree

apps/client/src/lib/schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { ClientId, Snapshot, WorkspaceId } from "@local/sync";
1+
import { ClientId, WorkspaceId } from "@local/sync";
2+
import { Snapshot } from "@local/sync/loro";
23
import { Schema } from "effect";
34

45
export class ClientTable extends Schema.Class<ClientTable>("ClientTable")({

apps/server/src/db/schema.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,41 @@
1-
import { customType, integer, pgTable, varchar } from "drizzle-orm/pg-core";
1+
import {
2+
boolean,
3+
customType,
4+
integer,
5+
pgEnum,
6+
pgTable,
7+
timestamp,
8+
uuid,
9+
varchar,
10+
} from "drizzle-orm/pg-core";
11+
12+
const scope = pgEnum("scope", ["read", "read_write"]);
213

314
const bytea = customType<{ data: Uint8Array }>({
415
dataType: () => "bytea",
516
});
617

7-
export const usersTable = pgTable("users", {
8-
id: integer().primaryKey().generatedAlwaysAsIdentity(),
9-
name: varchar({ length: 255 }).notNull(),
10-
snapshot: bytea(),
18+
export const workspaceTable = pgTable("workspace", {
19+
workspaceId: uuid().notNull(),
20+
ownerClientId: uuid().notNull(),
21+
clientId: uuid().notNull(),
22+
createdAt: timestamp().notNull().defaultNow(),
23+
snapshot: bytea().notNull(),
24+
});
25+
26+
export const clientTable = pgTable("client", {
27+
clientId: uuid().notNull(),
28+
createdAt: timestamp().notNull().defaultNow(),
29+
});
30+
31+
export const tokenTable = pgTable("token", {
32+
tokenId: integer().primaryKey().generatedAlwaysAsIdentity(),
33+
tokenValue: varchar().notNull(),
34+
clientId: uuid().notNull(),
35+
workspaceId: uuid().notNull(),
36+
isMaster: boolean().notNull().default(false),
37+
scope: scope().notNull(),
38+
issuedAt: timestamp().notNull().defaultNow(),
39+
expiresAt: timestamp(),
40+
revokedAt: timestamp(),
1141
});

apps/server/src/group/sync-auth.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { HttpApiBuilder } from "@effect/platform";
2+
import { SyncApi } from "@local/sync";
3+
import { Effect } from "effect";
4+
5+
export const SyncAuthGroupLive = HttpApiBuilder.group(
6+
SyncApi,
7+
"syncAuth",
8+
(handlers) =>
9+
Effect.gen(function* () {
10+
return handlers
11+
.handle("generateToken", ({ payload }) =>
12+
Effect.fail("Not implemented")
13+
)
14+
.handle("issueToken", ({ path: { workspaceId } }) =>
15+
Effect.fail("Not implemented")
16+
)
17+
.handle("listTokens", ({ path: { workspaceId } }) =>
18+
Effect.fail("Not implemented")
19+
)
20+
.handle("revokeToken", ({ path: { workspaceId } }) =>
21+
Effect.fail("Not implemented")
22+
);
23+
})
24+
);

apps/server/src/group/sync-data.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { HttpApiBuilder } from "@effect/platform";
2+
import { SyncApi } from "@local/sync";
3+
import { LoroSchemaTransform } from "@local/sync/loro";
4+
import { and, eq } from "drizzle-orm";
5+
import { Array, Effect, Layer, Schema } from "effect";
6+
import { workspaceTable } from "../db/schema";
7+
import { Drizzle } from "../drizzle";
8+
9+
export const SyncDataGroupLive = HttpApiBuilder.group(
10+
SyncApi,
11+
"syncData",
12+
(handlers) =>
13+
Effect.gen(function* () {
14+
const { query } = yield* Drizzle;
15+
return handlers
16+
.handle(
17+
"push",
18+
({ payload: { clientId, snapshot }, path: { workspaceId } }) =>
19+
Effect.gen(function* () {
20+
const doc = yield* Schema.encode(LoroSchemaTransform)(snapshot);
21+
22+
const workspace = yield* query({
23+
Request: Schema.Struct({
24+
workspaceId: Schema.UUID,
25+
clientId: Schema.UUID,
26+
}),
27+
execute: (db, { workspaceId, clientId }) =>
28+
db
29+
.select()
30+
.from(workspaceTable)
31+
.where(
32+
and(
33+
eq(workspaceTable.workspaceId, workspaceId),
34+
eq(workspaceTable.clientId, clientId)
35+
)
36+
)
37+
.limit(1),
38+
})({ clientId, workspaceId }).pipe(Effect.flatMap(Array.head));
39+
40+
doc.import(workspace.snapshot); // 🪄
41+
42+
const newSnapshot =
43+
yield* Schema.decode(LoroSchemaTransform)(doc);
44+
45+
yield* query({
46+
Request: Schema.Struct({
47+
newSnapshot: Schema.Uint8Array,
48+
}),
49+
execute: (db, { newSnapshot: snapshot }) =>
50+
db.insert(workspaceTable).values({
51+
snapshot,
52+
clientId: workspace.clientId,
53+
workspaceId: workspace.workspaceId,
54+
ownerClientId: workspace.ownerClientId,
55+
// createdAt
56+
}),
57+
})({ newSnapshot });
58+
59+
return yield* Effect.fail({
60+
message: "Not (fully) implemented" as const,
61+
});
62+
}).pipe(Effect.mapError((error) => error.message))
63+
)
64+
.handle("pull", ({ path: { workspaceId } }) =>
65+
Effect.fail("Not implemented")
66+
);
67+
})
68+
).pipe(Layer.provide(Drizzle.Default));

apps/server/src/main.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import {
99
NodeHttpServer,
1010
NodeRuntime,
1111
} from "@effect/platform-node";
12-
import { ServerApi } from "@local/api";
12+
import { SyncApi } from "@local/sync";
1313
import { Effect, Layer } from "effect";
14-
import { UserGroupLive } from "./user";
15-
1614
import { createServer } from "node:http";
1715
import { Drizzle } from "./drizzle";
16+
import { SyncAuthGroupLive } from "./group/sync-auth";
17+
import { SyncDataGroupLive } from "./group/sync-data";
1818

1919
const EnvProviderLayer = Layer.unwrapEffect(
2020
PlatformConfigProvider.fromDotEnv(".env").pipe(
@@ -23,8 +23,8 @@ const EnvProviderLayer = Layer.unwrapEffect(
2323
)
2424
);
2525

26-
const MainApiLive = HttpApiBuilder.api(ServerApi).pipe(
27-
Layer.provide([Drizzle.Default, UserGroupLive]),
26+
const MainApiLive = HttpApiBuilder.api(SyncApi).pipe(
27+
Layer.provide([Drizzle.Default, SyncDataGroupLive, SyncAuthGroupLive]),
2828
Layer.provide(EnvProviderLayer)
2929
);
3030

packages/sync/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
"typecheck": "tsc"
66
},
77
"exports": {
8-
".": "./src/main.ts"
8+
".": "./src/main.ts",
9+
"./loro": "./src/loro.ts"
910
},
1011
"devDependencies": {},
12+
"peerDependencies": {
13+
"loro-crdt": "^1.4.2"
14+
},
1115
"dependencies": {
1216
"@effect/platform": "^0.77.2",
1317
"effect": "^3.13.2"

packages/sync/src/loro.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { Effect, ParseResult, Schema } from "effect";
2+
import { LoroDoc, LoroList, LoroMap } from "loro-crdt";
3+
4+
type LoroSchema = {
5+
activity: LoroList<LoroMap<typeof Activity.Encoded>>;
6+
};
7+
8+
class Activity extends Schema.Class<Activity>("Activity")({
9+
id: Schema.UUID,
10+
name: Schema.String,
11+
}) {}
12+
13+
class SnapshotSchema extends Schema.Class<SnapshotSchema>("SnapshotSchema")({
14+
activity: Schema.Array(Activity),
15+
}) {}
16+
17+
const SnapshotSchemaTransform = Schema.instanceOf(LoroDoc<LoroSchema>).pipe(
18+
Schema.transformOrFail(SnapshotSchema, {
19+
decode: (from, _, ast) =>
20+
Schema.decodeUnknown(SnapshotSchema)(from.toJSON()).pipe(
21+
Effect.mapError(
22+
(error) => new ParseResult.Type(ast, from, error.message)
23+
)
24+
),
25+
26+
encode: (to) => {
27+
const doc = new LoroDoc<LoroSchema>();
28+
const activity = doc.getList("activity");
29+
for (let i = 0; i < to.activity.length; i++) {
30+
const item = to.activity[i];
31+
if (item !== undefined) {
32+
const map = activity.get(i);
33+
34+
for (const [key, value] of Object.entries(item)) {
35+
// Unsafe!
36+
map.set(key as keyof typeof item, value);
37+
}
38+
}
39+
}
40+
return ParseResult.succeed(doc);
41+
},
42+
})
43+
);
44+
45+
export const Snapshot = Schema.Uint8Array;
46+
export const LoroSchemaTransform = Schema.instanceOf(LoroDoc<LoroSchema>).pipe(
47+
Schema.transformOrFail(Snapshot, {
48+
decode: (from, _, ast) =>
49+
Schema.encode(Snapshot)(from.export({ mode: "snapshot" })).pipe(
50+
Effect.mapError(
51+
(error) => new ParseResult.Type(ast, from, error.message)
52+
)
53+
),
54+
55+
encode: (to, _, ast) =>
56+
Schema.decode(Snapshot)(to).pipe(
57+
Effect.flatMap((data) =>
58+
Effect.gen(function* () {
59+
const doc = new LoroDoc<LoroSchema>();
60+
doc.import(data);
61+
yield* Schema.decodeUnknown(SnapshotSchema)(doc.toJSON());
62+
return doc;
63+
})
64+
),
65+
Effect.mapError((error) => new ParseResult.Type(ast, to, error.message))
66+
),
67+
})
68+
);

packages/sync/src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import {
55
HttpApiSchema,
66
} from "@effect/platform";
77
import { Schema } from "effect";
8+
import { LoroSchemaTransform } from "./loro";
89

910
export const ClientId = Schema.UUID;
1011
export const WorkspaceId = Schema.UUID;
1112
export const Scope = Schema.Literal("read", "read_write");
12-
export const Snapshot = Schema.Uint8Array;
1313

1414
export class ClientTable extends Schema.Class<ClientTable>("ClientTable")({
1515
clientId: ClientId,
@@ -23,7 +23,7 @@ export class WorkspaceTable extends Schema.Class<WorkspaceTable>(
2323
ownerClientId: ClientId,
2424
createdAt: Schema.DateFromString,
2525
clientId: ClientId,
26-
snapshot: Snapshot,
26+
snapshot: LoroSchemaTransform,
2727
}) {}
2828

2929
export class TokenTable extends Schema.Class<TokenTable>("TokenTable")({

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)