| 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 | */ |
| 7 | import { |
| 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 | |
| 18 | import { publishDocEvent } from "./events.ts"; |
| 19 | import { fileStore, type FileStoreEnv } from "./files.ts"; |
| 20 | import { workspaceReadable } from "./folios/access-store.ts"; |
| 21 | import { publishFolioEvent } from "./folios/events.ts"; |
| 22 | import type { Rendition } from "./kinds/types.ts"; |
| 23 | import { excerpt, searchText } from "./markdown.ts"; |
| 24 | import { linkedPageIds, pageSlug } from "./slugs.ts"; |
| 25 | |
| 26 | /** How long edits gather into one version before the next starts. */ |
| 27 | export const VERSION_EVERY_MS = 10 * 60 * 1000; |
| 28 | /** The largest Yjs state a version keeps; past it, only its Markdown. */ |
| 29 | const MAX_VERSION_STATE = 1_500_000; |
| 30 | |
| 31 | export type SaveEnv = { DB: D1Database; NOTIFY?: ServiceBinding; EVENTS?: ServiceBinding }; |
| 32 | |
| 33 | export 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 | |
| 56 | type 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). */ |
| 68 | export 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 | |
| 195 | export type SaveFolioEnv = SaveEnv & FileStoreEnv; |
| 196 | |
| 197 | export 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 | |
| 212 | type 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 | */ |
| 231 | export 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 | } |