Skip to content
120 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Docs: a workspace knowledge base people and agents write together1/**
2 * What a page's room saves to D1 after a burst of edits: the Markdown
3 * rendition, the search index, backlinks, history, and telling people
4 * newly mentioned in the page. Run by the room (src/room.ts), which owns
5 * the live document; nothing here reads the document itself.
6 */
7import { newId, notifyClient, type DocVersionKind, type FeedNotification, type ServiceBinding } from "@g1t/contracts";
8
9import { excerpt, searchText } from "./markdown.ts";
10import { linkedPageIds, pageSlug } from "./slugs.ts";
11
12/** How long edits gather into one version before the next starts. */
13export const VERSION_EVERY_MS = 10 * 60 * 1000;
14/** The largest Yjs state a version keeps; past it, only its Markdown. */
15const MAX_VERSION_STATE = 1_500_000;
16
17export type SaveEnv = { DB: D1Database; NOTIFY?: ServiceBinding };
18
19export type Save = {
20 page_id: string;
21 markdown: string;
22 /** The editors since the last save, member keys, last one last. */
23 editors: string[];
24 /** People mentioned in the document now (usernames, lowercased). */
25 mentioned: string[];
26 /** The usernames of the people who made these edits, lowercased: nobody is told they mentioned themselves. */
27 editor_names: string[];
28 /** The whole document, for a version. */
29 state: Uint8Array;
30 /** A version to record now, whatever the time since the last. */
31 version: { kind: DocVersionKind; note: string | null; authors: string[] } | null;
32 /** Editors since the last version, for a timed one. */
33 pending_authors: string[];
34 /** When the last version was recorded (ms), or 0. */
35 last_version_at: number;
36 /** The workspace's slug as the room last heard it, for links in notifications. */
37 workspace_slug: string | null;
38};
39
40type PageRow = { id: string; workspace_id: string; space_id: string; title: string; mentioned: string; markdown: string; slug: string; archived_at: string | null };
41
42/** Saves; returns whether a version was recorded, and its id. */
43export async function save(env: SaveEnv, input: Save, now = new Date()): Promise<{ version_id: string | null }> {
44 const at = now.toISOString();
45 const page = await env.DB.prepare(
46 "SELECT p.id, p.workspace_id, p.space_id, p.title, p.mentioned, p.markdown, p.archived_at, s.slug AS slug FROM pages p JOIN spaces s ON s.id = p.space_id WHERE p.id = ?",
47 )
48 .bind(input.page_id)
49 .first<PageRow>();
50 if (!page) return { version_id: null };
51 const changed = page.markdown !== input.markdown;
52 const last = input.editors[input.editors.length - 1] ?? null;
53 const statements: D1PreparedStatement[] = [];
54 if (changed || last) {
55 statements.push(
56 env.DB.prepare("UPDATE pages SET markdown = ?, updated_at = ?, updated_by = COALESCE(?, updated_by) WHERE id = ?").bind(input.markdown, at, last, page.id),
57 );
58 }
59 if (changed) {
60 statements.push(env.DB.prepare("DELETE FROM pages_fts WHERE page_id = ?").bind(page.id));
61 statements.push(env.DB.prepare("INSERT INTO pages_fts (page_id, title, body) VALUES (?, ?, ?)").bind(page.id, page.title, searchText(input.markdown)));
62 statements.push(env.DB.prepare("DELETE FROM page_links WHERE from_page = ?").bind(page.id));
63 for (const to of linkedPageIds(input.markdown).filter((id) => id !== page.id).slice(0, 200)) {
64 statements.push(env.DB.prepare("INSERT OR IGNORE INTO page_links (from_page, to_page) VALUES (?, ?)").bind(page.id, to));
65 }
66 }
67 // A version: asked for (an agent's edit, a suggestion, a restore), or
68 // the first save after enough time since the last one.
69 let versionId: string | null = null;
70 const timed = input.pending_authors.length > 0 && now.getTime() - input.last_version_at >= VERSION_EVERY_MS;
71 if (input.version || (timed && changed)) {
72 versionId = newId("ver", now.getTime());
73 const authors = input.version?.authors.length ? input.version.authors : input.pending_authors;
74 const state = input.state.byteLength <= MAX_VERSION_STATE ? input.state : null;
75 statements.push(
76 env.DB.prepare("INSERT INTO page_versions (id, page_id, created_at, kind, authors, note, markdown, state) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").bind(
77 versionId,
78 page.id,
79 at,
80 input.version?.kind ?? "edit",
81 JSON.stringify([...new Set(authors)]),
82 input.version?.note ?? null,
83 input.markdown,
84 state,
85 ),
86 );
87 }
88 // People mentioned for the first time.
89 let told: string[] = [];
90 try {
91 told = JSON.parse(page.mentioned) as string[];
92 } catch {
93 told = [];
94 }
95 const editors = new Set(input.editor_names);
96 const fresh = input.mentioned.filter((name) => !told.includes(name) && !editors.has(name));
97 if (fresh.length || input.mentioned.length !== told.length) {
98 statements.push(env.DB.prepare("UPDATE pages SET mentioned = ? WHERE id = ?").bind(JSON.stringify([...new Set([...told.filter((id) => input.mentioned.includes(id)), ...fresh])]), page.id));
99 }
100 if (statements.length) await env.DB.batch(statements);
101 if (fresh.length && env.NOTIFY && !page.archived_at) {
102 const slug = input.workspace_slug;
103 if (slug) {
104 const href = `/${slug}/-/docs/${page.slug}/${pageSlug(page.title, page.id)}`;
105 const notification = (username: string): FeedNotification => ({
106 id: `doc-mention:${page.id}:${username}`,
107 kind: "mention",
108 workspace: slug,
109 title: `You were mentioned in ${page.title || "Untitled"}`,
110 body: excerpt(input.markdown, 140),
111 href,
112 actor: { kind: last?.startsWith("agent:") ? "agent" : "user", id: last?.slice(last.indexOf(":") + 1) ?? "", name: "Docs" },
113 created_at: at,
114 });
115 const notify = notifyClient(env.NOTIFY);
116 await Promise.all(fresh.map((name) => notify.notify({ username: name }, notification(name)).catch(() => undefined)));
117 }
118 }
119 return { version_id: versionId };
120}