Skip to content
273 linesCodeBlameRaw
1/**
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 DocCitation, type DocVersionKind, type FeedNotification, type FolioKind, type FolioVersionKind, type ServiceBinding } from "@g1t/contracts";
8
9import { publishDocEvent } from "./events.ts";
10import { fileStore, type FileStoreEnv } from "./files.ts";
11import { workspaceReadable } from "./folios/access-store.ts";
12import { publishFolioEvent } from "./folios/events.ts";
13import type { Rendition } from "./kinds/types.ts";
14import { excerpt, searchText } from "./markdown.ts";
15import { linkedPageIds, pageSlug } from "./slugs.ts";
16
17/** How long edits gather into one version before the next starts. */
18export const VERSION_EVERY_MS = 10 * 60 * 1000;
19/** The largest Yjs state a version keeps; past it, only its Markdown. */
20const MAX_VERSION_STATE = 1_500_000;
21
22export type SaveEnv = { DB: D1Database; NOTIFY?: ServiceBinding; EVENTS?: ServiceBinding };
23
24export type Save = {
25 page_id: string;
26 markdown: string;
27 /** Code the document cites (src/citations.ts `bodyCitations`). */
28 citations: DocCitation[];
29 /** The editors since the last save, member keys, last one last. */
30 editors: string[];
31 /** People mentioned in the document now (usernames, lowercased). */
32 mentioned: string[];
33 /** The usernames of the people who made these edits, lowercased: nobody is told they mentioned themselves. */
34 editor_names: string[];
35 /** The whole document, for a version. */
36 state: Uint8Array;
37 /** A version to record now, whatever the time since the last. */
38 version: { kind: DocVersionKind; note: string | null; authors: string[] } | null;
39 /** Editors since the last version, for a timed one. */
40 pending_authors: string[];
41 /** When the last version was recorded (ms), or 0. */
42 last_version_at: number;
43 /** The workspace's slug as the room last heard it, for links in notifications. */
44 workspace_slug: string | null;
45};
46
47type PageRow = { id: string; workspace_id: string; space_id: string; title: string; mentioned: string; markdown: string; slug: string; archived_at: string | null };
48
49/** Saves; returns whether a version was recorded, and its id, and whether the Markdown changed (so the room indexes it again, src/indexer.ts). */
50export async function save(env: SaveEnv, input: Save, now = new Date()): Promise<{ version_id: string | null; changed: boolean }> {
51 const at = now.toISOString();
52 const page = await env.DB.prepare(
53 "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 = ?",
54 )
55 .bind(input.page_id)
56 .first<PageRow>();
57 if (!page) return { version_id: null, changed: false };
58 const changed = page.markdown !== input.markdown;
59 const last = input.editors[input.editors.length - 1] ?? null;
60 const statements: D1PreparedStatement[] = [];
61 if (changed || last) {
62 statements.push(
63 env.DB.prepare("UPDATE pages SET markdown = ?, updated_at = ?, updated_by = COALESCE(?, updated_by) WHERE id = ?").bind(input.markdown, at, last, page.id),
64 );
65 }
66 if (changed) {
67 statements.push(env.DB.prepare("DELETE FROM pages_fts WHERE page_id = ?").bind(page.id));
68 statements.push(env.DB.prepare("INSERT INTO pages_fts (page_id, title, body) VALUES (?, ?, ?)").bind(page.id, page.title, searchText(input.markdown)));
69 statements.push(env.DB.prepare("DELETE FROM page_links WHERE from_page = ?").bind(page.id));
70 for (const to of linkedPageIds(input.markdown).filter((id) => id !== page.id).slice(0, 200)) {
71 statements.push(env.DB.prepare("INSERT OR IGNORE INTO page_links (from_page, to_page) VALUES (?, ?)").bind(page.id, to));
72 }
73 // What it cites, from its text; the header's own stay.
74 statements.push(env.DB.prepare("DELETE FROM citations WHERE page_id = ? AND source = 'body'").bind(page.id));
75 for (const c of input.citations) {
76 statements.push(
77 env.DB.prepare("INSERT OR IGNORE INTO citations (page_id, repo, path, kind, label, ref, source) VALUES (?, ?, ?, ?, ?, ?, 'body')").bind(page.id, c.repo, c.path, c.kind, c.label ?? "", c.ref),
78 );
79 }
80 }
81 // A version: asked for (an agent's edit, a suggestion, a restore), or
82 // the first save after enough time since the last one.
83 let versionId: string | null = null;
84 const timed = input.pending_authors.length > 0 && now.getTime() - input.last_version_at >= VERSION_EVERY_MS;
85 if (input.version || (timed && changed)) {
86 versionId = newId("ver", now.getTime());
87 const authors = input.version?.authors.length ? input.version.authors : input.pending_authors;
88 const state = input.state.byteLength <= MAX_VERSION_STATE ? input.state : null;
89 statements.push(
90 env.DB.prepare("INSERT INTO page_versions (id, page_id, created_at, kind, authors, note, markdown, state) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").bind(
91 versionId,
92 page.id,
93 at,
94 input.version?.kind ?? "edit",
95 JSON.stringify([...new Set(authors)]),
96 input.version?.note ?? null,
97 input.markdown,
98 state,
99 ),
100 );
101 }
102 // People mentioned for the first time.
103 let told: string[] = [];
104 try {
105 told = JSON.parse(page.mentioned) as string[];
106 } catch {
107 told = [];
108 }
109 const editors = new Set(input.editor_names);
110 const fresh = input.mentioned.filter((name) => !told.includes(name) && !editors.has(name));
111 if (fresh.length || input.mentioned.length !== told.length) {
112 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));
113 }
114 if (statements.length) await env.DB.batch(statements);
115 // A version is what the rest of g1t hears of: at most every ten minutes of editing, and each agent edit, suggestion and restore.
116 const kind = input.version?.kind ?? "edit";
117 if (versionId && kind !== "created" && input.workspace_slug && !page.archived_at) {
118 await publishDocEvent(
119 env.EVENTS,
120 "doc.page.updated",
121 {
122 workspace: input.workspace_slug,
123 workspaceId: page.workspace_id,
124 pageId: page.id,
125 spaceId: page.space_id,
126 title: page.title,
127 path: `/${input.workspace_slug}/-/docs/${page.slug}/${pageSlug(page.title, page.id)}`,
128 versionId,
129 kind,
130 authors: [...new Set(input.version?.authors.length ? input.version.authors : input.pending_authors)],
131 },
132 last,
133 );
134 }
135 if (fresh.length && env.NOTIFY && !page.archived_at) {
136 const slug = input.workspace_slug;
137 if (slug) {
138 const href = `/${slug}/-/docs/${page.slug}/${pageSlug(page.title, page.id)}`;
139 const notification = (username: string): FeedNotification => ({
140 id: `doc-mention:${page.id}:${username}`,
141 kind: "mention",
142 workspace: slug,
143 title: `You were mentioned in ${page.title || "Untitled"}`,
144 body: excerpt(input.markdown, 140),
145 href,
146 actor: { kind: last?.startsWith("agent:") ? "agent" : "user", id: last?.slice(last.indexOf(":") + 1) ?? "", name: "Docs" },
147 created_at: at,
148 });
149 const notify = notifyClient(env.NOTIFY);
150 await Promise.all(fresh.map((name) => notify.notify({ username: name }, notification(name)).catch(() => undefined)));
151 }
152 }
153 return { version_id: versionId, changed };
154}
155
156// ── Folios (Artifacts mode) ─────────────────────────────────────────────
157
158export type SaveFolioEnv = SaveEnv & FileStoreEnv;
159
160export type SaveFolio = {
161 folio_id: string;
162 /** What the kind rendered from the document (src/kinds/types.ts). */
163 rendition: Rendition;
164 /** The editors since the last save, member keys, last one last. */
165 editors: string[];
166 /** The usernames of the people who made these edits, lowercased. */
167 editor_names: string[];
168 state: Uint8Array;
169 version: { kind: FolioVersionKind; note: string | null; authors: string[] } | null;
170 pending_authors: string[];
171 last_version_at: number;
172 workspace_slug: string | null;
173};
174
175type FolioSaveRow = { id: string; workspace_id: string; kind: FolioKind; title: string; text: string; preview: string | null; mentioned: string; space_id: string | null; trashed_at: string | null };
176
177/**
178 * What a folio's room saves to D1 after a burst of edits: its text
179 * rendition, card, search text, links, citations and history. Returns the
180 * version recorded (if one was), whether the text changed (so the room
181 * indexes it again), and the people newly mentioned, whom the room tells
182 * only if they can read the folio (src/folios/notify.ts).
183 */
184export async function saveFolio(env: SaveFolioEnv, input: SaveFolio, now = new Date()): Promise<{ version_id: string | null; changed: boolean; mentioned: string[]; last: string | null }> {
185 const at = now.toISOString();
186 const db = env.DB;
187 const folio = await db.prepare("SELECT id, workspace_id, kind, title, text, preview, mentioned, space_id, trashed_at FROM folios WHERE id = ?").bind(input.folio_id).first<FolioSaveRow>();
188 if (!folio) return { version_id: null, changed: false, mentioned: [], last: null };
189 const r = input.rendition;
190 const changed = folio.text !== r.text;
191 const last = input.editors[input.editors.length - 1] ?? null;
192 const statements: D1PreparedStatement[] = [];
193 const preview = r.preview ? JSON.stringify(r.preview) : null;
194 // A folio made from text that already reads as the kind renders it still needs its card.
195 if (!changed && preview !== folio.preview) statements.push(db.prepare("UPDATE folios SET preview = ? WHERE id = ?").bind(preview, folio.id));
196 if (changed) {
197 statements.push(
198 db
199 .prepare("UPDATE folios SET text = ?, excerpt = ?, preview = ?, edited_at = ?, edited_by = COALESCE(?, edited_by), updated_at = ? WHERE id = ?")
200 .bind(r.text, excerpt(r.text), preview, at, last, at, folio.id),
201 db.prepare("DELETE FROM folios_fts WHERE folio_id = ?").bind(folio.id),
202 db.prepare("INSERT INTO folios_fts (folio_id, kind, title, body) VALUES (?, ?, ?, ?)").bind(folio.id, folio.kind, folio.title, searchText(r.text)),
203 db.prepare("DELETE FROM folio_links WHERE from_folio = ?").bind(folio.id),
204 db.prepare("DELETE FROM folio_citations WHERE folio_id = ? AND source = 'body'").bind(folio.id),
205 );
206 for (const to of r.links.filter((id) => id !== folio.id).slice(0, 200)) {
207 statements.push(db.prepare("INSERT OR IGNORE INTO folio_links (from_folio, to_folio) VALUES (?, ?)").bind(folio.id, to));
208 }
209 for (const c of r.citations) {
210 statements.push(
211 db.prepare("INSERT OR IGNORE INTO folio_citations (folio_id, repo, path, kind, label, ref, source) VALUES (?, ?, ?, ?, ?, ?, 'body')").bind(folio.id, c.repo, c.path, c.kind, c.label ?? "", c.ref),
212 );
213 }
214 }
215 // A version: asked for, or the first save with changes after enough time.
216 let versionId: string | null = null;
217 const timed = input.pending_authors.length > 0 && now.getTime() - input.last_version_at >= VERSION_EVERY_MS;
218 if (input.version || (timed && changed)) {
219 versionId = newId("ver", now.getTime());
220 const authors = input.version?.authors.length ? input.version.authors : input.pending_authors;
221 let state: Uint8Array | null = input.state.byteLength <= MAX_VERSION_STATE ? input.state : null;
222 let stateKey: string | null = null;
223 if (!state) {
224 // Too large for a row: the file store keeps it.
225 try {
226 stateKey = `docs/versions/${folio.id}/${versionId}`;
227 await fileStore(env).put(stateKey, input.state, "application/octet-stream");
228 } catch (error) {
229 console.error("folios could not keep a large version's state; its text is kept", folio.id, String(error));
230 stateKey = null;
231 state = null;
232 }
233 }
234 statements.push(
235 db
236 .prepare("INSERT INTO folio_versions (id, folio_id, created_at, kind, authors, note, text, state, state_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
237 .bind(versionId, folio.id, at, input.version?.kind ?? "edit", JSON.stringify([...new Set(authors)]), input.version?.note ?? null, r.text, state, stateKey),
238 );
239 }
240 let told: string[] = [];
241 try {
242 told = JSON.parse(folio.mentioned) as string[];
243 } catch {
244 told = [];
245 }
246 const editors = new Set(input.editor_names);
247 const fresh = r.mentions.filter((name) => !told.includes(name) && !editors.has(name));
248 if (fresh.length || r.mentions.length !== told.length) {
249 statements.push(db.prepare("UPDATE folios SET mentioned = ? WHERE id = ?").bind(JSON.stringify([...new Set([...told.filter((n) => r.mentions.includes(n)), ...fresh])]), folio.id));
250 }
251 if (statements.length) await db.batch(statements);
252 const kind = input.version?.kind ?? "edit";
253 if (versionId && kind !== "created" && input.workspace_slug && !folio.trashed_at) {
254 const open = await workspaceReadable(db, folio.id).catch(() => false);
255 await publishFolioEvent(
256 env.EVENTS,
257 "folio.updated",
258 {
259 workspace: input.workspace_slug,
260 workspaceId: folio.workspace_id,
261 folioId: folio.id,
262 kind: folio.kind,
263 spaceId: folio.space_id,
264 title: open ? folio.title : null,
265 versionId,
266 versionKind: kind,
267 authors: [...new Set(input.version?.authors.length ? input.version.authors : input.pending_authors)],
268 },
269 last,
270 );
271 }
272 return { version_id: versionId, changed, mentioned: folio.trashed_at ? [] : fresh, last };
273}