diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..deb6b0a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.yarn/install-state.gz diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 6af1922..8c51572 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/package.json b/package.json index dcf7c20..908a5fe 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,11 @@ "typecheck": "yarn generate && tsc --noEmit" }, "dependencies": { + "@automerge/automerge": "^3.3.0", "@bufbuild/protobuf": "^2.12.1", "@connectrpc/connect": "^2.1.2", - "@connectrpc/connect-node": "^2.1.2" + "@connectrpc/connect-node": "^2.1.2", + "@quixos/camino-datatypes": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/camino-datatypes.git#commit=fa50782559944b88c410e264acadf79266b70b43" }, "devDependencies": { "@bufbuild/protoc-gen-es": "^2.12.1", diff --git a/src/index.ts b/src/index.ts index 588fb5f..a5f644f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,12 @@ import { type HandlerContext, type Interceptor, } from "@connectrpc/connect"; +import * as Automerge from "@automerge/automerge"; +import { + encodeAutomergeFieldWrite, + loadAutomergeFieldDoc, + type CaminoAutomergeDoc, +} from "@quixos/camino-datatypes/crdt-automerge"; import { connectNodeAdapter, createConnectTransport, @@ -183,6 +189,11 @@ export type Field = { set(value: T): Promise; }; +export type CrdtField = Field> & { + change(fn: (doc: { value: T }) => void): Promise>; + replaceFromJson(value: T): Promise; +}; + export type LiveFieldValue = { $caminoValue: T; $caminoSource: { @@ -475,6 +486,11 @@ const bytesToBase64 = (value: Uint8Array) => const base64ToBytes = (value: string) => Buffer.from(value, "base64"); +type FieldCodec = { + fieldType?: string; + conflictStrategy?: string; +}; + const sourceToProto = (value: unknown) => { if (!isRecord(value) || !isRecord(value.field)) { return undefined; @@ -507,12 +523,30 @@ const sourceToProto = (value: unknown) => { }); }; +const codecFromSource = (value: unknown): FieldCodec | undefined => { + if (!isRecord(value) || !isRecord(value.field)) { + return undefined; + } + const field = value.field; + return { + ...(typeof field.fieldType === "string" ? { fieldType: field.fieldType } : {}), + ...(typeof field.conflictStrategy === "string" + ? { conflictStrategy: field.conflictStrategy } + : {}), + }; +}; + const withSource = (value: Value, source: unknown): Value => { const sourceValue = sourceToProto(source); return sourceValue ? create(ValueSchema, { ...value, source: sourceValue }) : value; }; -export const jsToProtoValue = (value: unknown): Value => { +export const jsToProtoValue = (value: unknown, codec?: FieldCodec): Value => { + if (codec?.conflictStrategy === "crdt" && codec.fieldType) { + return jsToProtoValue( + encodeAutomergeFieldWrite(value, codec.fieldType), + ); + } if (value === null || value === undefined) { return create(ValueSchema, { kind: { @@ -551,14 +585,17 @@ export const jsToProtoValue = (value: unknown): Value => { kind: { case: "listValue", value: create(ListValueSchema, { - values: value.map(jsToProtoValue), + values: value.map((entry) => jsToProtoValue(entry)), }), }, }); } if (isRecord(value)) { if (Object.hasOwn(value, "$caminoValue") && isRecord(value.$caminoSource)) { - return withSource(jsToProtoValue(value.$caminoValue), value.$caminoSource); + return withSource( + jsToProtoValue(value.$caminoValue, codecFromSource(value.$caminoSource)), + value.$caminoSource, + ); } if (typeof value.$caminoRef === "string") { return create(ValueSchema, { @@ -631,11 +668,12 @@ export const protoValueToJs = (value: Value | undefined): unknown => { case "refValue": return value.kind.value.objectId; case "crdtValue": - return { + return loadAutomergeFieldDoc({ $caminoCrdtType: value.kind.value.type, - $caminoCrdtEncoding: value.kind.value.encoding, + $caminoCrdtEncoding: + value.kind.value.encoding === "base64" ? "base64" : "base64", $caminoCrdtPayload: bytesToBase64(value.kind.value.payload), - }; + }, value.kind.value.type); } }; @@ -761,6 +799,7 @@ export const createField = ( camino: CaminoClient, objectId: string, fieldName: string, + codec?: FieldCodec, ): Field => ({ async get() { recordDerivedDependency({ kind: "field", objectId, fieldName }); @@ -771,11 +810,44 @@ export const createField = ( await camino.setField({ objectId, fieldName, - value: jsToProtoValue(value), + value: jsToProtoValue(value, codec), }); }, }); +export const createCrdtField = ( + camino: CaminoClient, + objectId: string, + fieldName: string, + fieldType: string, +): CrdtField => { + const field = createField>(camino, objectId, fieldName, { + fieldType, + conflictStrategy: "crdt", + }); + return { + ...field, + async change(fn) { + const current = await field.get(); + if (!current) { + throw new Error(`Cannot change missing CRDT field ${fieldName}`); + } + const next = Automerge.change(current, (doc) => { + fn(doc as { value: T }); + }) as CaminoAutomergeDoc; + await field.set(next); + return next; + }, + async replaceFromJson(value) { + await camino.setField({ + objectId, + fieldName, + value: jsToProtoValue(value, { fieldType, conflictStrategy: "crdt" }), + }); + }, + }; +}; + const createDependencyTrackingCaminoClient = ( camino: CaminoClient, ): CaminoClient => diff --git a/yarn-project.nix b/yarn-project.nix index f09f13d..ec9f010 100644 --- a/yarn-project.nix +++ b/yarn-project.nix @@ -156,11 +156,13 @@ let overriddenProject = optionalOverride overrideAttrs project; cacheEntries = { +"@automerge/automerge@npm:3.3.0" = { filename = "@automerge-automerge-npm-3.3.0-3f6661ca17-74b3a8ef9e.zip"; hash = "sha512-dLOo7548v0B2DomHJ+za1RKOUv0rBTgJRUrnsrhbTsZHw8uipjXNv4MfIxVzGtQl2jenhLeb8TvIbsKKfpKv8g=="; }; "@bufbuild/protobuf@npm:2.12.1" = { filename = "@bufbuild-protobuf-npm-2.12.1-d8afb467b3-2d40cfe4ec.zip"; hash = "sha512-LUDP5Ow7sOPwarLih3HcXcVm6qywVfvok9dWIbBAfQJy1I9ApJCtR97YOe1UFRThr3G7E68CSzrjbvDFbVbdqQ=="; }; "@bufbuild/protoc-gen-es@npm:2.12.1" = { filename = "@bufbuild-protoc-gen-es-npm-2.12.1-6e9387fcb8-0e2805376d.zip"; hash = "sha512-DigFN22Lmj/OD9OdkPZa4dH/vqMSvi2tk2f3xyEqvFHcKleC531iqPhh7De9xtG9MM+sVRDRJCeCzXA/kH4f9Q=="; }; "@bufbuild/protoplugin@npm:2.12.1" = { filename = "@bufbuild-protoplugin-npm-2.12.1-e1aebb80bf-c7b401ab31.zip"; hash = "sha512-x7QBqzHZSsaCXLm75c/3XQXSjuINRnskt6XUKZuaygZmhuo5t6OoRTuLWgGD+qd4HoP8LliAZhgHudMVX3Hr6Q=="; }; "@connectrpc/connect-node@npm:2.1.2" = { filename = "@connectrpc-connect-node-npm-2.1.2-021434d5d5-ba8b0c9384.zip"; hash = "sha512-uosMk4TDgps3eqeoJtwM1Qpgk1J9LscBRgSRKDYzbqlgSitazqQrEEd/i+RWnknd7WbKD8d/X1sCcDXgKeE+qQ=="; }; "@connectrpc/connect@npm:2.1.2" = { filename = "@connectrpc-connect-npm-2.1.2-ab34825de3-4e15000890.zip"; hash = "sha512-ThUACJAjt1Lv1E+pQKWuHh+gDhvrxLoM7ScruTWSDYw7hbd0UOZErUQruMqaJ1sMFxNviWuo7/A2F4Ex2wHWFQ=="; }; +"@quixos/camino-datatypes@https://gitea-external.egads.tutti.syntaxblitz.net/quixos/camino-datatypes.git#commit=fa50782559944b88c410e264acadf79266b70b43" = { filename = "@quixos-camino-datatypes-https-08035afa77-0223fd49ac.zip"; hash = "sha512-AiP9SawAVbzD/srTymZRhXZH01djfCteKuHWkkk2wBr66OPF0AaUy3stZyxwEgJrRh18cFcpTc4zcjWsu7mGAg=="; }; "@types/node@npm:24.13.3" = { filename = "@types-node-npm-24.13.3-b512a0bbeb-a5bc08f49b.zip"; hash = "sha512-pbwI9JuVgdzcqQ4CzXcZejx5mECAdmSULlzVFhpVPU0q6AZPfjKUQQ10jX0Ji1wYSL5/pQwG8pxBk6sWvk5Z6w=="; }; "@typescript/vfs@npm:1.6.4" = { filename = "@typescript-vfs-npm-1.6.4-78935c9d3e-acb9de42f2.zip"; hash = "sha512-rLneQvI/2j9149eQC6EG7zI6L0588OSpTbtFfibTU8pjuzUZPtGjL8hzPzrlkJlhKykLBr1iIWlIYb65v7YvYg=="; }; "debug@npm:4.4.3" = { filename = "debug-npm-4.4.3-0105c6123a-d79136ec6c.zip"; hash = "sha512-15E27GyD7L79D2pVk9pqnJHsTX3cS1TIg9bnHsmsy19noaXpbQCjKBlrW1yG02XpjYo6cIVqrxa057GYXmf1pg=="; }; diff --git a/yarn.lock b/yarn.lock index 855060c..13bc8a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,13 @@ __metadata: version: 9 cacheKey: 10c0 +"@automerge/automerge@npm:^3.3.0": + version: 3.3.0 + resolution: "@automerge/automerge@npm:3.3.0" + checksum: 10c0/74b3a8ef9e3cbf40760e898727ecdad5128e52fd2b053809454ae7b2b85b4ec647c3cba2a635cdbf831f2315731ad425da37a784b79bf13bc86ec28a7e92aff2 + languageName: node + linkType: hard + "@bufbuild/protobuf@npm:2.12.1, @bufbuild/protobuf@npm:^2.12.1": version: 2.12.1 resolution: "@bufbuild/protobuf@npm:2.12.1" @@ -59,14 +66,25 @@ __metadata: languageName: node linkType: hard +"@quixos/camino-datatypes@https://gitea-external.egads.tutti.syntaxblitz.net/quixos/camino-datatypes.git#commit=fa50782559944b88c410e264acadf79266b70b43": + version: 0.1.0 + resolution: "@quixos/camino-datatypes@https://gitea-external.egads.tutti.syntaxblitz.net/quixos/camino-datatypes.git#commit=fa50782559944b88c410e264acadf79266b70b43" + dependencies: + "@automerge/automerge": "npm:^3.3.0" + checksum: 10c0/0223fd49ac0055bcc3fecad3ca6651857647d357637c2b5e2ae1d6924936c01afae8e3c5d00694cb7b2d672c7012026b461d7c7057294dce337235acbbb98602 + languageName: node + linkType: hard + "@quixos/camino-package-runtime@workspace:.": version: 0.0.0-use.local resolution: "@quixos/camino-package-runtime@workspace:." dependencies: + "@automerge/automerge": "npm:^3.3.0" "@bufbuild/protobuf": "npm:^2.12.1" "@bufbuild/protoc-gen-es": "npm:^2.12.1" "@connectrpc/connect": "npm:^2.1.2" "@connectrpc/connect-node": "npm:^2.1.2" + "@quixos/camino-datatypes": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/camino-datatypes.git#commit=fa50782559944b88c410e264acadf79266b70b43" "@types/node": "npm:^24" typescript: "npm:^5.9.3" languageName: unknown