commit 09e5954fb79f9ce8efa7b4f21146bb25812ae9f8 Author: Charlotte Som Date: Wed Mar 5 12:45:08 2025 +0000 initial commit. little plaintext ORDT demo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..33f1597 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "printWidth": 96, + "arrowParens": "avoid" +} diff --git a/_build.ts b/_build.ts new file mode 100644 index 0000000..e776635 --- /dev/null +++ b/_build.ts @@ -0,0 +1,7 @@ +import { build } from "@char/aftercare/esbuild"; + +await build({ + in: ["./client/main.ts"], + outDir: "./client/dist", + watch: Deno.args.includes("--watch"), +}); diff --git a/client/dist/.gitignore b/client/dist/.gitignore new file mode 100644 index 0000000..120f485 --- /dev/null +++ b/client/dist/.gitignore @@ -0,0 +1,2 @@ +* +!/.gitignore diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..08dee41 --- /dev/null +++ b/client/index.html @@ -0,0 +1,28 @@ + + + + +
+ +
+ + diff --git a/client/main.ts b/client/main.ts new file mode 100644 index 0000000..966a962 --- /dev/null +++ b/client/main.ts @@ -0,0 +1,74 @@ +import diff from "npm:fast-diff"; +import { Packet } from "../proto.ts"; +import { CausalTree, CausalTreeOp } from "../sync/ordt/causal-tree.ts"; +import { PlainTextOperation, PlainTextORDT } from "../sync/ordt/plain-text.ts"; + +const textarea = document.querySelector("textarea")!; + +const pt = new PlainTextORDT(); +let me: string | undefined = undefined; + +pt.onevent = event => { + if (event.at[0] === me) return; + textarea.value = pt.render()[0]; +}; + +const socket = new WebSocket("/api/connect"); +const initialized = Promise.withResolvers(); + +socket.addEventListener("message", ev => { + const packet = JSON.parse(ev.data) as Packet; + if (packet.t === "init") { + for (const op of packet.ops) pt.apply(op); + initialized.resolve(packet); + } else if (packet.t === "op") { + pt.apply(packet.op); + } +}); + +const initPacket = await initialized.promise; +me = initPacket.you; +textarea.value = pt.render()[0]; + +textarea.addEventListener("input", () => { + const text = pt.render()[0]; + const diffResults = diff(text, textarea.value, textarea.selectionStart); + let idx = 0; + for (const [id, str] of diffResults) { + if (id === diff.INSERT) { + let parentOp = pt.operations[pt.findOpAtTextIndex(idx)]; + for (const glyph of str) { + const op: CausalTreeOp = { + type: "insert", + at: [me, ++pt.clock], + parent: parentOp, + sequence: glyph, + }; + + pt.apply(op); + socket.send(JSON.stringify(CausalTree.toWeakOp(op))); + + parentOp = op; + idx += glyph.length; + } + } + if (id === diff.EQUAL) { + idx += str.length; + } + if (id === diff.DELETE) { + for (const glyph of str) { + const parentOp = pt.operations[pt.findOpAtTextIndex(idx + glyph.length)]; + const op: CausalTreeOp = { + type: "delete", + at: [me, ++pt.clock], + parent: parentOp, + }; + pt.apply(op); + socket.send(JSON.stringify(CausalTree.toWeakOp(op))); + } + } + } +}); + +Object.defineProperty(globalThis, "pt", { value: pt }); +Object.defineProperty(globalThis, "textarea", { value: textarea }); diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..8e26a11 --- /dev/null +++ b/deno.json @@ -0,0 +1,12 @@ +{ + "version": "0.1.0", + "imports": { + "@char/aftercare": "jsr:@char/aftercare@^0.3.0", + "@oak/oak": "jsr:@oak/oak@^17.1.4" + }, + "compilerOptions": { + "lib": ["deno.window", "dom"], + "jsx": "react-jsx", + "jsxImportSource": "@char/aftercare" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..e212292 --- /dev/null +++ b/deno.lock @@ -0,0 +1,226 @@ +{ + "version": "4", + "specifiers": { + "jsr:@char/aftercare@0.3": "0.3.0", + "jsr:@luca/esbuild-deno-loader@0.11": "0.11.1", + "jsr:@oak/commons@1": "1.0.0", + "jsr:@oak/oak@^17.1.4": "17.1.4", + "jsr:@std/assert@1": "1.0.11", + "jsr:@std/bytes@1": "1.0.4", + "jsr:@std/bytes@^1.0.2": "1.0.4", + "jsr:@std/cli@1": "1.0.11", + "jsr:@std/crypto@1": "1.0.3", + "jsr:@std/dotenv@0.225": "0.225.3", + "jsr:@std/encoding@1": "1.0.6", + "jsr:@std/encoding@^1.0.5": "1.0.6", + "jsr:@std/http@1": "1.0.12", + "jsr:@std/media-types@1": "1.1.0", + "jsr:@std/path@1": "1.0.8", + "jsr:@std/path@^1.0.6": "1.0.8", + "npm:@types/node@*": "22.12.0", + "npm:esbuild@0.24": "0.24.2", + "npm:fast-diff@*": "1.3.0", + "npm:path-to-regexp@^6.3.0": "6.3.0" + }, + "jsr": { + "@char/aftercare@0.3.0": { + "integrity": "0960c67f07bc0b70b1c2e6ba464a2fe4eab05fe853b1c4caf2213cff85399ba0", + "dependencies": [ + "jsr:@luca/esbuild-deno-loader", + "jsr:@std/cli", + "jsr:@std/dotenv", + "jsr:@std/path@1", + "npm:esbuild" + ] + }, + "@luca/esbuild-deno-loader@0.11.1": { + "integrity": "dc020d16d75b591f679f6b9288b10f38bdb4f24345edb2f5732affa1d9885267", + "dependencies": [ + "jsr:@std/bytes@^1.0.2", + "jsr:@std/encoding@^1.0.5", + "jsr:@std/path@^1.0.6" + ] + }, + "@oak/commons@1.0.0": { + "integrity": "49805b55603c3627a9d6235c0655aa2b6222d3036b3a13ff0380c16368f607ac", + "dependencies": [ + "jsr:@std/assert", + "jsr:@std/bytes@1", + "jsr:@std/crypto", + "jsr:@std/encoding@1", + "jsr:@std/http", + "jsr:@std/media-types" + ] + }, + "@oak/oak@17.1.4": { + "integrity": "60530b582bf276ff741e39cc664026781aa08dd5f2bc5134d756cc427bf2c13e", + "dependencies": [ + "jsr:@oak/commons", + "jsr:@std/assert", + "jsr:@std/bytes@1", + "jsr:@std/http", + "jsr:@std/media-types", + "jsr:@std/path@1", + "npm:path-to-regexp" + ] + }, + "@std/assert@1.0.11": { + "integrity": "2461ef3c368fe88bc60e186e7744a93112f16fd110022e113a0849e94d1c83c1" + }, + "@std/bytes@1.0.4": { + "integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc" + }, + "@std/cli@1.0.11": { + "integrity": "ec219619fdcd31bcf0d8e53bee1e2706ec9a02f70255365a094f69755dadd340" + }, + "@std/crypto@1.0.3": { + "integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f" + }, + "@std/dotenv@0.225.3": { + "integrity": "a95e5b812c27b0854c52acbae215856d9cce9d4bbf774d938c51d212711e8d4a" + }, + "@std/encoding@1.0.6": { + "integrity": "ca87122c196e8831737d9547acf001766618e78cd8c33920776c7f5885546069" + }, + "@std/http@1.0.12": { + "integrity": "85246d8bfe9c8e2538518725b158bdc31f616e0869255f4a8d9e3de919cab2aa", + "dependencies": [ + "jsr:@std/encoding@^1.0.5" + ] + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/path@1.0.8": { + "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" + } + }, + "npm": { + "@esbuild/aix-ppc64@0.24.2": { + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==" + }, + "@esbuild/android-arm64@0.24.2": { + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==" + }, + "@esbuild/android-arm@0.24.2": { + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==" + }, + "@esbuild/android-x64@0.24.2": { + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==" + }, + "@esbuild/darwin-arm64@0.24.2": { + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==" + }, + "@esbuild/darwin-x64@0.24.2": { + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==" + }, + "@esbuild/freebsd-arm64@0.24.2": { + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==" + }, + "@esbuild/freebsd-x64@0.24.2": { + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==" + }, + "@esbuild/linux-arm64@0.24.2": { + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==" + }, + "@esbuild/linux-arm@0.24.2": { + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==" + }, + "@esbuild/linux-ia32@0.24.2": { + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==" + }, + "@esbuild/linux-loong64@0.24.2": { + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==" + }, + "@esbuild/linux-mips64el@0.24.2": { + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==" + }, + "@esbuild/linux-ppc64@0.24.2": { + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==" + }, + "@esbuild/linux-riscv64@0.24.2": { + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==" + }, + "@esbuild/linux-s390x@0.24.2": { + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==" + }, + "@esbuild/linux-x64@0.24.2": { + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==" + }, + "@esbuild/netbsd-arm64@0.24.2": { + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==" + }, + "@esbuild/netbsd-x64@0.24.2": { + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==" + }, + "@esbuild/openbsd-arm64@0.24.2": { + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==" + }, + "@esbuild/openbsd-x64@0.24.2": { + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==" + }, + "@esbuild/sunos-x64@0.24.2": { + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==" + }, + "@esbuild/win32-arm64@0.24.2": { + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==" + }, + "@esbuild/win32-ia32@0.24.2": { + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==" + }, + "@esbuild/win32-x64@0.24.2": { + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==" + }, + "@types/node@22.12.0": { + "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", + "dependencies": [ + "undici-types" + ] + }, + "esbuild@0.24.2": { + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ] + }, + "fast-diff@1.3.0": { + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==" + }, + "path-to-regexp@6.3.0": { + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" + }, + "undici-types@6.20.0": { + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@char/aftercare@0.3", + "jsr:@oak/oak@^17.1.4" + ] + } +} diff --git a/proto.ts b/proto.ts new file mode 100644 index 0000000..8ae481a --- /dev/null +++ b/proto.ts @@ -0,0 +1,6 @@ +import { WeakCausalTreeOp } from "./sync/ordt/causal-tree.ts"; +import { PlainTextOperation } from "./sync/ordt/plain-text.ts"; + +export type Packet = + | { t: "init"; ops: WeakCausalTreeOp[]; you: string } + | { t: "op"; op: WeakCausalTreeOp }; diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..42acb54 --- /dev/null +++ b/server.ts @@ -0,0 +1,60 @@ +import { Application, Router } from "@oak/oak"; +import { Packet } from "./proto.ts"; +import { CausalTree } from "./sync/ordt/causal-tree.ts"; +import { PlainTextORDT } from "./sync/ordt/plain-text.ts"; + +const app = new Application(); +const router = new Router(); + +const pt = new PlainTextORDT(); +const sockets = new Set(); + +// simple websocket broadcast +router.get("/api/connect", ctx => { + const socket = ctx.upgrade(); + sockets.add(socket); + socket.addEventListener("message", event => { + if (typeof event.data !== "string") return; + + const op = JSON.parse(event.data); + pt.apply(op); // mutates op to strong + const weakOp = CausalTree.toWeakOp(op); + + for (const other of sockets) { + if (other === socket) continue; + other.send(JSON.stringify({ t: "op", op: weakOp })); + } + }); + socket.addEventListener("close", () => { + sockets.delete(socket); + }); + + const onReady = () => { + socket.send( + JSON.stringify({ + t: "init", + ops: pt.operations.map(it => CausalTree.toWeakOp(it)), + you: crypto.randomUUID(), + } satisfies Packet), + ); + }; + if (socket.readyState === WebSocket.OPEN) { + onReady(); + } else { + socket.addEventListener("open", () => onReady()); + } +}); + +router.get("/:path*", async ctx => { + try { + await ctx.send({ root: "./client", index: "index.html" }); + } catch { + // ignore + } +}); + +app.use(router.routes()); +app.use(router.allowedMethods()); + +console.log("Listening at: http://127.0.0.1:3000/ ..."); +app.listen({ port: 3000 }); diff --git a/sync/common.ts b/sync/common.ts new file mode 100644 index 0000000..5906e54 --- /dev/null +++ b/sync/common.ts @@ -0,0 +1,16 @@ +import { Comparator } from "./ordered-set.ts"; + +export type PeerId = string; +export type Tid = number; +export type Timestamp = [PeerId, Tid]; + +// deno-lint-ignore no-explicit-any +export const basicCompare: Comparator = (a, b) => (a < b ? -1 : a > b ? 1 : 0); + +export const timestampCompare: Comparator = (a, b) => { + const at = basicCompare(a[1], b[1]); + if (at !== 0) return at; + return basicCompare(a[0], b[0]); +}; + +export type Primitive = string | number | boolean | undefined | null; diff --git a/sync/crdt/tiny-lww.ts b/sync/crdt/tiny-lww.ts new file mode 100644 index 0000000..4559b51 --- /dev/null +++ b/sync/crdt/tiny-lww.ts @@ -0,0 +1,31 @@ +import { PeerId, Primitive, Tid } from "../common.ts"; + +export class TinyLWW> { + values: Partial = {}; + lastWriters = new Map(); + clock = new Map(); + + set( + key: K, + value: V, + from: PeerId, + at?: Tid, + ): boolean { + const lastWriter = this.lastWriters.get(key); + at ??= lastWriter ? this.clock.get(lastWriter) : undefined; + at ??= -1; + + if (lastWriter !== undefined && (this.clock.get(lastWriter) ?? -1) >= at) return false; + if ((this.clock.get(from) ?? -1) >= at) return false; + + this.values[key] = value; + this.clock.set(from, at); + this.lastWriters.set(key, from); + + return true; + } + + get(key: K): Shape[K] | undefined { + return this.values[key]; + } +} diff --git a/sync/ordered-set.ts b/sync/ordered-set.ts new file mode 100644 index 0000000..c9118c6 --- /dev/null +++ b/sync/ordered-set.ts @@ -0,0 +1,51 @@ +export type Comparator = (a: T, b: T) => number; + +/** ordered contiguous set of items (backing array exposed) */ +export class OrderedSet { + items: T[] = []; + + constructor(readonly comparator: Comparator = (a, b) => (a < b ? -1 : a > b ? 1 : 0)) {} + + add(item: T, fromEnd: boolean = false): number { + // TODO: binary search ? + + let idx = 0; + if (!fromEnd) { + while (idx < this.items.length) { + const compareVal = this.comparator(this.items[idx], item); + if (compareVal === 0) return -1; + if (compareVal > 0) break; + idx++; + } + } else { + idx = this.items.length - 1; + while (idx > 0) { + const compareVal = this.comparator(this.items[idx], item); + if (compareVal === 0) return -1; + if (compareVal < 0) break; + idx--; + } + } + this.items.splice(idx, 0, item); + return idx; + } + + remove(item: T): number { + const idx = this.items.findIndex(it => this.comparator(item, it) === 0); + if (idx === -1) return idx; + this.items.splice(idx, 1); + return idx; + } + + at(n: number) { + return this.items.at(n); + } + + get length(): number { + return this.items.length; + } + + [Symbol.iterator]() { + return this.items[Symbol.iterator](); + } +} diff --git a/sync/ordt/causal-tree.ts b/sync/ordt/causal-tree.ts new file mode 100644 index 0000000..bfdb443 --- /dev/null +++ b/sync/ordt/causal-tree.ts @@ -0,0 +1,40 @@ +import { Timestamp, timestampCompare } from "../common.ts"; + +export type CausalTreeOp = T & { at: Timestamp; parent?: CausalTreeOp }; +// parent is referred to via timestamp instead of reference here +// makes the api simpler +export type WeakCausalTreeOp = T & { at: Timestamp; parent?: Timestamp }; +export type AnyCausalTreeOp = CausalTreeOp | WeakCausalTreeOp; + +export class CausalTree { + operations: CausalTreeOp[] = []; + clock: number = 0; + + /** WARN: mutates 'op' for performance (in-place conversion from weak op to strong) */ + apply(op: AnyCausalTreeOp): number { + const opParent = op.parent; + const parentOpIdx = this.operations.findIndex( + Array.isArray(opParent) + ? it => timestampCompare(it.at, opParent) === 0 + : it => it === opParent, + ); + if (opParent && parentOpIdx === -1) throw new Error("parent was not found in op log"); + + let idx = parentOpIdx + 1; + for (; idx < this.operations.length; idx++) { + const curr = this.operations[idx]; + if (timestampCompare(curr.at, op.at) < 0) break; + } + + const storedOp = op as CausalTreeOp; + storedOp.parent = this.operations[parentOpIdx]; + this.operations.splice(idx, 0, storedOp); + this.clock = Math.max(this.clock, storedOp.at[1]); + + return idx; + } + + static toWeakOp(op: CausalTreeOp): WeakCausalTreeOp { + return { ...op, parent: op.parent?.at }; + } +} diff --git a/sync/ordt/historical-lww.ts b/sync/ordt/historical-lww.ts new file mode 100644 index 0000000..8c1f4d0 --- /dev/null +++ b/sync/ordt/historical-lww.ts @@ -0,0 +1,95 @@ +import { basicCompare, PeerId, Primitive, Tid, Timestamp } from "../common.ts"; +import { Comparator, OrderedSet } from "../ordered-set.ts"; + +const tombstone = Symbol("multi-lww.tombstone"); + +export type MultiLWWOperation> = { + timestamp: Timestamp; + key: keyof Shape; + value: Shape[keyof Shape] | typeof tombstone; +}; + +/** a last-write-wins key/value register with history */ +export class HistoricalLWW> { + // an OrderedSet is an ORDT + operations: OrderedSet>; + + // we keep some indices for performance: + lookup = new Map(); + latest: Tid = -1; + + constructor() { + type Operation = MultiLWWOperation; + const comparator: Comparator = (a, b) => { + const key = basicCompare(a.key, b.key); + if (key !== 0) return key; + const at = basicCompare(a.timestamp[1], b.timestamp[1]); + if (at !== 0) return at; + return -basicCompare(a.timestamp[0], b.timestamp[0]); + }; + this.operations = new OrderedSet>(comparator); + } + + // prettier-ignore + set( + key: K, value: V, + from: PeerId, at: Tid = this.latest + 1, + ) { + this.#apply({ key, value, timestamp: [from, at] }); + } + + delete(key: K, from: PeerId, at: Tid = this.latest + 1) { + this.#apply({ key, value: tombstone, timestamp: [from, at] }); + } + + get(key: K, at?: Tid): V | undefined { + let idx = this.lookup.get(key); + if (!idx) return undefined; + + let operation = this.operations.items[idx]; + while (at && operation.timestamp[1] > at && idx > 0) { + idx--; + operation = this.operations.items[idx]; + } + + const v = operation.value; + if (v === tombstone) return undefined; + return v as V; + } + + #apply(op: MultiLWWOperation) { + const resultingIndex = this.operations.add(op); + for (const [key, index] of this.lookup.entries()) { + if (index >= resultingIndex) this.lookup.set(key, index + 1); + } + const existingIndex = this.lookup.get(op.key); + if (existingIndex === undefined || existingIndex < resultingIndex) + this.lookup.set(op.key, resultingIndex); + if (op.timestamp[1] > this.latest) this.latest = op.timestamp[1]; + } + + #recalculateLookup() { + const len = this.operations.length; + for (let idx = 0; idx < len; idx++) { + const operation = this.operations.items[idx]; + this.lookup.set(operation.key, idx); + } + } + + patch(operations: MultiLWWOperation[]) { + for (const op of operations) this.#apply(op); + } + + merge(operations: MultiLWWOperation[]) { + this.operations.items.push(...operations); + this.operations.items.sort(this.operations.comparator); + this.#recalculateLookup(); + } + + compact(since?: Tid) { + this.operations.items = this.operations.items.filter( + (it, idx) => (since && it.timestamp[1] >= since) || this.lookup.get(it.key) === idx, + ); + this.#recalculateLookup(); + } +} diff --git a/sync/ordt/plain-text.ts b/sync/ordt/plain-text.ts new file mode 100644 index 0000000..091be8b --- /dev/null +++ b/sync/ordt/plain-text.ts @@ -0,0 +1,139 @@ +import { Timestamp, timestampCompare } from "../common.ts"; +import { AnyCausalTreeOp, CausalTree, CausalTreeOp } from "./causal-tree.ts"; + +export type PlainTextOperation = + | { + type: "insert"; + sequence: string; + } + | { type: "delete" }; + +export class PlainTextORDT extends CausalTree { + onevent?: (op: CausalTreeOp) => void; + + // caches for insert op <=> text transformation, + // all these arrays should be the same length (SoA) + opIndexCache: number[] = []; + sequences: string[] = []; + timestamps: Timestamp[] = []; + deleted: boolean[] = []; + + override apply(op: AnyCausalTreeOp): number { + const opIdx = super.apply(op); + op = this.operations[opIdx]; + this.#applyCacheUpdate(op, opIdx); + this.onevent?.(op); + return opIdx; + } + + #applyCacheUpdate(op: CausalTreeOp, opIdx: number) { + for (let i = 0; i < this.opIndexCache.length; i++) { + if (this.opIndexCache[i] >= opIdx) { + this.opIndexCache[i] += 1; + } + } + + const parentTimestamp = op.parent?.at; + const parentCacheIdx = this.timestamps.findIndex(it => it === parentTimestamp); + + if (op.type === "insert") { + let idx = parentCacheIdx + 1; + for (; idx < this.timestamps.length; idx++) { + const curr = this.timestamps[idx]; + if (timestampCompare(curr, op.at) < 0) break; + } + + this.sequences.splice(idx, 0, op.sequence); + this.timestamps.splice(idx, 0, op.at); + this.deleted.splice(idx, 0, false); + this.opIndexCache.splice(idx, 0, opIdx); + } + if (op.type === "delete" && parentCacheIdx !== -1) { + this.deleted[parentCacheIdx] = true; + } + } + + findOpAtTextIndex(textIndex: number): number { + let start = 0; + for (let idx = 0; idx < this.sequences.length; idx++) { + if (this.deleted[idx]) continue; + + const sequence = this.sequences[idx]; + if (start < textIndex && textIndex <= start + sequence.length) + return this.opIndexCache[idx]; + + start += this.sequences[idx].length; + } + + return -1; + } + + render() { + let s = ""; + let start = 0; + const metadata = []; + for (let idx = 0; idx < this.sequences.length; idx++) { + if (this.deleted[idx]) continue; + const sequence = this.sequences[idx]; + s += sequence; + metadata.push({ + start, + end: start + sequence.length, + op: this.operations[this.opIndexCache[idx]], + }); + start += sequence.length; + } + return [s, metadata] as const; + } + + /* very slow, uncached: + findOpAtTextIndex(textIndex: number): number { + const [_string, metadata] = this.render(); + + for (const meta of metadata) { + if (meta.start < textIndex && textIndex <= meta.end) + return this.operations.findIndex(it => it === meta.op); + } + return -1; + } + + render() { + const s: string[] = []; + const metadata = []; + let length = 0; + for (const op of this.operations) { + if (op.type === "insert") { + s.push(op.sequence); + metadata.push({ + op, + start: length, + end: length + op.sequence.length, + }); + length += op.sequence.length; + } + if (op.type === "delete") { + if (!op.parent) continue; + if (op.parent.type !== "insert") continue; + + const len = op.parent.sequence.length; + length -= len; + + // since we expect to be tacked on close to the referenced op, + // we can quickly seek back from current position + // (it should only take a few iterations - usually 1) + for (let idx = metadata.length - 1; idx >= 0; idx--) { + const m = metadata[idx]; + m.start -= len; + m.end -= len; + if (m.op === op.parent) { + s.splice(idx, 1); + metadata.splice(idx, 1); + break; + } + } + } + } + + return [s.join(""), metadata] as const; + } */ +}