initial commit. little plaintext ORDT demo
This commit is contained in:
commit
09e5954fb7
16 changed files with 791 additions and 0 deletions
0
.gitignore
vendored
Normal file
0
.gitignore
vendored
Normal file
4
.prettierrc
Normal file
4
.prettierrc
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"printWidth": 96,
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
}
|
7
_build.ts
Normal file
7
_build.ts
Normal 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
2
client/dist/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!/.gitignore
|
28
client/index.html
Normal file
28
client/index.html
Normal 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
74
client/main.ts
Normal 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
12
deno.json
Normal 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
226
deno.lock
Normal 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
6
proto.ts
Normal 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
60
server.ts
Normal 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
16
sync/common.ts
Normal 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
31
sync/crdt/tiny-lww.ts
Normal 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
51
sync/ordered-set.ts
Normal 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
40
sync/ordt/causal-tree.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
95
sync/ordt/historical-lww.ts
Normal file
95
sync/ordt/historical-lww.ts
Normal 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
139
sync/ordt/plain-text.ts
Normal 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;
|
||||||
|
} */
|
||||||
|
}
|
Loading…
Reference in a new issue