Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions drafts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@
"dependencies": {
"@automerge/automerge": "3.3.0-fragments.1",
"@automerge/automerge-repo": "2.6.0-subduction.47",
"@inkandswitch/patchwork-elements": "1.0.0",
"@inkandswitch/patchwork-plugins": "^0.0.11",
"@inkandswitch/patchwork-providers": "0.5.1",
"@inkandswitch/patchwork-providers-solid": "0.2.7",
"@inkandswitch/patchwork-elements": "^6.0.2",
"@inkandswitch/patchwork-plugins": "^1.2.5",
"@inkandswitch/patchwork-providers": "^0.5.2",
"@inkandswitch/patchwork-providers-solid": "^0.2.8",
"solid-automerge": "^2.0.1",
"solid-js": "^1.9.14"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@inkandswitch/patchwork-bootloader": "^0.2.8",
"@inkandswitch/patchwork-bootloader": "^0.7.2",
"@typescript-eslint/parser": "^8.66.0",
"eslint": "^9.39.5",
"globals": "^16.5.0",
Expand Down
403 changes: 170 additions & 233 deletions drafts/pnpm-lock.yaml

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion drafts/src/DraftsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export function DraftsSidebar(props: { element: HTMLElement }) {

const selectDraft = (url: AutomergeUrl | null) => {
const handle = checkedOutHandle();
if (!handle) return;
if (!handle || disposed) return;
setScrubber(null);
setBaseliner(null);
handle.change((d) => {
Expand All @@ -181,6 +181,13 @@ export function DraftsSidebar(props: { element: HTMLElement }) {
// Monotonic counter so a slow checkpoint computation can't overwrite a newer
// scrub position (a drag fires one recompute per snapped change).
let scrubSeq = 0;
// The checkout doc outlives this sidebar (the provider reuses it across
// mounts), so work still in flight at unmount must not write into it.
let disposed = false;
onCleanup(() => {
disposed = true;
scrubSeq++;
});

// Recompute and persist the checkpoint from the current head (`scrubber`)
// and baseline (`baseliner`) signals: `to`s follow the head, `from`s follow
Expand Down
104 changes: 82 additions & 22 deletions drafts/src/actor-attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,32 @@ import {
isValidAutomergeUrl,
type AutomergeUrl,
type DocHandle,
type DocHandleChangePayload,
type Repo,
} from "@automerge/automerge-repo/slim";
import * as Automerge from "@automerge/automerge/slim";
import { subscribe } from "@inkandswitch/patchwork-providers";

import type { ActorAttributionDoc, DraftDoc } from "./draft-types.js";
import { editCountsSince } from "./change-group-cache.js";

// Only the writing client knows which Automerge actor ids are its own. Each
// local change reveals one id, which the ActorRecorder attributes to the
// current user's contact. Changes seen before dependencies resolve are
// buffered.
export function createActorRecorder(element: HTMLElement): ActorRecorder {
// Only the writing client knows which Automerge actor ids are its own. The
// recorder watches the docs the user can currently write to (`watch`); each
// local change reveals one id, which it attributes to the current user's
// contact. The attribution doc is resolved through `resolveAttribution` only
// once there is an id to record, so viewing a document creates nothing.
// Changes seen before dependencies resolve are buffered.
export function createActorRecorder(
element: HTMLElement,
repo: Repo,
resolveAttribution: () => Promise<DocHandle<ActorAttributionDoc> | null>
): ActorRecorder {
let contactUrl: AutomergeUrl | null = null;
let attributionHandle: DocHandle<ActorAttributionDoc> | null = null;
let attribution: Promise<void> | null = null;
const recordedActorIds = new Set<string>();
const pendingActorIds = new Set<string>();
const watched = new Map<AutomergeUrl, DocHandle<unknown> | null>();
let disposed = false;

const unsubscribe = subscribe<AutomergeUrl>(
Expand All @@ -32,33 +42,84 @@ export function createActorRecorder(element: HTMLElement): ActorRecorder {
}
);

// `patchInfo.source` is "change" for remote updates too in this
// automerge-repo build, so a local write is recognised by its actor: one of
// the new changes was made by this doc instance's own actor id. Changes that
// only touch `@patchwork` (tools stamping metadata on open) don't count.
const onChange = (payload: DocHandleChangePayload<unknown>) => {
if (disposed) return;
const { before, after } = payload.patchInfo;
const actorId = Automerge.getActorId(after);
if (recordedActorIds.has(actorId)) return;
const since = Automerge.getHeads(before);
const ours = Automerge.getChangesMetaSince(after, since).filter(
(meta) => meta.actor === actorId
);
if (ours.length === 0) return;
void editCountsSince(after, since).then(
(counts) => {
if (disposed || !counts) return;
const edited = ours.some((meta) => {
const c = counts.get(meta.hash);
return !!c && c.additions + c.deletions > 0;
});
if (!edited) return;
pendingActorIds.add(actorId);
flushPendingActors();
},
() => {}
);
};

return {
recordLocalChange(doc) {
watch(urls) {
if (disposed) return;
let actorId: string;
try {
actorId = Automerge.getActorId(doc);
} catch {
return;
const wanted = new Set(urls);
for (const [url, handle] of [...watched]) {
if (wanted.has(url)) continue;
handle?.off("change", onChange);
watched.delete(url);
}
for (const url of wanted) {
if (watched.has(url)) continue;
watched.set(url, null);
repo.find<unknown>(url).then(
(handle) => {
if (disposed || watched.get(url) !== null) return;
watched.set(url, handle);
handle.on("change", onChange);
},
() => {
if (watched.get(url) === null) watched.delete(url);
}
);
}
if (recordedActorIds.has(actorId)) return;
pendingActorIds.add(actorId);
flushPendingActors();
},
setAttributionHandle(handle) {
if (disposed) return;
attributionHandle = handle;
flushPendingActors();
},
dispose() {
disposed = true;
pendingActorIds.clear();
for (const [, handle] of watched) handle?.off("change", onChange);
watched.clear();
unsubscribe();
},
};

function flushPendingActors(): void {
if (!attributionHandle || !contactUrl || pendingActorIds.size === 0) return;
if (disposed || !contactUrl || pendingActorIds.size === 0) return;
if (!attributionHandle) {
attribution ??= resolveAttribution().then(
(handle) => {
if (disposed) return;
attributionHandle = handle;
if (handle) flushPendingActors();
},
(err) => {
attribution = null;
console.warn("[drafts] failed to resolve actor attribution:", err);
}
);
return;
}
const url = contactUrl;
const actorIds = [...pendingActorIds];
pendingActorIds.clear();
Expand Down Expand Up @@ -103,8 +164,7 @@ export async function ensureActorAttribution(
}

export type ActorRecorder = {
recordLocalChange: (doc: Automerge.Doc<unknown>) => void;
setAttributionHandle: (handle: DocHandle<ActorAttributionDoc>) => void;
watch: (urls: readonly AutomergeUrl[]) => void;
dispose: () => void;
};

Expand Down
112 changes: 112 additions & 0 deletions drafts/src/change-group-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import * as Automerge from "@automerge/automerge";

import { computeEditCounts, editCountsSince } from "./change-group-cache";

type Fixture = {
"@patchwork": {
type: string;
nested?: { a: number };
copies?: string[];
mainDraftUrl?: string;
};
text: string;
list: { n: number }[];
title?: string;
};

function history(): Automerge.Doc<Fixture> {
let doc = Automerge.from<Fixture>({
"@patchwork": { type: "essay", nested: { a: 1 }, copies: [] },
text: "",
list: [],
});
for (let i = 0; i < 60; i++) {
doc = Automerge.change(doc, { time: 1_700_000_000 + i * 30 }, (d) => {
if (i % 7 === 0 && d.text.length > 5) {
Automerge.splice(d, ["text"], 0, 3);
} else {
Automerge.splice(d, ["text"], d.text.length, 0, `word${i} `);
}
if (i % 10 === 0) d.list.push({ n: i });
if (i % 15 === 0) d["@patchwork"].nested!.a = i;
if (i === 20) d["@patchwork"].mainDraftUrl = "automerge:abc";
if (i === 25) d["@patchwork"].copies!.push("automerge:copy");
if (i === 30) d.title = "Hello";
if (i === 40) delete d.title;
});
}
let other = Automerge.clone(doc);
other = Automerge.change(other, { time: 1_700_009_000 }, (d) => {
Automerge.splice(d, ["text"], 0, 0, "Y");
});
doc = Automerge.change(doc, { time: 1_700_009_001 }, (d) => {
Automerge.splice(d, ["text"], 0, 0, "Z");
});
return Automerge.merge(doc, other);
}

async function expectSameAsDiff(
doc: Automerge.Doc<Fixture>,
since: Automerge.Heads
) {
const counts = await editCountsSince(doc, since);
const metas = Automerge.getChangesMetaSince(doc, since);
expect(counts).not.toBeNull();
expect(counts!.size).toBe(metas.length);
for (const meta of metas) {
expect(counts!.get(meta.hash), meta.hash).toEqual(
computeEditCounts(doc, meta.hash, meta.deps)
);
}
}

describe("editCountsSince", () => {
it("matches the per-change diff counts over the whole history", async () => {
await expectSameAsDiff(history(), []);
});

it("matches the diff counts on an incremental tail, including edits inside @patchwork containers created earlier", async () => {
const doc = history();
const metas = Automerge.getChangesMetaSince(doc, []);
await expectSameAsDiff(doc, [metas[10].hash]);
await expectSameAsDiff(doc, [metas[24].hash]);
});

it("counts a metadata-only change as zero edits", async () => {
let doc = history();
doc = Automerge.change(doc, (d) => {
d["@patchwork"].copies!.push("automerge:another");
d["@patchwork"].nested!.a = 99;
d["@patchwork"].type = "note";
});
const heads = Automerge.getHeads(doc);
const counts = await editCountsSince(doc, [
Automerge.getChangesMetaSince(doc, []).at(-2)!.hash,
]);
expect(counts!.get(heads[0])).toEqual({ additions: 0, deletions: 0 });
});

it("counts a mark once, like the diff does", async () => {
let doc = history();
doc = Automerge.change(doc, (d) => {
Automerge.mark(
d,
["text"],
{ start: 0, end: 4, expand: "none" },
"bold",
true
);
});
const [head] = Automerge.getHeads(doc);
const meta = Automerge.getChangesMetaSince(doc, []).find(
(m) => m.hash === head
)!;
const counts = await editCountsSince(doc, meta.deps);
expect(counts!.get(head)).toEqual(computeEditCounts(doc, head, meta.deps));
});

it("resolves null when the tick aborts", async () => {
expect(await editCountsSince(history(), [], async () => false)).toBeNull();
});
});
Loading
Loading