Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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