initial commit. little plaintext ORDT demo

This commit is contained in:
Charlotte Som 2025-03-05 12:45:08 +00:00
commit 09e5954fb7
16 changed files with 791 additions and 0 deletions

0
.gitignore vendored Normal file
View file

4
.prettierrc Normal file
View file

@ -0,0 +1,4 @@
{
"printWidth": 96,
"arrowParens": "avoid"
}

7
_build.ts Normal file
View file

@ -0,0 +1,7 @@
import { build } from "@char/aftercare/esbuild";
await build({
in: ["./client/main.ts"],
outDir: "./client/dist",
watch: Deno.args.includes("--watch"),
});

2
client/dist/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!/.gitignore

28
client/index.html Normal file
View file

@ -0,0 +1,28 @@
<!doctype html>
<meta charset="utf-8" />
<style>
:root,
input,
textarea {
font-family: sans-serif;
}
main {
display: flex;
margin: 0 auto;
width: fit-content;
padding: 1em;
}
textarea {
resize: none;
overflow-y: scroll;
white-space: pre-wrap;
}
</style>
<main>
<textarea cols="80" rows="20"></textarea>
</main>
<script type="module" src="/dist/main.js"></script>

74
client/main.ts Normal file
View file

@ -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<Packet & { t: "init" }>();
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<PlainTextOperation> = {
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<PlainTextOperation> = {
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 });

12
deno.json Normal file
View file

@ -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"
}
}

226
deno.lock Normal file
View file

@ -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"
]
}
}

6
proto.ts Normal file
View file

@ -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<PlainTextOperation>[]; you: string }
| { t: "op"; op: WeakCausalTreeOp<PlainTextOperation> };

60
server.ts Normal file
View file

@ -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<WebSocket>();
// 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 });

16
sync/common.ts Normal file
View file

@ -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<any> = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
export const timestampCompare: Comparator<Timestamp> = (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;

31
sync/crdt/tiny-lww.ts Normal file
View file

@ -0,0 +1,31 @@
import { PeerId, Primitive, Tid } from "../common.ts";
export class TinyLWW<Shape extends Record<string, Primitive>> {
values: Partial<Shape> = {};
lastWriters = new Map<keyof Shape, PeerId>();
clock = new Map<PeerId, Tid>();
set<K extends keyof Shape, V extends Shape[K]>(
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<K extends keyof Shape>(key: K): Shape[K] | undefined {
return this.values[key];
}
}

51
sync/ordered-set.ts Normal file
View file

@ -0,0 +1,51 @@
export type Comparator<T> = (a: T, b: T) => number;
/** ordered contiguous set of items (backing array exposed) */
export class OrderedSet<T> {
items: T[] = [];
constructor(readonly comparator: Comparator<T> = (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]();
}
}

40
sync/ordt/causal-tree.ts Normal file
View file

@ -0,0 +1,40 @@
import { Timestamp, timestampCompare } from "../common.ts";
export type CausalTreeOp<T> = T & { at: Timestamp; parent?: CausalTreeOp<T> };
// parent is referred to via timestamp instead of reference here
// makes the api simpler
export type WeakCausalTreeOp<T> = T & { at: Timestamp; parent?: Timestamp };
export type AnyCausalTreeOp<T> = CausalTreeOp<T> | WeakCausalTreeOp<T>;
export class CausalTree<T> {
operations: CausalTreeOp<T>[] = [];
clock: number = 0;
/** WARN: mutates 'op' for performance (in-place conversion from weak op to strong) */
apply(op: AnyCausalTreeOp<T>): 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<T>;
storedOp.parent = this.operations[parentOpIdx];
this.operations.splice(idx, 0, storedOp);
this.clock = Math.max(this.clock, storedOp.at[1]);
return idx;
}
static toWeakOp<T>(op: CausalTreeOp<T>): WeakCausalTreeOp<T> {
return { ...op, parent: op.parent?.at };
}
}

View file

@ -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<Shape extends Record<string, Primitive>> = {
timestamp: Timestamp;
key: keyof Shape;
value: Shape[keyof Shape] | typeof tombstone;
};
/** a last-write-wins key/value register with history */
export class HistoricalLWW<Shape extends Record<string, Primitive>> {
// an OrderedSet<Operation> is an ORDT
operations: OrderedSet<MultiLWWOperation<Shape>>;
// we keep some indices for performance:
lookup = new Map<keyof Shape, number>();
latest: Tid = -1;
constructor() {
type Operation = MultiLWWOperation<Shape>;
const comparator: Comparator<Operation> = (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<MultiLWWOperation<Shape>>(comparator);
}
// prettier-ignore
set<K extends keyof Shape, V extends Shape[K]>(
key: K, value: V,
from: PeerId, at: Tid = this.latest + 1,
) {
this.#apply({ key, value, timestamp: [from, at] });
}
delete<K extends keyof Shape>(key: K, from: PeerId, at: Tid = this.latest + 1) {
this.#apply({ key, value: tombstone, timestamp: [from, at] });
}
get<K extends keyof Shape, V extends Shape[K]>(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<Shape>) {
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<Shape>[]) {
for (const op of operations) this.#apply(op);
}
merge(operations: MultiLWWOperation<Shape>[]) {
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();
}
}

139
sync/ordt/plain-text.ts Normal file
View file

@ -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<PlainTextOperation> {
onevent?: (op: CausalTreeOp<PlainTextOperation>) => 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<PlainTextOperation>): number {
const opIdx = super.apply(op);
op = this.operations[opIdx];
this.#applyCacheUpdate(op, opIdx);
this.onevent?.(op);
return opIdx;
}
#applyCacheUpdate(op: CausalTreeOp<PlainTextOperation>, 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;
} */
}