Skip to content
1,860 linesCodeBlameRaw
1/**
2 * The docs service: a workspace's spaces and pages, their live documents,
3 * history, comments, agents' suggestions, templates and search. Plan:
4 * docs/WORKSPACE.md, "Docs".
5 *
6 * Reached through service bindings: `POST /rpc/<method>` with snake_case
7 * bodies (`docsClient` in @g1t/contracts); `GET /live` for a page's socket
8 * and `PUT /files` for uploads, which the site forwards after checking the
9 * session; `GET /files/<key>` for the usercontent origin to serve a file.
10 *
11 * Each page has a room (src/room.ts), a Durable Object that owns its Yjs
12 * document. Everything that changes a page's content goes through the
13 * room; this Worker decides who may ask.
14 */
15
16import {
17 DOCS_VIEWER_HEADER,
18 DOC_MAX_FILE_BYTES,
19 fail,
20 identityClient,
21 newId,
22 notifyClient,
23 ok,
24 openD1,
25 parsePrincipalKey,
26 principalKey,
27 workspaceAgentsClient,
28 type DocAgentAbilities,
29 type DocAgentEditResult,
30 type DocAgentMode,
31 type DocAgentPage,
32 type DocAudience,
33 type DocEditTarget,
34 type DocFile,
35 type DocMove,
36 type DocPage,
37 type DocPageChange,
38 type DocPageDetail,
39 type DocPageRef,
40 type DocRole,
41 type DocSearchHit,
42 type DocSearchQuery,
43 type DocSpace,
44 type DocSpaceChange,
45 type DocSpaceKind,
46 type DocSpaceMember,
47 type DocSuggestion,
48 type DocTemplate,
49 type DocThread,
50 type DocThreadAction,
51 type DocTreeNode,
52 type DocVersion,
53 type DocVersionDetail,
54 type DocsHome,
55 type DocsLiveEvent,
56 type DocsSidebar,
57 type Member,
58 type MemberProfile,
59 type NewDocPage,
60 type NewDocSpace,
61 type Principal,
62 type Result,
63 type ServiceBinding,
64 type User,
65 type Viewer,
66 type Workspace,
67 type WorkspaceAgent,
68} from "@g1t/contracts";
69
70import { RANK, agentAbilities, atLeast, isRole, leavesNoManager, memberKey, readableByAll, readableByWorkspace, roleOf, type Person, type SpaceRules } from "./access.ts";
71import { diffLines } from "./diff.ts";
72import { r2FileStore, safeName, servedType } from "./files.ts";
73import { excerpt, searchText } from "./markdown.ts";
74import { ROOM_MEMBER_HEADER, type Origin, type PageRoom, type RoomMember } from "./room.ts";
75import { ftsQuery, inProject, projectRef, searchSpaces } from "./search.ts";
76import { freeSlug, pageSlug, validSpaceSlug } from "./slugs.ts";
77import { BUILTIN_TEMPLATES, builtinTemplate } from "./templates.ts";
78import type { ThreadResult } from "./threads.ts";
79import { descendants, exportPaths, lastPosition, placeBefore, wouldCycle, ancestors } from "./tree.ts";
80
81export { PageRoom } from "./room.ts";
82
83type Env = {
84 DB: D1Database;
85 IDENTITY: ServiceBinding;
86 AGENTS: ServiceBinding;
87 NOTIFY?: ServiceBinding;
88 PAGES: DurableObjectNamespace<PageRoom>;
89 FILES: R2Bucket;
90};
91
92type SpaceRow = {
93 id: string;
94 workspace_id: string;
95 slug: string;
96 name: string;
97 description: string | null;
98 icon: string | null;
99 kind: DocSpaceKind;
100 team: string | null;
101 default_role: DocRole | null;
102 agent_mode: DocAgentMode;
103 is_default: number;
104 created_by: string;
105 created_at: string;
106 archived_at: string | null;
107};
108
109type PageRow = {
110 id: string;
111 workspace_id: string;
112 space_id: string;
113 parent_id: string | null;
114 position: number;
115 title: string;
116 icon: string | null;
117 cover: string | null;
118 markdown: string;
119 created_by: string;
120 created_at: string;
121 updated_by: string | null;
122 updated_at: string;
123 archived_at: string | null;
124 archived_by: string | null;
125};
126
127type SuggestionRow = {
128 id: string;
129 page_id: string;
130 author: string;
131 asked_by: string | null;
132 target: string;
133 before_markdown: string;
134 after_markdown: string;
135 note: string | null;
136 status: DocSuggestion["status"];
137 created_at: string;
138 decided_by: string | null;
139 decided_at: string | null;
140};
141
142type VersionRow = { id: string; page_id: string; created_at: string; kind: DocVersion["kind"]; authors: string; note: string | null; markdown: string; state: ArrayBuffer | null };
143
144/** A space, with who is in it and the viewer's role. */
145type Space = { row: SpaceRow; members: { principal: string; role: DocRole }[]; projects: string[]; role: DocRole | null };
146
147/** The page columns lists read: everything but the Markdown. */
148const PAGE_COLUMNS = "id, workspace_id, space_id, parent_id, position, title, icon, cover, '' AS markdown, created_by, created_at, updated_by, updated_at, archived_at, archived_by";
149
150const MAX_TITLE = 200;
151const MAX_MARKDOWN = 512 * 1024;
152const MAX_NOTE = 500;
153
154const now = () => new Date().toISOString();
155
156function isMember(viewer: Viewer, workspace: string): boolean {
157 return !!viewer?.workspaces?.some((m) => m.slug === String(workspace ?? "").toLowerCase());
158}
159
160function rulesOf(space: Space): SpaceRules {
161 return { kind: space.row.kind, team: space.row.team, default_role: space.row.default_role, members: space.members };
162}
163
164function cleanTitle(title: unknown): string {
165 return String(title ?? "")
166 .replace(/\s+/g, " ")
167 .trim()
168 .slice(0, MAX_TITLE);
169}
170
171/** One emoji (or a few characters), or null. */
172function cleanIcon(icon: unknown): string | null {
173 const s = String(icon ?? "").trim();
174 if (!s) return null;
175 return [...s].slice(0, 4).join("");
176}
177
178function cleanCover(cover: unknown): string | null {
179 const s = String(cover ?? "").trim();
180 if (!s) return null;
181 if (/^gradient:\d{1,2}$/.test(s)) return s;
182 if (/^https:\/\/[^\s"'<>]{1,500}$/.test(s)) return s;
183 return null;
184}
185
186function cleanProjects(list: unknown): string[] {
187 if (!Array.isArray(list)) return [];
188 return [...new Set(list.map((p) => projectRef(String(p))).filter((p): p is string => !!p))].slice(0, 20);
189}
190
191function cleanTarget(target: unknown): DocEditTarget | null {
192 const t = target as DocEditTarget | null;
193 if (!t || typeof t !== "object") return null;
194 switch (t.kind) {
195 case "append":
196 case "document":
197 return { kind: t.kind };
198 case "section":
199 return typeof t.heading === "string" && t.heading.trim() ? { kind: "section", heading: t.heading.trim().slice(0, 300) } : null;
200 case "blocks":
201 return typeof t.from_block === "string" && typeof t.to_block === "string" ? { kind: "blocks", from_block: t.from_block, to_block: t.to_block } : null;
202 default:
203 return null;
204 }
205}
206
207class Docs {
208 private readonly workspaces = new Map<string, Promise<Workspace | null>>();
209 private readonly people = new Map<string, Promise<Map<string, Member>>>();
210 private readonly teams = new Map<string, Promise<Map<string, Set<string>>>>();
211 private readonly usernames = new Map<string, string>();
212 private readonly agents = new Map<string, WorkspaceAgent | null>();
213
214 constructor(
215 private readonly env: Env,
216 private readonly defer: (work: Promise<unknown>) => void = () => {},
217 ) {}
218
219 private get db() {
220 return this.env.DB;
221 }
222
223 // ── Who and where ───────────────────────────────────────────────────────
224
225 private workspace(slug: string): Promise<Workspace | null> {
226 const key = String(slug ?? "").toLowerCase();
227 let found = this.workspaces.get(key);
228 if (!found) {
229 found = identityClient(this.env.IDENTITY).getWorkspace(key).catch(() => null);
230 this.workspaces.set(key, found);
231 }
232 return found;
233 }
234
235 /** The workspace acting for itself: how this service asks identity about its members. */
236 private actor(workspace: Workspace): User {
237 return { id: workspace.id, username: workspace.slug, kind: "workspace", verified: true, workspaces: [{ slug: workspace.slug, role: "member" }] };
238 }
239
240 /** The workspace's people by username. */
241 private members(workspace: Workspace): Promise<Map<string, Member>> {
242 let found = this.people.get(workspace.id);
243 if (!found) {
244 found = identityClient(this.env.IDENTITY)
245 .listMembers(workspace.slug, this.actor(workspace))
246 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), m]) : []))
247 .catch(() => new Map<string, Member>());
248 this.people.set(workspace.id, found);
249 }
250 return found;
251 }
252
253 /** Each member's teams (slugs, lowercased), by username. */
254 private teamsOf(workspace: Workspace): Promise<Map<string, Set<string>>> {
255 let found = this.teams.get(workspace.id);
256 if (!found) {
257 found = identityClient(this.env.IDENTITY)
258 .teamMemberships(this.actor(workspace), workspace.slug)
259 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), new Set(m.teams.map((t) => t.slug.toLowerCase()))]) : []))
260 .catch(() => new Map<string, Set<string>>());
261 this.teams.set(workspace.id, found);
262 }
263 return found;
264 }
265
266 private async nameUsers(ids: string[]): Promise<void> {
267 const unnamed = [...new Set(ids)].filter((id) => !this.usernames.has(id));
268 if (!unnamed.length) return;
269 const named = await identityClient(this.env.IDENTITY)
270 .usernames(unnamed)
271 .catch(() => ({}) as Record<string, string>);
272 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
273 }
274
275 private async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> {
276 const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id));
277 if (wanted.length) {
278 let found: WorkspaceAgent[] = [];
279 try {
280 found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted);
281 } catch (error) {
282 console.error("docs could not resolve agents", error);
283 }
284 for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null);
285 }
286 return new Map(ids.map((id) => [id, this.agents.get(id) ?? null]));
287 }
288
289 /** How member keys show. Teams show by slug. */
290 private async profiles(workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
291 const principals = [...new Set(keys)].map((k) => parsePrincipalKey(k)).filter((p): p is Principal => !!p);
292 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
293 const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
294 const [, people, agents] = await Promise.all([this.nameUsers(userIds), userIds.length ? this.members(workspace) : new Map<string, Member>(), this.agentsById(agentIds)]);
295 const out = new Map<string, MemberProfile>();
296 for (const p of principals) {
297 if (p.kind === "user") {
298 const username = this.usernames.get(p.id) ?? null;
299 const person = username ? people.get(username.toLowerCase()) : undefined;
300 out.set(principalKey(p), {
301 ...p,
302 name: username ?? "ghost",
303 display_name: person?.name || username || "Former member",
304 avatar: person?.avatar ?? null,
305 role: null,
306 title: null,
307 avatar_seed: null,
308 });
309 } else {
310 const agent = agents.get(p.id) ?? null;
311 out.set(principalKey(p), {
312 ...p,
313 name: agent?.handle ?? p.id,
314 display_name: agent?.display_name ?? "Former agent",
315 avatar: agent?.avatar ?? null,
316 role: agent?.role ?? null,
317 title: agent?.title || null,
318 avatar_seed: agent?.avatar_seed ?? null,
319 });
320 }
321 }
322 // Anything else (system, ghost keys): a plain name.
323 for (const key of keys) {
324 if (!out.has(key)) out.set(key, { kind: "user", id: key, name: "g1t", display_name: "g1t", avatar: null, role: null, title: null, avatar_seed: null });
325 }
326 return out;
327 }
328
329 private async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
330 if (!viewer?.id) return fail("unauthenticated", "Sign in to use Docs.");
331 if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its Docs.");
332 const workspace = await this.workspace(slug);
333 return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
334 }
335
336 /** A person as access sees them. */
337 private async personOf(workspace: Workspace, user: Pick<User, "id" | "username">, owner: boolean, needTeams: boolean): Promise<Person> {
338 const teams = needTeams ? ((await this.teamsOf(workspace)).get(user.username.toLowerCase()) ?? new Set<string>()) : new Set<string>();
339 return { user_id: user.id, owner, teams };
340 }
341
342 private viewerOwner(viewer: User, slug: string): boolean {
343 return !!viewer.workspaces?.some((m) => m.slug === slug.toLowerCase() && m.role === "owner");
344 }
345
346 /** Every space in the workspace (archived ones too when asked), with members and projects. */
347 private async allSpaces(workspace: Workspace, archived = false): Promise<Omit<Space, "role">[]> {
348 const [spaces, members, projects] = await Promise.all([
349 this.db
350 .prepare(`SELECT * FROM spaces WHERE workspace_id = ? ${archived ? "" : "AND archived_at IS NULL"} ORDER BY is_default DESC, name COLLATE NOCASE`)
351 .bind(workspace.id)
352 .all<SpaceRow>(),
353 this.db
354 .prepare("SELECT m.space_id, m.principal, m.role FROM space_members m JOIN spaces s ON s.id = m.space_id WHERE s.workspace_id = ?")
355 .bind(workspace.id)
356 .all<{ space_id: string; principal: string; role: DocRole }>(),
357 this.db
358 .prepare("SELECT p.space_id, p.repo FROM space_projects p JOIN spaces s ON s.id = p.space_id WHERE s.workspace_id = ?")
359 .bind(workspace.id)
360 .all<{ space_id: string; repo: string }>(),
361 ]);
362 return spaces.results.map((row) => ({
363 row,
364 members: members.results.filter((m) => m.space_id === row.id).map((m) => ({ principal: m.principal, role: m.role })),
365 projects: projects.results.filter((p) => p.space_id === row.id).map((p) => p.repo),
366 }));
367 }
368
369 /** Whether any space's access depends on teams. */
370 private needsTeams(spaces: Omit<Space, "role">[]): boolean {
371 return spaces.some((s) => s.row.kind === "team" || s.members.some((m) => m.principal.startsWith("team:")));
372 }
373
374 /** The spaces, each with the viewer's role (null: they can't read it). */
375 private async spacesFor(workspace: Workspace, viewer: User, archived = false): Promise<Space[]> {
376 const spaces = await this.allSpaces(workspace, archived);
377 const person = await this.personOf(workspace, viewer, this.viewerOwner(viewer, workspace.slug), this.needsTeams(spaces));
378 return spaces.map((s) => ({ ...s, role: roleOf(rulesOf({ ...s, role: null }), person) }));
379 }
380
381 /** Makes the workspace's General space, once. */
382 private async ensureDefault(workspace: Workspace, viewer: User): Promise<boolean> {
383 const found = await this.db.prepare("SELECT id FROM spaces WHERE workspace_id = ? AND is_default = 1").bind(workspace.id).first<{ id: string }>();
384 if (found) return false;
385 const taken = new Set((await this.db.prepare("SELECT slug FROM spaces WHERE workspace_id = ?").bind(workspace.id).all<{ slug: string }>()).results.map((r) => r.slug));
386 const id = newId("spc");
387 await this.db
388 .prepare(
389 "INSERT OR IGNORE INTO spaces (id, workspace_id, slug, name, description, icon, kind, team, default_role, agent_mode, is_default, created_by, created_at) VALUES (?, ?, ?, 'General', 'Everything the whole workspace should know.', '📚', 'workspace', NULL, 'edit', 'suggest', 1, ?, ?)",
390 )
391 .bind(id, workspace.id, freeSlug("general", taken), principalKey({ kind: "user", id: viewer.id }), now())
392 .run();
393 return true;
394 }
395
396 private toSpace(space: Space, pageCount = 0): DocSpace {
397 const created = parsePrincipalKey(space.row.created_by) ?? { kind: "user" as const, id: space.row.created_by };
398 return {
399 id: space.row.id,
400 workspace_id: space.row.workspace_id,
401 slug: space.row.slug,
402 name: space.row.name,
403 description: space.row.description,
404 icon: space.row.icon,
405 kind: space.row.kind,
406 team: space.row.team,
407 default_role: space.row.kind === "private" ? null : space.row.default_role,
408 agent_mode: space.row.agent_mode,
409 is_default: !!space.row.is_default,
410 projects: space.projects,
411 created_by: created,
412 created_at: space.row.created_at,
413 archived_at: space.row.archived_at,
414 viewer_role: space.role ?? "view",
415 page_count: pageCount,
416 };
417 }
418
419 private ref(slug: string, space: Pick<SpaceRow, "id" | "slug">, row: Pick<PageRow, "id" | "title" | "icon">): DocPageRef {
420 const s = pageSlug(row.title, row.id);
421 return { id: row.id, space_id: space.id, space_slug: space.slug, title: row.title, icon: row.icon, slug: s, path: `/${slug}/-/docs/${space.slug}/${s}` };
422 }
423
424 /** Pages as the site shows them, with owners, projects and people resolved. */
425 private async toPages(workspace: Workspace, spaces: Map<string, SpaceRow>, rows: PageRow[]): Promise<DocPage[]> {
426 if (!rows.length) return [];
427 const ids = rows.map((r) => r.id);
428 const marks = ids.map(() => "?").join(",");
429 const [owners, projects, kids] = await Promise.all([
430 this.db.prepare(`SELECT page_id, principal FROM page_owners WHERE page_id IN (${marks})`).bind(...ids).all<{ page_id: string; principal: string }>(),
431 this.db.prepare(`SELECT page_id, repo FROM page_projects WHERE page_id IN (${marks})`).bind(...ids).all<{ page_id: string; repo: string }>(),
432 this.db
433 .prepare(`SELECT DISTINCT parent_id FROM pages WHERE parent_id IN (${marks}) AND archived_at IS NULL`)
434 .bind(...ids)
435 .all<{ parent_id: string }>(),
436 ]);
437 const keys = [...rows.flatMap((r) => [r.created_by, r.updated_by ?? r.created_by]), ...owners.results.map((o) => o.principal)];
438 const people = await this.profiles(workspace, keys);
439 const parents = new Set(kids.results.map((k) => k.parent_id));
440 return rows.map((row) => {
441 const space = spaces.get(row.space_id)!;
442 return {
443 ...this.ref(workspace.slug, space, row),
444 parent_id: row.parent_id,
445 position: row.position,
446 cover: row.cover,
447 created_by: people.get(row.created_by)!,
448 created_at: row.created_at,
449 updated_by: row.updated_by ? (people.get(row.updated_by) ?? null) : null,
450 updated_at: row.updated_at,
451 archived_at: row.archived_at,
452 has_children: parents.has(row.id),
453 projects: projects.results.filter((p) => p.page_id === row.id).map((p) => p.repo),
454 owners: owners.results.filter((o) => o.page_id === row.id).map((o) => people.get(o.principal)!),
455 excerpt: excerpt(row.markdown ?? ""),
456 };
457 });
458 }
459
460 /** A page and its space, with the viewer's role; not found when they can't read it. */
461 private async pageFor(slug: string, pageId: string, viewer: Viewer, need: DocRole): Promise<Result<{ workspace: Workspace; page: PageRow; space: Space; spaces: Space[] }>> {
462 const found = await this.viewerWorkspace(slug, viewer);
463 if (!found.ok) return found;
464 const workspace = found.value;
465 const page = await this.db.prepare("SELECT * FROM pages WHERE id = ? AND workspace_id = ?").bind(String(pageId ?? ""), workspace.id).first<PageRow>();
466 if (!page) return fail("not_found", "No such page.");
467 const spaces = await this.spacesFor(workspace, viewer!, true);
468 const space = spaces.find((s) => s.row.id === page.space_id);
469 if (!space || !space.role) return fail("not_found", "No such page.");
470 if (!atLeast(space.role, need)) return fail("forbidden", need === "comment" ? "You can read this page but not comment on it." : "You can read this page but not change it.");
471 return ok({ workspace, page, space, spaces });
472 }
473
474 private room(pageId: string) {
475 return this.env.PAGES.get(this.env.PAGES.idFromName(pageId));
476 }
477
478 private tell(pageId: string, event: DocsLiveEvent): void {
479 this.defer(
480 this.room(pageId)
481 .notice(event)
482 .catch((error: unknown) => console.error("docs could not tell page", pageId, error)),
483 );
484 }
485
486 private userKey(viewer: User): string {
487 return principalKey({ kind: "user", id: viewer.id });
488 }
489
490 // ── Sidebar and home ────────────────────────────────────────────────────
491
492 async sidebar(a: { workspace: string; viewer: Viewer }): Promise<Result<DocsSidebar>> {
493 const found = await this.viewerWorkspace(a.workspace, a.viewer);
494 if (!found.ok) return found;
495 const workspace = found.value;
496 const viewer = a.viewer!;
497 await this.ensureDefault(workspace, viewer);
498 const spaces = (await this.spacesFor(workspace, viewer)).filter((s) => s.role);
499 const ids = spaces.map((s) => s.row.id);
500 if (!ids.length) return ok({ spaces: [], favorites: [], recent: [], can_create_space: true, trash_count: 0 });
501 const marks = ids.map(() => "?").join(",");
502 const [pages, favorites, recent, trash] = await Promise.all([
503 this.db
504 .prepare(`SELECT id, space_id, parent_id, position, title, icon FROM pages WHERE space_id IN (${marks}) AND archived_at IS NULL ORDER BY position`)
505 .bind(...ids)
506 .all<Pick<PageRow, "id" | "space_id" | "parent_id" | "position" | "title" | "icon">>(),
507 this.db
508 .prepare(`SELECT p.id, p.space_id, p.title, p.icon FROM favorites f JOIN pages p ON p.id = f.page_id WHERE f.user_id = ? AND p.space_id IN (${marks}) AND p.archived_at IS NULL ORDER BY f.created_at`)
509 .bind(viewer.id, ...ids)
510 .all<Pick<PageRow, "id" | "space_id" | "title" | "icon">>(),
511 this.db
512 .prepare(`SELECT p.id, p.space_id, p.title, p.icon FROM page_views v JOIN pages p ON p.id = v.page_id WHERE v.user_id = ? AND p.space_id IN (${marks}) AND p.archived_at IS NULL ORDER BY v.viewed_at DESC LIMIT 8`)
513 .bind(viewer.id, ...ids)
514 .all<Pick<PageRow, "id" | "space_id" | "title" | "icon">>(),
515 this.db
516 .prepare(`SELECT COUNT(*) AS n FROM pages WHERE space_id IN (${marks}) AND archived_at IS NOT NULL`)
517 .bind(...ids)
518 .first<{ n: number }>(),
519 ]);
520 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
521 const ref = (r: Pick<PageRow, "id" | "space_id" | "title" | "icon">) => this.ref(workspace.slug, bySpace.get(r.space_id)!, r);
522 return ok({
523 spaces: spaces.map((s) => {
524 const mine = pages.results.filter((p) => p.space_id === s.row.id);
525 return {
526 ...this.toSpace(s, mine.length),
527 pages: mine.map((p): DocTreeNode => ({ id: p.id, parent_id: p.parent_id, position: p.position, title: p.title, icon: p.icon, slug: pageSlug(p.title, p.id) })),
528 };
529 }),
530 favorites: favorites.results.map(ref),
531 recent: recent.results.map(ref),
532 can_create_space: true,
533 trash_count: trash?.n ?? 0,
534 });
535 }
536
537 async home(a: { workspace: string; viewer: Viewer; project: string | null }): Promise<Result<DocsHome>> {
538 const found = await this.viewerWorkspace(a.workspace, a.viewer);
539 if (!found.ok) return found;
540 const workspace = found.value;
541 const viewer = a.viewer!;
542 await this.ensureDefault(workspace, viewer);
543 const spaces = (await this.spacesFor(workspace, viewer)).filter((s) => s.role);
544 const project = a.project ? projectRef(a.project) : null;
545 const ids = spaces.map((s) => s.row.id);
546 const allProjects = new Set(spaces.flatMap((s) => s.projects));
547 if (!ids.length) return ok({ recent: [], mine: [], spaces: [], projects: [...allProjects].sort(), project });
548 const marks = ids.map(() => "?").join(",");
549 const [recentRows, mineRows, pageProjects, counts] = await Promise.all([
550 this.db
551 .prepare(`SELECT id, workspace_id, space_id, parent_id, position, title, icon, cover, substr(markdown, 1, 600) AS markdown, created_by, created_at, updated_by, updated_at, archived_at, archived_by FROM pages WHERE space_id IN (${marks}) AND archived_at IS NULL ORDER BY updated_at DESC LIMIT 60`)
552 .bind(...ids)
553 .all<PageRow>(),
554 this.db
555 .prepare(
556 `SELECT id, workspace_id, space_id, parent_id, position, title, icon, cover, substr(markdown, 1, 600) AS markdown, created_by, created_at, updated_by, updated_at, archived_at, archived_by FROM pages WHERE space_id IN (${marks}) AND archived_at IS NULL AND (created_by = ? OR id IN (SELECT page_id FROM page_owners WHERE principal = ?)) ORDER BY updated_at DESC LIMIT 12`,
557 )
558 .bind(...ids, this.userKey(viewer), this.userKey(viewer))
559 .all<PageRow>(),
560 this.db
561 .prepare(`SELECT pp.page_id, pp.repo FROM page_projects pp JOIN pages p ON p.id = pp.page_id WHERE p.space_id IN (${marks})`)
562 .bind(...ids)
563 .all<{ page_id: string; repo: string }>(),
564 this.db
565 .prepare(`SELECT space_id, COUNT(*) AS n FROM pages WHERE space_id IN (${marks}) AND archived_at IS NULL GROUP BY space_id`)
566 .bind(...ids)
567 .all<{ space_id: string; n: number }>(),
568 ]);
569 for (const p of pageProjects.results) allProjects.add(p.repo);
570 const projectsOf = (pageId: string) => pageProjects.results.filter((p) => p.page_id === pageId).map((p) => p.repo);
571 const spaceProjects = new Map(spaces.map((s) => [s.row.id, s.projects]));
572 const keep = (r: PageRow) => inProject(project, projectsOf(r.id), spaceProjects.get(r.space_id) ?? []);
573 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
574 const [recent, mine] = await Promise.all([this.toPages(workspace, bySpace, recentRows.results.filter(keep).slice(0, 12)), this.toPages(workspace, bySpace, mineRows.results.filter(keep).slice(0, 8))]);
575 const count = new Map(counts.results.map((c) => [c.space_id, c.n]));
576 return ok({
577 recent,
578 mine,
579 spaces: spaces.filter((s) => !project || s.projects.includes(project) || recentRows.results.some((r) => r.space_id === s.row.id && keep(r))).map((s) => this.toSpace(s, count.get(s.row.id) ?? 0)),
580 projects: [...allProjects].sort(),
581 project,
582 });
583 }
584
585 // ── Spaces ──────────────────────────────────────────────────────────────
586
587 private async spaceMembers(workspace: Workspace, space: Space): Promise<DocSpaceMember[]> {
588 const people = await this.profiles(
589 workspace,
590 space.members.filter((m) => !m.principal.startsWith("team:")).map((m) => m.principal),
591 );
592 return space.members
593 .map((m): DocSpaceMember => {
594 const key = memberKey(m.principal);
595 if (key?.kind === "team") return { key: m.principal, kind: "team", name: key.id, display_name: `@${workspace.slug}/${key.id}`, avatar: null, role: m.role };
596 const p = people.get(m.principal)!;
597 return { key: m.principal, kind: p.kind, name: p.name, display_name: p.display_name, avatar: p.avatar, avatar_seed: p.avatar_seed ?? null, role: m.role };
598 })
599 .sort((x, y) => RANK[y.role] - RANK[x.role] || x.display_name.localeCompare(y.display_name));
600 }
601
602 async space(a: { workspace: string; space: string; viewer: Viewer }): Promise<Result<{ space: DocSpace; members: DocSpaceMember[]; pages: DocPage[] }>> {
603 const found = await this.viewerWorkspace(a.workspace, a.viewer);
604 if (!found.ok) return found;
605 const workspace = found.value;
606 const spaces = await this.spacesFor(workspace, a.viewer!, true);
607 const space = spaces.find((s) => s.row.slug === String(a.space ?? "").toLowerCase() || s.row.id === a.space);
608 if (!space?.role) return fail("not_found", "No such space.");
609 const rows = (
610 await this.db
611 .prepare("SELECT id, workspace_id, space_id, parent_id, position, title, icon, cover, substr(markdown, 1, 600) AS markdown, created_by, created_at, updated_by, updated_at, archived_at, archived_by FROM pages WHERE space_id = ? AND archived_at IS NULL ORDER BY position")
612 .bind(space.row.id)
613 .all<PageRow>()
614 ).results;
615 const [members, pages] = await Promise.all([this.spaceMembers(workspace, space), this.toPages(workspace, new Map([[space.row.id, space.row]]), rows)]);
616 return ok({ space: this.toSpace(space, rows.length), members, pages });
617 }
618
619 async createSpace(a: { workspace: string; viewer: Viewer; input: NewDocSpace }): Promise<Result<DocSpace>> {
620 const found = await this.viewerWorkspace(a.workspace, a.viewer);
621 if (!found.ok) return found;
622 const workspace = found.value;
623 const viewer = a.viewer!;
624 const input = a.input ?? ({} as NewDocSpace);
625 const name = cleanTitle(input.name).slice(0, 80);
626 if (!name) return fail("invalid", "Name the space.");
627 const kind: DocSpaceKind = input.kind === "team" || input.kind === "private" ? input.kind : "workspace";
628 const team = kind === "team" ? String(input.team ?? "").trim().toLowerCase() : null;
629 if (kind === "team") {
630 if (!team) return fail("invalid", "Choose the team the space is for.");
631 const teams = await this.teamsOf(workspace);
632 const known = [...teams.values()].some((set) => set.has(team));
633 if (!known) return fail("invalid", "No such team in this workspace.");
634 }
635 const taken = new Set((await this.db.prepare("SELECT slug FROM spaces WHERE workspace_id = ?").bind(workspace.id).all<{ slug: string }>()).results.map((r) => r.slug));
636 let slug: string;
637 if (input.slug) {
638 const wanted = validSpaceSlug(input.slug);
639 if (!wanted) return fail("invalid", "A space's address is lowercase letters, numbers and hyphens.");
640 if (taken.has(wanted)) return fail("conflict", "Another space has that address.");
641 slug = wanted;
642 } else slug = freeSlug(name, taken);
643 const defaultRole: DocRole | null = kind === "private" ? null : isRole(input.default_role) ? input.default_role : "edit";
644 const id = newId("spc");
645 const at = now();
646 const me = this.userKey(viewer);
647 const projects = cleanProjects(input.projects);
648 await this.db.batch([
649 this.db
650 .prepare(
651 "INSERT INTO spaces (id, workspace_id, slug, name, description, icon, kind, team, default_role, agent_mode, is_default, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)",
652 )
653 .bind(id, workspace.id, slug, name, String(input.description ?? "").trim().slice(0, 300) || null, cleanIcon(input.icon), kind, team, defaultRole, input.agent_mode === "edit" ? "edit" : "suggest", me, at),
654 // Whoever makes a space manages it.
655 this.db.prepare("INSERT INTO space_members (space_id, principal, role, added_by, added_at) VALUES (?, ?, 'manage', ?, ?)").bind(id, me, me, at),
656 ...projects.map((repo) => this.db.prepare("INSERT INTO space_projects (space_id, repo) VALUES (?, ?)").bind(id, repo)),
657 ]);
658 const spaces = await this.spacesFor(workspace, viewer);
659 const space = spaces.find((s) => s.row.id === id)!;
660 return ok(this.toSpace(space));
661 }
662
663 async updateSpace(a: { workspace: string; space_id: string; viewer: Viewer; change: DocSpaceChange }): Promise<Result<DocSpace>> {
664 const found = await this.viewerWorkspace(a.workspace, a.viewer);
665 if (!found.ok) return found;
666 const workspace = found.value;
667 const spaces = await this.spacesFor(workspace, a.viewer!, true);
668 const space = spaces.find((s) => s.row.id === a.space_id);
669 if (!space?.role) return fail("not_found", "No such space.");
670 if (!atLeast(space.role, "manage")) return fail("forbidden", "Only people with full access can change a space.");
671 const c = a.change ?? {};
672 const sets: string[] = [];
673 const values: unknown[] = [];
674 const set = (column: string, value: unknown) => {
675 sets.push(`${column} = ?`);
676 values.push(value);
677 };
678 if (c.name !== undefined) {
679 const name = cleanTitle(c.name).slice(0, 80);
680 if (!name) return fail("invalid", "Name the space.");
681 set("name", name);
682 }
683 if (c.description !== undefined) set("description", String(c.description ?? "").trim().slice(0, 300) || null);
684 if (c.icon !== undefined) set("icon", cleanIcon(c.icon));
685 if (c.slug !== undefined && c.slug !== space.row.slug) {
686 const wanted = validSpaceSlug(String(c.slug ?? ""));
687 if (!wanted) return fail("invalid", "A space's address is lowercase letters, numbers and hyphens.");
688 const clash = await this.db.prepare("SELECT 1 FROM spaces WHERE workspace_id = ? AND slug = ? AND id <> ?").bind(workspace.id, wanted, space.row.id).first();
689 if (clash) return fail("conflict", "Another space has that address.");
690 set("slug", wanted);
691 }
692 if (c.kind !== undefined && c.kind !== space.row.kind) {
693 if (space.row.is_default) return fail("invalid", "The General space is always the whole workspace's.");
694 if (c.kind !== "workspace" && c.kind !== "team" && c.kind !== "private") return fail("invalid", "Choose who the space is for.");
695 set("kind", c.kind);
696 if (c.kind === "private") set("default_role", null);
697 else if (!space.row.default_role) set("default_role", "edit");
698 if (c.kind === "private" && !space.members.some((m) => m.role === "manage")) {
699 // Someone must still manage it: whoever made it private.
700 await this.db
701 .prepare("INSERT INTO space_members (space_id, principal, role, added_by, added_at) VALUES (?, ?, 'manage', ?, ?) ON CONFLICT (space_id, principal) DO UPDATE SET role = 'manage'")
702 .bind(space.row.id, this.userKey(a.viewer!), this.userKey(a.viewer!), now())
703 .run();
704 }
705 }
706 if (c.team !== undefined) set("team", c.team ? String(c.team).trim().toLowerCase() : null);
707 if (c.default_role !== undefined && (c.kind ?? space.row.kind) !== "private") set("default_role", isRole(c.default_role) ? c.default_role : null);
708 if (c.agent_mode !== undefined) set("agent_mode", c.agent_mode === "edit" ? "edit" : "suggest");
709 if (c.archived !== undefined) {
710 if (space.row.is_default && c.archived) return fail("invalid", "The General space can't be archived.");
711 set("archived_at", c.archived ? now() : null);
712 }
713 const statements: D1PreparedStatement[] = [];
714 if (sets.length) statements.push(this.db.prepare(`UPDATE spaces SET ${sets.join(", ")} WHERE id = ?`).bind(...values, space.row.id));
715 if (c.projects !== undefined) {
716 statements.push(this.db.prepare("DELETE FROM space_projects WHERE space_id = ?").bind(space.row.id));
717 for (const repo of cleanProjects(c.projects)) statements.push(this.db.prepare("INSERT INTO space_projects (space_id, repo) VALUES (?, ?)").bind(space.row.id, repo));
718 }
719 if (statements.length) await this.db.batch(statements);
720 this.workspaces.clear();
721 const after = (await this.spacesFor(workspace, a.viewer!, true)).find((s) => s.row.id === space.row.id)!;
722 return ok(this.toSpace(after));
723 }
724
725 async setSpaceMember(a: { workspace: string; space_id: string; viewer: Viewer; member: string; role: DocRole | null }): Promise<Result<DocSpaceMember[]>> {
726 const found = await this.viewerWorkspace(a.workspace, a.viewer);
727 if (!found.ok) return found;
728 const workspace = found.value;
729 const spaces = await this.spacesFor(workspace, a.viewer!, true);
730 const space = spaces.find((s) => s.row.id === a.space_id);
731 if (!space?.role) return fail("not_found", "No such space.");
732 if (!atLeast(space.role, "manage")) return fail("forbidden", "Only people with full access can change who is in a space.");
733 const key = memberKey(String(a.member ?? ""));
734 if (!key) return fail("invalid", "Choose a person, agent or team.");
735 const role = a.role === null ? null : isRole(a.role) ? a.role : null;
736 if (a.role !== null && !role) return fail("invalid", "Choose a role.");
737 // They must belong to the workspace.
738 if (key.kind === "user") {
739 await this.nameUsers([key.id]);
740 const username = this.usernames.get(key.id);
741 if (!username || !(await this.members(workspace)).has(username.toLowerCase())) return fail("invalid", "Only members of the workspace can be added.");
742 } else if (key.kind === "agent") {
743 const agent = (await this.agentsById([key.id])).get(key.id);
744 if (!agent || agent.workspace_id !== workspace.id || agent.archived_at) return fail("invalid", "No such agent in this workspace.");
745 } else {
746 const teams = await this.teamsOf(workspace);
747 if (![...teams.values()].some((set) => set.has(key.id.toLowerCase()))) return fail("invalid", "No such team in this workspace.");
748 }
749 const principal = key.kind === "team" ? `team:${key.id.toLowerCase()}` : `${key.kind}:${key.id}`;
750 if (leavesNoManager(space.row.kind, space.members, principal, role)) return fail("invalid", "Someone must keep full access to a private space.");
751 if (role) {
752 await this.db
753 .prepare("INSERT INTO space_members (space_id, principal, role, added_by, added_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (space_id, principal) DO UPDATE SET role = excluded.role")
754 .bind(space.row.id, principal, role, this.userKey(a.viewer!), now())
755 .run();
756 } else {
757 await this.db.prepare("DELETE FROM space_members WHERE space_id = ? AND principal = ?").bind(space.row.id, principal).run();
758 }
759 const after = (await this.spacesFor(workspace, a.viewer!, true)).find((s) => s.row.id === space.row.id)!;
760 return ok(await this.spaceMembers(workspace, after));
761 }
762
763 // ── Pages ───────────────────────────────────────────────────────────────
764
765 async page(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPageDetail>> {
766 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
767 if (!found.ok) return found;
768 const { workspace, page, space, spaces } = found.value;
769 const viewer = a.viewer!;
770 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
771 const readable = new Set(spaces.filter((s) => s.role).map((s) => s.row.id));
772 const [tree, backlinks, children, favorite, viewed, suggestions] = await Promise.all([
773 this.db.prepare("SELECT id, space_id, parent_id, position, title, icon FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>(),
774 this.db
775 .prepare("SELECT p.id, p.space_id, p.title, p.icon FROM page_links l JOIN pages p ON p.id = l.from_page WHERE l.to_page = ? AND p.archived_at IS NULL LIMIT 50")
776 .bind(page.id)
777 .all<PageRow>(),
778 this.db.prepare("SELECT id, space_id, title, icon FROM pages WHERE parent_id = ? AND archived_at IS NULL ORDER BY position").bind(page.id).all<PageRow>(),
779 this.db.prepare("SELECT 1 AS yes FROM favorites WHERE user_id = ? AND page_id = ?").bind(viewer.id, page.id).first<{ yes: number }>(),
780 this.db.prepare("SELECT viewed_at FROM page_views WHERE user_id = ? AND page_id = ?").bind(viewer.id, page.id).first<{ viewed_at: string }>(),
781 this.openSuggestions(workspace, page.id),
782 ]);
783 this.defer(
784 this.db
785 .prepare("INSERT INTO page_views (page_id, user_id, viewed_at) VALUES (?, ?, ?) ON CONFLICT (page_id, user_id) DO UPDATE SET viewed_at = excluded.viewed_at")
786 .bind(page.id, viewer.id, now())
787 .run(),
788 );
789 const [detail] = await this.toPages(workspace, bySpace, [page]);
790 return ok({
791 page: detail!,
792 space: this.toSpace(space),
793 breadcrumbs: ancestors(tree.results, page.id).map((r) => this.ref(workspace.slug, space.row, r)),
794 markdown: page.markdown,
795 role: space.role!,
796 backlinks: backlinks.results.filter((r) => readable.has(r.space_id)).map((r) => this.ref(workspace.slug, bySpace.get(r.space_id)!, r)),
797 children: children.results.map((r) => this.ref(workspace.slug, space.row, r)),
798 favorite: !!favorite,
799 last_viewed_at: viewed?.viewed_at ?? null,
800 suggestions,
801 });
802 }
803
804 /** Where a new page in `space` from `input` starts: its Markdown and title. */
805 private async startingPoint(workspace: Workspace, input: NewDocPage): Promise<{ markdown: string; title: string; icon: string | null }> {
806 let markdown = String(input.markdown ?? "").slice(0, MAX_MARKDOWN);
807 let title = cleanTitle(input.title);
808 let icon = cleanIcon(input.icon);
809 if (input.template_id) {
810 const template = builtinTemplate(input.template_id) ?? (await this.savedTemplate(workspace, input.template_id));
811 if (template) {
812 markdown = template.markdown;
813 if (!title) title = template.name;
814 if (!icon) icon = template.icon;
815 }
816 }
817 return { markdown, title, icon };
818 }
819
820 private async savedTemplate(workspace: Workspace, id: string): Promise<DocTemplate | null> {
821 const row = await this.db
822 .prepare("SELECT * FROM templates WHERE id = ? AND workspace_id = ?")
823 .bind(id, workspace.id)
824 .first<{ id: string; name: string; description: string; icon: string; markdown: string; created_by: string }>();
825 if (!row) return null;
826 return { id: row.id, name: row.name, description: row.description, icon: row.icon, markdown: row.markdown, builtin: false, created_by: parsePrincipalKey(row.created_by) };
827 }
828
829 /** Inserts a page row and fills its room. */
830 private async insertPage(
831 workspace: Workspace,
832 space: SpaceRow,
833 author: string,
834 input: { parent_id: string | null; title: string; icon: string | null; markdown: string; state?: Uint8Array | null; projects?: string[]; owners?: string[]; position?: number },
835 ): Promise<PageRow> {
836 const rows = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(space.id).all<PageRow>()).results;
837 const id = newId("pag");
838 const at = now();
839 const position = input.position ?? lastPosition(rows, input.parent_id);
840 const row: PageRow = {
841 id,
842 workspace_id: workspace.id,
843 space_id: space.id,
844 parent_id: input.parent_id,
845 position,
846 title: input.title,
847 icon: input.icon,
848 cover: null,
849 markdown: input.markdown,
850 created_by: author,
851 created_at: at,
852 updated_by: author,
853 updated_at: at,
854 archived_at: null,
855 archived_by: null,
856 };
857 await this.db.batch([
858 this.db
859 .prepare(
860 "INSERT INTO pages (id, workspace_id, space_id, parent_id, position, title, icon, cover, markdown, created_by, created_at, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)",
861 )
862 .bind(id, workspace.id, space.id, input.parent_id, position, input.title, input.icon, input.markdown, author, at, author, at),
863 this.db.prepare("INSERT INTO pages_fts (page_id, title, body) VALUES (?, ?, ?)").bind(id, input.title, searchText(input.markdown)),
864 this.db.prepare("INSERT INTO page_owners (page_id, principal) VALUES (?, ?)").bind(id, author),
865 ...(input.owners ?? []).filter((o) => o !== author).map((o) => this.db.prepare("INSERT OR IGNORE INTO page_owners (page_id, principal) VALUES (?, ?)").bind(id, o)),
866 ...(input.projects ?? []).map((repo) => this.db.prepare("INSERT OR IGNORE INTO page_projects (page_id, repo) VALUES (?, ?)").bind(id, repo)),
867 this.db
868 .prepare("INSERT INTO page_versions (id, page_id, created_at, kind, authors, note, markdown, state) VALUES (?, ?, ?, 'created', ?, NULL, ?, NULL)")
869 .bind(newId("ver"), id, at, JSON.stringify([author]), input.markdown),
870 ]);
871 await this.room(id).ensure({ page_id: id, workspace_slug: workspace.slug, markdown: input.markdown, state: input.state ?? null });
872 return row;
873 }
874
875 async createPage(a: { workspace: string; viewer: Viewer; input: NewDocPage }): Promise<Result<DocPage>> {
876 const found = await this.viewerWorkspace(a.workspace, a.viewer);
877 if (!found.ok) return found;
878 const workspace = found.value;
879 const input = a.input ?? ({} as NewDocPage);
880 const spaces = await this.spacesFor(workspace, a.viewer!);
881 const space = spaces.find((s) => s.row.id === input.space_id) ?? (input.space_id ? null : spaces.find((s) => s.row.is_default));
882 if (!space?.role) return fail("not_found", "No such space.");
883 if (!atLeast(space.role, "edit")) return fail("forbidden", "You can read this space but not add pages to it.");
884 const parent = input.parent_id
885 ? await this.db.prepare("SELECT id FROM pages WHERE id = ? AND space_id = ? AND archived_at IS NULL").bind(input.parent_id, space.row.id).first<{ id: string }>()
886 : null;
887 if (input.parent_id && !parent) return fail("not_found", "No such parent page.");
888 const start = await this.startingPoint(workspace, input);
889 const row = await this.insertPage(workspace, space.row, this.userKey(a.viewer!), {
890 parent_id: parent?.id ?? null,
891 title: start.title,
892 icon: start.icon,
893 markdown: start.markdown,
894 projects: cleanProjects(input.projects),
895 });
896 const [page] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [row]);
897 return ok(page!);
898 }
899
900 async updatePage(a: { workspace: string; page_id: string; viewer: Viewer; change: DocPageChange }): Promise<Result<DocPage>> {
901 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
902 if (!found.ok) return found;
903 const { workspace, page, space } = found.value;
904 const c = a.change ?? {};
905 const statements: D1PreparedStatement[] = [];
906 const sets: string[] = [];
907 const values: unknown[] = [];
908 if (c.title !== undefined) {
909 sets.push("title = ?");
910 values.push(cleanTitle(c.title));
911 statements.push(this.db.prepare("UPDATE pages_fts SET title = ? WHERE page_id = ?").bind(cleanTitle(c.title), page.id));
912 }
913 if (c.icon !== undefined) {
914 sets.push("icon = ?");
915 values.push(cleanIcon(c.icon));
916 }
917 if (c.cover !== undefined) {
918 sets.push("cover = ?");
919 values.push(cleanCover(c.cover));
920 }
921 if (sets.length) {
922 sets.push("updated_at = ?", "updated_by = ?");
923 values.push(now(), this.userKey(a.viewer!));
924 statements.unshift(this.db.prepare(`UPDATE pages SET ${sets.join(", ")} WHERE id = ?`).bind(...values, page.id));
925 }
926 if (c.projects !== undefined) {
927 statements.push(this.db.prepare("DELETE FROM page_projects WHERE page_id = ?").bind(page.id));
928 for (const repo of cleanProjects(c.projects)) statements.push(this.db.prepare("INSERT INTO page_projects (page_id, repo) VALUES (?, ?)").bind(page.id, repo));
929 }
930 if (c.owners !== undefined) {
931 const owners = [...new Set((Array.isArray(c.owners) ? c.owners : []).map(String).filter((k) => memberKey(k)?.kind === "user" || memberKey(k)?.kind === "agent"))].slice(0, 20);
932 statements.push(this.db.prepare("DELETE FROM page_owners WHERE page_id = ?").bind(page.id));
933 for (const o of owners) statements.push(this.db.prepare("INSERT INTO page_owners (page_id, principal) VALUES (?, ?)").bind(page.id, o));
934 }
935 if (statements.length) await this.db.batch(statements);
936 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
937 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
938 this.tell(page.id, { type: "page.updated", page: detail! });
939 return ok(detail!);
940 }
941
942 async movePage(a: { workspace: string; page_id: string; viewer: Viewer; move: DocMove }): Promise<Result<DocPage>> {
943 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
944 if (!found.ok) return found;
945 const { workspace, page, spaces } = found.value;
946 const move = a.move ?? ({ parent_id: null } as DocMove);
947 const target = move.space_id ? spaces.find((s) => s.row.id === move.space_id) : spaces.find((s) => s.row.id === page.space_id);
948 if (!target?.role || target.row.archived_at) return fail("not_found", "No such space.");
949 if (!atLeast(target.role, "edit")) return fail("forbidden", "You can't add pages to that space.");
950 const [sourceRows, targetRows] = await Promise.all([
951 this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(page.space_id).all<PageRow>(),
952 this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(target.row.id).all<PageRow>(),
953 ]);
954 const parent = move.parent_id ?? null;
955 if (parent && !targetRows.results.some((r) => r.id === parent)) return fail("not_found", "No such parent page in that space.");
956 if (wouldCycle(sourceRows.results, page.id, parent)) return fail("invalid", "A page can't go inside itself.");
957 const placed = placeBefore(targetRows.results, page.id, parent, move.before_id ?? null);
958 const statements: D1PreparedStatement[] = [this.db.prepare("UPDATE pages SET parent_id = ?, position = ? WHERE id = ?").bind(parent, placed.position, page.id)];
959 for (const [id, position] of placed.renumber) statements.push(this.db.prepare("UPDATE pages SET position = ? WHERE id = ?").bind(position, id));
960 if (target.row.id !== page.space_id) {
961 // The page and everything under it move to the other space.
962 const all = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>()).results;
963 for (const id of descendants(all, page.id)) statements.push(this.db.prepare("UPDATE pages SET space_id = ? WHERE id = ?").bind(target.row.id, id));
964 }
965 await this.db.batch(statements);
966 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
967 const [detail] = await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), [after!]);
968 this.tell(page.id, { type: "page.updated", page: detail! });
969 return ok(detail!);
970 }
971
972 async duplicatePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
973 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
974 if (!found.ok) return found;
975 const { workspace, page, space } = found.value;
976 const room = this.room(page.id);
977 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
978 const [state, read] = await Promise.all([room.state(), room.read()]);
979 const rows = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(page.space_id).all<PageRow>()).results;
980 const next = rows.filter((r) => r.parent_id === page.parent_id).sort((x, y) => x.position - y.position).find((r) => r.position > page.position);
981 const row = await this.insertPage(workspace, space.row, this.userKey(a.viewer!), {
982 parent_id: page.parent_id,
983 title: `${page.title || "Untitled"} (copy)`.slice(0, MAX_TITLE),
984 icon: page.icon,
985 markdown: read.markdown,
986 state,
987 position: next ? (page.position + next.position) / 2 : page.position + 1024,
988 });
989 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [row]);
990 return ok(detail!);
991 }
992
993 async archivePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
994 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
995 if (!found.ok) return found;
996 const { workspace, page, space } = found.value;
997 const all = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(page.space_id).all<PageRow>()).results;
998 const ids = descendants(all, page.id);
999 const at = now();
1000 await this.db.batch(ids.map((id) => this.db.prepare("UPDATE pages SET archived_at = ?, archived_by = ? WHERE id = ? AND archived_at IS NULL").bind(at, this.userKey(a.viewer!), id)));
1001 for (const id of ids) this.defer(this.room(id).closeAll("Moved to the trash").catch(() => undefined));
1002 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1003 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1004 return ok(detail!);
1005 }
1006
1007 async restorePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
1008 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1009 if (!found.ok) return found;
1010 const { workspace, page, space } = found.value;
1011 if (!page.archived_at) return fail("invalid", "That page isn't in the trash.");
1012 // It comes back with what was trashed with it; under its parent if that is still there.
1013 const all = (await this.db.prepare("SELECT id, parent_id, position, archived_at FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>()).results;
1014 const parent = page.parent_id ? all.find((r) => r.id === page.parent_id) : null;
1015 const parentGone = !!page.parent_id && (!parent || !!parent.archived_at);
1016 const ids = descendants(all, page.id).filter((id) => all.find((r) => r.id === id)?.archived_at === page.archived_at);
1017 const statements = ids.map((id) => this.db.prepare("UPDATE pages SET archived_at = NULL, archived_by = NULL WHERE id = ?").bind(id));
1018 if (parentGone) statements.push(this.db.prepare("UPDATE pages SET parent_id = NULL WHERE id = ?").bind(page.id));
1019 await this.db.batch(statements);
1020 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1021 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1022 return ok(detail!);
1023 }
1024
1025 async deletePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1026 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "manage");
1027 if (!found.ok) return found;
1028 const { page } = found.value;
1029 if (!page.archived_at) return fail("invalid", "Move the page to the trash first.");
1030 const all = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>()).results;
1031 const ids = descendants(all, page.id);
1032 await this.db.batch(ids.flatMap((id) => [this.db.prepare("DELETE FROM pages_fts WHERE page_id = ?").bind(id), this.db.prepare("DELETE FROM pages WHERE id = ?").bind(id)]));
1033 return ok(true);
1034 }
1035
1036 async trash(a: { workspace: string; viewer: Viewer }): Promise<Result<DocPage[]>> {
1037 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1038 if (!found.ok) return found;
1039 const workspace = found.value;
1040 const spaces = (await this.spacesFor(workspace, a.viewer!)).filter((s) => atLeast(s.role, "edit"));
1041 if (!spaces.length) return ok([]);
1042 const marks = spaces.map(() => "?").join(",");
1043 const rows = (
1044 await this.db
1045 .prepare(`SELECT ${PAGE_COLUMNS} FROM pages WHERE space_id IN (${marks}) AND archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT 200`)
1046 .bind(...spaces.map((s) => s.row.id))
1047 .all<PageRow>()
1048 ).results;
1049 return ok(await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), rows));
1050 }
1051
1052 async favorite(a: { workspace: string; page_id: string; viewer: Viewer; on: boolean }): Promise<Result<boolean>> {
1053 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1054 if (!found.ok) return found;
1055 if (a.on) {
1056 await this.db.prepare("INSERT OR IGNORE INTO favorites (user_id, page_id, created_at) VALUES (?, ?, ?)").bind(a.viewer!.id, a.page_id, now()).run();
1057 } else {
1058 await this.db.prepare("DELETE FROM favorites WHERE user_id = ? AND page_id = ?").bind(a.viewer!.id, a.page_id).run();
1059 }
1060 return ok(!!a.on);
1061 }
1062
1063 // ── Search ──────────────────────────────────────────────────────────────
1064
1065 /** Full text over `spaceIds`, best first. */
1066 private async searchIn(workspace: Workspace, spaces: Space[], query: DocSearchQuery): Promise<DocSearchHit[]> {
1067 const q = ftsQuery(query.query);
1068 const ids = searchSpaces(
1069 spaces.map((s) => s.row.id),
1070 query.space_id ?? null,
1071 );
1072 if (!ids.length) return [];
1073 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1074 const marks = ids.map(() => "?").join(",");
1075 const project = query.project ? projectRef(query.project) : null;
1076 type Hit = { id: string; space_id: string; title: string; icon: string | null; updated_at: string; snippet: string };
1077 let rows: Hit[];
1078 if (q) {
1079 rows = (
1080 await this.db
1081 .prepare(
1082 `SELECT p.id, p.space_id, p.title, p.icon, p.updated_at, snippet(pages_fts, 2, '[[', ']]', '…', 16) AS snippet
1083 FROM pages_fts JOIN pages p ON p.id = pages_fts.page_id
1084 WHERE pages_fts MATCH ? AND p.space_id IN (${marks}) AND p.archived_at IS NULL
1085 ORDER BY bm25(pages_fts, 0, 8.0, 1.0) LIMIT ?`,
1086 )
1087 .bind(q, ...ids, limit * 3)
1088 .all<Hit>()
1089 ).results;
1090 } else {
1091 rows = (
1092 await this.db
1093 .prepare(`SELECT id, space_id, title, icon, updated_at, substr(markdown, 1, 160) AS snippet FROM pages WHERE space_id IN (${marks}) AND archived_at IS NULL ORDER BY updated_at DESC LIMIT ?`)
1094 .bind(...ids, limit * 3)
1095 .all<Hit>()
1096 ).results;
1097 }
1098 const pageIds = rows.map((r) => r.id);
1099 const projects = pageIds.length
1100 ? (
1101 await this.db
1102 .prepare(`SELECT page_id, repo FROM page_projects WHERE page_id IN (${pageIds.map(() => "?").join(",")})`)
1103 .bind(...pageIds)
1104 .all<{ page_id: string; repo: string }>()
1105 ).results
1106 : [];
1107 const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
1108 return rows
1109 .map((r) => {
1110 const space = bySpace.get(r.space_id)!;
1111 const own = projects.filter((p) => p.page_id === r.id).map((p) => p.repo);
1112 return { r, space, own };
1113 })
1114 .filter(({ space, own }) => inProject(project, own, space.projects))
1115 .slice(0, limit)
1116 .map(({ r, space, own }) => ({
1117 ...this.ref(workspace.slug, space.row, r),
1118 space_name: space.row.name,
1119 snippet: q ? r.snippet : excerpt(r.snippet, 140),
1120 updated_at: r.updated_at,
1121 projects: [...new Set([...own, ...space.projects])],
1122 }));
1123 }
1124
1125 async search(a: { workspace: string; viewer: Viewer; query: DocSearchQuery }): Promise<Result<DocSearchHit[]>> {
1126 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1127 if (!found.ok) return found;
1128 const workspace = found.value;
1129 const spaces = (await this.spacesFor(workspace, a.viewer!)).filter((s) => s.role);
1130 return ok(await this.searchIn(workspace, spaces, a.query ?? { query: "" }));
1131 }
1132
1133 // ── History ─────────────────────────────────────────────────────────────
1134
1135 private async toVersions(workspace: Workspace, rows: Omit<VersionRow, "markdown" | "state">[]): Promise<DocVersion[]> {
1136 const authors = rows.map((r) => {
1137 try {
1138 return JSON.parse(r.authors) as string[];
1139 } catch {
1140 return [];
1141 }
1142 });
1143 const people = await this.profiles(workspace, authors.flat());
1144 return rows.map((r, i) => ({ id: r.id, page_id: r.page_id, created_at: r.created_at, kind: r.kind, note: r.note, authors: authors[i]!.map((k) => people.get(k)!).filter(Boolean) }));
1145 }
1146
1147 async versions(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocVersion[]>> {
1148 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1149 if (!found.ok) return found;
1150 // Whatever is unsaved goes in first, so the newest version is now.
1151 await this.room(a.page_id)
1152 .flush()
1153 .catch(() => undefined);
1154 const rows = (
1155 await this.db
1156 .prepare("SELECT id, page_id, created_at, kind, authors, note FROM page_versions WHERE page_id = ? ORDER BY created_at DESC LIMIT 200")
1157 .bind(a.page_id)
1158 .all<Omit<VersionRow, "markdown" | "state">>()
1159 ).results;
1160 return ok(await this.toVersions(found.value.workspace, rows));
1161 }
1162
1163 async version(a: { workspace: string; page_id: string; version_id: string; viewer: Viewer }): Promise<Result<DocVersionDetail>> {
1164 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1165 if (!found.ok) return found;
1166 const row = await this.db
1167 .prepare("SELECT id, page_id, created_at, kind, authors, note, markdown FROM page_versions WHERE id = ? AND page_id = ?")
1168 .bind(a.version_id, a.page_id)
1169 .first<Omit<VersionRow, "state">>();
1170 if (!row) return fail("not_found", "No such version.");
1171 const before = await this.db
1172 .prepare("SELECT markdown FROM page_versions WHERE page_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT 1")
1173 .bind(a.page_id, row.created_at)
1174 .first<{ markdown: string }>();
1175 const [version] = await this.toVersions(found.value.workspace, [row]);
1176 return ok({ ...version!, markdown: row.markdown, diff: diffLines(before?.markdown ?? "", row.markdown) });
1177 }
1178
1179 async restoreVersion(a: { workspace: string; page_id: string; version_id: string; viewer: Viewer }): Promise<Result<DocVersion>> {
1180 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1181 if (!found.ok) return found;
1182 const { workspace, page } = found.value;
1183 const row = await this.db.prepare("SELECT * FROM page_versions WHERE id = ? AND page_id = ?").bind(a.version_id, page.id).first<VersionRow>();
1184 if (!row) return fail("not_found", "No such version.");
1185 const room = this.room(page.id);
1186 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1187 const when = new Date(row.created_at).toISOString().slice(0, 16).replace("T", " ");
1188 const origin: Origin = { key: this.userKey(a.viewer!), kind: "restore", note: `Restored the version of ${when} UTC` };
1189 const versionId = await room.restore({ state: row.state ? new Uint8Array(row.state) : null, markdown: row.markdown }, origin);
1190 const created = versionId
1191 ? await this.db.prepare("SELECT id, page_id, created_at, kind, authors, note FROM page_versions WHERE id = ?").bind(versionId).first<Omit<VersionRow, "markdown" | "state">>()
1192 : null;
1193 if (!created) return fail("conflict", "The page could not be restored. Try again.");
1194 const [version] = await this.toVersions(workspace, [created]);
1195 this.tell(page.id, { type: "version.created", version: version! });
1196 return ok(version!);
1197 }
1198
1199 // ── Templates and export ────────────────────────────────────────────────
1200
1201 async templates(a: { workspace: string; viewer: Viewer }): Promise<Result<DocTemplate[]>> {
1202 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1203 if (!found.ok) return found;
1204 const rows = (
1205 await this.db
1206 .prepare("SELECT * FROM templates WHERE workspace_id = ? ORDER BY name COLLATE NOCASE")
1207 .bind(found.value.id)
1208 .all<{ id: string; name: string; description: string; icon: string; markdown: string; created_by: string }>()
1209 ).results;
1210 return ok([
1211 ...BUILTIN_TEMPLATES,
1212 ...rows.map((r) => ({ id: r.id, name: r.name, description: r.description, icon: r.icon, markdown: r.markdown, builtin: false, created_by: parsePrincipalKey(r.created_by) })),
1213 ]);
1214 }
1215
1216 async saveTemplate(a: { workspace: string; viewer: Viewer; input: { page_id: string; name: string; description?: string | null } }): Promise<Result<DocTemplate>> {
1217 const found = await this.pageFor(a.workspace, a.input?.page_id, a.viewer, "view");
1218 if (!found.ok) return found;
1219 const { workspace, page } = found.value;
1220 const name = cleanTitle(a.input.name || page.title).slice(0, 80);
1221 if (!name) return fail("invalid", "Name the template.");
1222 const room = this.room(page.id);
1223 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1224 const { markdown } = await room.read();
1225 const id = newId("tpl");
1226 const description = String(a.input.description ?? "").trim().slice(0, 200);
1227 await this.db
1228 .prepare("INSERT INTO templates (id, workspace_id, name, description, icon, markdown, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
1229 .bind(id, workspace.id, name, description, page.icon ?? "📄", markdown, this.userKey(a.viewer!), now())
1230 .run();
1231 return ok({ id, name, description, icon: page.icon ?? "📄", markdown, builtin: false, created_by: { kind: "user", id: a.viewer!.id } });
1232 }
1233
1234 async deleteTemplate(a: { workspace: string; template_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1235 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1236 if (!found.ok) return found;
1237 const row = await this.db.prepare("SELECT created_by FROM templates WHERE id = ? AND workspace_id = ?").bind(a.template_id, found.value.id).first<{ created_by: string }>();
1238 if (!row) return fail("not_found", "No such template.");
1239 if (row.created_by !== this.userKey(a.viewer!) && !this.viewerOwner(a.viewer!, a.workspace)) return fail("forbidden", "Only whoever saved a template, or an owner, can delete it.");
1240 await this.db.prepare("DELETE FROM templates WHERE id = ?").bind(a.template_id).run();
1241 return ok(true);
1242 }
1243
1244 async exportPage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<{ filename: string; markdown: string }>> {
1245 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1246 if (!found.ok) return found;
1247 const { workspace, page } = found.value;
1248 const room = this.room(page.id);
1249 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1250 const { markdown } = await room.read();
1251 const title = page.title || "Untitled";
1252 return ok({ filename: `${title.replace(/[\\/:*?"<>|]+/g, " ").trim() || "page"}.md`, markdown: `# ${title}\n\n${markdown}` });
1253 }
1254
1255 async exportSpace(a: { workspace: string; space_id: string; viewer: Viewer }): Promise<Result<{ name: string; files: { path: string; markdown: string }[] }>> {
1256 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1257 if (!found.ok) return found;
1258 const spaces = await this.spacesFor(found.value, a.viewer!);
1259 const space = spaces.find((s) => s.row.id === a.space_id);
1260 if (!space?.role) return fail("not_found", "No such space.");
1261 const rows = (
1262 await this.db.prepare("SELECT id, parent_id, position, title, markdown FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(space.row.id).all<PageRow>()
1263 ).results;
1264 const paths = exportPaths(rows.map((r) => ({ ...r, title: r.title || "Untitled" })));
1265 return ok({
1266 name: space.row.slug,
1267 files: rows.map((r) => ({ path: paths.get(r.id)!, markdown: `# ${r.title || "Untitled"}\n\n${r.markdown}` })).sort((x, y) => x.path.localeCompare(y.path)),
1268 });
1269 }
1270
1271 // ── Suggestions ─────────────────────────────────────────────────────────
1272
1273 private async toSuggestions(workspace: Workspace, rows: SuggestionRow[], blocks: (string[] | null)[] = []): Promise<DocSuggestion[]> {
1274 const people = await this.profiles(
1275 workspace,
1276 rows.flatMap((r) => [r.author, r.asked_by, r.decided_by].filter((k): k is string => !!k)),
1277 );
1278 return rows.map((r, i) => ({
1279 id: r.id,
1280 page_id: r.page_id,
1281 author: people.get(r.author)!,
1282 asked_by: r.asked_by ? (people.get(r.asked_by) ?? null) : null,
1283 target: JSON.parse(r.target) as DocEditTarget,
1284 before_markdown: r.before_markdown,
1285 after_markdown: r.after_markdown,
1286 note: r.note,
1287 status: r.status,
1288 created_at: r.created_at,
1289 decided_by: r.decided_by ? (people.get(r.decided_by) ?? null) : null,
1290 decided_at: r.decided_at,
1291 block_ids: blocks[i] ?? [],
1292 }));
1293 }
1294
1295 /** A page's open suggestions, with the blocks each covers now; ones whose target is gone become stale. */
1296 private async openSuggestions(workspace: Workspace, pageId: string): Promise<DocSuggestion[]> {
1297 const rows = (await this.db.prepare("SELECT * FROM suggestions WHERE page_id = ? AND status = 'open' ORDER BY created_at").bind(pageId).all<SuggestionRow>()).results;
1298 if (!rows.length) return [];
1299 let blocks: (string[] | null)[] = rows.map(() => []);
1300 try {
1301 blocks = await this.room(pageId).targets(rows.map((r) => JSON.parse(r.target) as DocEditTarget));
1302 } catch (error) {
1303 console.error("docs could not place suggestions", error);
1304 }
1305 const gone = rows.filter((_, i) => blocks[i] === null);
1306 if (gone.length) {
1307 await this.db.batch(gone.map((r) => this.db.prepare("UPDATE suggestions SET status = 'stale' WHERE id = ?").bind(r.id)));
1308 }
1309 const live = rows.map((r, i) => ({ r, b: blocks[i] })).filter((x) => x.b !== null);
1310 return this.toSuggestions(
1311 workspace,
1312 live.map((x) => x.r),
1313 live.map((x) => x.b!),
1314 );
1315 }
1316
1317 async suggestions(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocSuggestion[]>> {
1318 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1319 if (!found.ok) return found;
1320 return ok(await this.openSuggestions(found.value.workspace, a.page_id));
1321 }
1322
1323 async decideSuggestion(a: { workspace: string; suggestion_id: string; viewer: Viewer; decision: "accept" | "reject" }): Promise<Result<DocSuggestion>> {
1324 const row = await this.db.prepare("SELECT * FROM suggestions WHERE id = ?").bind(String(a.suggestion_id ?? "")).first<SuggestionRow>();
1325 if (!row) return fail("not_found", "No such suggestion.");
1326 const found = await this.pageFor(a.workspace, row.page_id, a.viewer, "edit");
1327 if (!found.ok) return found.error.code === "forbidden" ? fail("forbidden", "Only people who can edit the page can accept or reject a suggestion.") : found;
1328 const { workspace, page } = found.value;
1329 if (row.status !== "open") return fail("conflict", "That suggestion was already decided.");
1330 const me = this.userKey(a.viewer!);
1331 let status: DocSuggestion["status"] = a.decision === "accept" ? "accepted" : "rejected";
1332 if (a.decision === "accept") {
1333 const people = await this.profiles(workspace, [row.author, me]);
1334 const room = this.room(page.id);
1335 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1336 const result = await room.edit(JSON.parse(row.target) as DocEditTarget, row.after_markdown, {
1337 key: me,
1338 kind: "suggestion",
1339 note: `Suggested by @${people.get(row.author)!.name}, accepted by @${people.get(me)!.name}`,
1340 authors: [row.author, me],
1341 });
1342 if (!result.applied) status = "stale";
1343 }
1344 await this.db.prepare("UPDATE suggestions SET status = ?, decided_by = ?, decided_at = ? WHERE id = ?").bind(status, me, now(), row.id).run();
1345 const [after] = await this.toSuggestions(workspace, [{ ...row, status, decided_by: me, decided_at: now() }]);
1346 this.tell(page.id, { type: "suggestion.updated", suggestion: after! });
1347 if (status === "stale") return fail("conflict", "The part of the page this suggestion changes is gone, so it can't be applied.");
1348 return ok(after!);
1349 }
1350
1351 async acceptAll(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocSuggestion[]>> {
1352 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1353 if (!found.ok) return found;
1354 const rows = (await this.db.prepare("SELECT id FROM suggestions WHERE page_id = ? AND status = 'open' ORDER BY created_at").bind(a.page_id).all<{ id: string }>()).results;
1355 const out: DocSuggestion[] = [];
1356 for (const r of rows) {
1357 const decided = await this.decideSuggestion({ workspace: a.workspace, suggestion_id: r.id, viewer: a.viewer, decision: "accept" });
1358 if (decided.ok) out.push(decided.value);
1359 }
1360 return ok(out);
1361 }
1362
1363 // ── Comments ────────────────────────────────────────────────────────────
1364
1365 async thread(a: { workspace: string; page_id: string; viewer: Viewer; action: DocThreadAction }): Promise<Result<unknown>> {
1366 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "comment");
1367 if (!found.ok) return found;
1368 const { workspace, page, space } = found.value;
1369 const me = this.userKey(a.viewer!);
1370 const room = this.room(page.id);
1371 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1372 const result = (await room.thread(me, space.role!, a.action)) as ThreadResult;
1373 if (!result.ok) return fail(result.code, result.message);
1374 if (result.mentions?.length) this.defer(this.notifyMentioned(workspace, page, space.row, me, result.mentions, result.text ?? "", result.thread_id ?? null));
1375 return ok(result.value);
1376 }
1377
1378 /** People mentioned in a comment hear of it, if they can read the page. */
1379 private async notifyMentioned(workspace: Workspace, page: PageRow, space: SpaceRow, author: string, mentions: string[], text: string, threadId: string | null): Promise<void> {
1380 if (!this.env.NOTIFY) return;
1381 // Comment mentions name people by username (`user:<username>`).
1382 const me = author.startsWith("user:") ? (this.usernames.get(author.slice(5)) ?? "").toLowerCase() : "";
1383 const names = [...new Set(mentions.filter((k) => k.startsWith("user:")).map((k) => k.slice(5).toLowerCase()))].filter((n) => n && n !== me);
1384 if (!names.length) return;
1385 const [spaces, people] = await Promise.all([this.allSpaces(workspace), this.profiles(workspace, [author])]);
1386 const s = spaces.find((x) => x.row.id === space.id);
1387 if (!s) return;
1388 await this.nameUsers(s.members.filter((m) => m.principal.startsWith("user:")).map((m) => m.principal.slice(5)));
1389 const teams = this.needsTeams([s]) ? await this.teamsOf(workspace) : new Map<string, Set<string>>();
1390 const members = await this.members(workspace);
1391 const who = people.get(author)!;
1392 const href = `${this.ref(workspace.slug, space, page).path}${threadId ? `?thread=${encodeURIComponent(threadId)}` : ""}`;
1393 const notify = notifyClient(this.env.NOTIFY);
1394 await Promise.all(
1395 names.map(async (username) => {
1396 const member = members.get(username);
1397 if (!member) return;
1398 // Their id is not needed: access by username's teams and role is enough to decide.
1399 const person: Person = { user_id: `name:${username}`, owner: member.role === "owner", teams: teams.get(username) ?? new Set() };
1400 const listed = s.members.some((m) => m.principal.startsWith("user:") && this.usernames.get(m.principal.slice(5))?.toLowerCase() === username);
1401 if (!listed && !atLeast(roleOf(rulesOf({ ...s, role: null }), person), "view")) return;
1402 await notify
1403 .notify(
1404 { username },
1405 {
1406 id: `doc-comment:${page.id}:${threadId ?? ""}:${username}:${Date.now()}`,
1407 kind: "mention",
1408 workspace: workspace.slug,
1409 title: `${who.display_name} mentioned you on ${page.title || "Untitled"}`,
1410 body: text.slice(0, 140),
1411 href,
1412 actor: { kind: who.kind, id: who.id, name: who.display_name, avatar: who.avatar, avatar_seed: who.avatar_seed ?? null },
1413 created_at: now(),
1414 },
1415 )
1416 .catch(() => undefined);
1417 }),
1418 );
1419 }
1420
1421 private async resolveThreads(workspace: Workspace, threads: Awaited<ReturnType<PageRoom["threads"]>>): Promise<DocThread[]> {
1422 const people = await this.profiles(
1423 workspace,
1424 threads.flatMap((t) => t.comments.map((c) => c.author)),
1425 );
1426 return threads.map((t) => ({ ...t, comments: t.comments.map((c) => ({ ...c, author: people.get(c.author)! })) }));
1427 }
1428
1429 async threads(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocThread[]>> {
1430 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1431 if (!found.ok) return found;
1432 return ok(await this.resolveThreads(found.value.workspace, await this.room(a.page_id).threads()));
1433 }
1434
1435 // ── Agents ──────────────────────────────────────────────────────────────
1436
1437 /**
1438 * What an agent may read and do for `viewer`, space by space: the
1439 * viewer's own access, narrowed to what every person in the audience can
1440 * read. Never wider than the viewer.
1441 */
1442 private async agentSpaces(slug: string, agentId: string, viewer: Viewer, audience: DocAudience | null): Promise<Result<{ workspace: Workspace; agent: WorkspaceAgent; spaces: (Space & { can: DocAgentAbilities })[] }>> {
1443 const found = await this.viewerWorkspace(slug, viewer);
1444 if (!found.ok) return found;
1445 const workspace = found.value;
1446 const agent = (await this.agentsById([String(agentId ?? "")])).get(String(agentId ?? ""));
1447 if (!agent || agent.workspace_id !== workspace.id || agent.archived_at) return fail("not_found", "No such agent.");
1448 const spaces = await this.spacesFor(workspace, viewer!);
1449 let readable = (s: Space) => !!s.role;
1450 if (audience?.kind === "workspace") {
1451 readable = (s) => !!s.role && readableByWorkspace(rulesOf(s));
1452 } else if (audience?.kind === "people" && Array.isArray(audience.user_ids) && audience.user_ids.length) {
1453 const ids = [...new Set(audience.user_ids.map(String))].slice(0, 200);
1454 await this.nameUsers(ids);
1455 const [members, teams] = await Promise.all([this.members(workspace), this.needsTeams(spaces) ? this.teamsOf(workspace) : Promise.resolve(new Map<string, Set<string>>())]);
1456 const people: Person[] = ids.map((id) => {
1457 const username = this.usernames.get(id)?.toLowerCase() ?? "";
1458 const member = members.get(username);
1459 // Someone who is not a member reads nothing: a person with no teams who owns nothing.
1460 return { user_id: member ? id : `outside:${id}`, owner: member?.role === "owner", teams: teams.get(username) ?? new Set() };
1461 });
1462 readable = (s) => !!s.role && readableByAll(rulesOf(s), people);
1463 }
1464 return ok({
1465 workspace,
1466 agent,
1467 spaces: spaces.filter(readable).map((s) => ({ ...s, can: agentAbilities(s.role, s.row.agent_mode) })),
1468 });
1469 }
1470
1471 async spacesForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; audience: DocAudience | null }) {
1472 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
1473 if (!found.ok) return found;
1474 return ok(
1475 found.value.spaces.map((s) => ({
1476 id: s.row.id,
1477 slug: s.row.slug,
1478 name: s.row.name,
1479 description: s.row.description,
1480 kind: s.row.kind,
1481 agent_mode: s.row.agent_mode,
1482 projects: s.projects,
1483 can: s.can,
1484 })),
1485 );
1486 }
1487
1488 /** A page an agent may read for the viewer and audience, or not found. */
1489 private async agentPage(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience?: DocAudience | null }) {
1490 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience ?? null);
1491 if (!found.ok) return found;
1492 const page = await this.db
1493 .prepare("SELECT * FROM pages WHERE id = ? AND workspace_id = ? AND archived_at IS NULL")
1494 .bind(String(a.page_id ?? ""), found.value.workspace.id)
1495 .first<PageRow>();
1496 const space = page ? found.value.spaces.find((s) => s.row.id === page.space_id) : null;
1497 if (!page || !space) return fail("not_found", "No such page.");
1498 return ok({ ...found.value, page, space });
1499 }
1500
1501 async pageMarkdown(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience: DocAudience | null }): Promise<Result<DocAgentPage>> {
1502 const found = await this.agentPage(a);
1503 if (!found.ok) return found;
1504 const { workspace, page, space } = found.value;
1505 const room = this.room(page.id);
1506 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1507 const read = await room.read();
1508 return ok({
1509 page: { ...this.ref(workspace.slug, space.row, page), updated_at: page.updated_at },
1510 space: { id: space.row.id, slug: space.row.slug, name: space.row.name, agent_mode: space.row.agent_mode },
1511 markdown: read.markdown,
1512 blocks: read.blocks,
1513 can: space.can,
1514 });
1515 }
1516
1517 async searchForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; query: DocSearchQuery; audience: DocAudience | null }): Promise<Result<DocSearchHit[]>> {
1518 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
1519 if (!found.ok) return found;
1520 return ok(await this.searchIn(found.value.workspace, found.value.spaces, { ...(a.query ?? { query: "" }), limit: Math.min(Number(a.query?.limit) || 10, 20) }));
1521 }
1522
1523 private async fileSuggestion(
1524 workspace: Workspace,
1525 page: PageRow,
1526 space: SpaceRow,
1527 agent: WorkspaceAgent,
1528 viewer: User,
1529 edit: { target: DocEditTarget; markdown: string; note: string | null },
1530 ): Promise<Result<DocSuggestion>> {
1531 const room = this.room(page.id);
1532 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1533 const current = await room.target(edit.target);
1534 if (!current) return fail("not_found", "That part of the page isn't there. Read the page again and target what is there now.");
1535 const id = newId("sug");
1536 const row: SuggestionRow = {
1537 id,
1538 page_id: page.id,
1539 author: principalKey({ kind: "agent", id: agent.id }),
1540 asked_by: this.userKey(viewer),
1541 target: JSON.stringify(edit.target),
1542 before_markdown: current.markdown,
1543 after_markdown: edit.markdown,
1544 note: edit.note,
1545 status: "open",
1546 created_at: now(),
1547 decided_by: null,
1548 decided_at: null,
1549 };
1550 await this.db
1551 .prepare("INSERT INTO suggestions (id, page_id, author, asked_by, target, before_markdown, after_markdown, note, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)")
1552 .bind(row.id, row.page_id, row.author, row.asked_by, row.target, row.before_markdown, row.after_markdown, row.note, row.created_at)
1553 .run();
1554 const [suggestion] = await this.toSuggestions(workspace, [row], [current.block_ids]);
1555 this.tell(page.id, { type: "suggestion.created", suggestion: suggestion! });
1556 this.defer(room.announce(row.author, agent.display_name).catch(() => undefined));
1557 this.defer(this.notifyOwners(workspace, page, space, suggestion!));
1558 return ok(suggestion!);
1559 }
1560
1561 /** A page's owners (people) hear of a suggestion waiting for them. */
1562 private async notifyOwners(workspace: Workspace, page: PageRow, space: SpaceRow, suggestion: DocSuggestion): Promise<void> {
1563 if (!this.env.NOTIFY) return;
1564 const owners = (await this.db.prepare("SELECT principal FROM page_owners WHERE page_id = ?").bind(page.id).all<{ principal: string }>()).results
1565 .map((o) => o.principal)
1566 .filter((k) => k.startsWith("user:"))
1567 .map((k) => k.slice(5));
1568 if (!owners.length) return;
1569 const notify = notifyClient(this.env.NOTIFY);
1570 const href = this.ref(workspace.slug, space, page).path;
1571 await Promise.all(
1572 owners.map((id) =>
1573 notify
1574 .notify(
1575 { user_id: id },
1576 {
1577 id: `doc-suggestion:${suggestion.id}:${id}`,
1578 kind: "inbox",
1579 workspace: workspace.slug,
1580 title: `${suggestion.author.display_name} suggested a change to ${page.title || "Untitled"}`,
1581 body: suggestion.note ?? excerpt(suggestion.after_markdown, 140),
1582 href,
1583 actor: { kind: "agent", id: suggestion.author.id, name: suggestion.author.display_name, avatar: suggestion.author.avatar, avatar_seed: suggestion.author.avatar_seed ?? null },
1584 created_at: suggestion.created_at,
1585 },
1586 )
1587 .catch(() => undefined),
1588 ),
1589 );
1590 }
1591
1592 private cleanEdit(edit: unknown): Result<{ target: DocEditTarget; markdown: string; note: string | null }> {
1593 const e = (edit ?? {}) as { target?: unknown; markdown?: unknown; note?: unknown };
1594 const target = cleanTarget(e.target);
1595 if (!target) return fail("invalid", "Say what to change: append, document, a section by its heading, or blocks by id.");
1596 const markdown = String(e.markdown ?? "");
1597 if (markdown.length > MAX_MARKDOWN) return fail("invalid", "That edit is too long.");
1598 if (target.kind === "append" && !markdown.trim()) return fail("invalid", "Nothing to add.");
1599 return ok({ target, markdown, note: e.note ? String(e.note).trim().slice(0, MAX_NOTE) || null : null });
1600 }
1601
1602 async suggestEdit(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; edit: unknown }): Promise<Result<DocSuggestion>> {
1603 const edit = this.cleanEdit(a.edit);
1604 if (!edit.ok) return edit;
1605 const found = await this.agentPage(a);
1606 if (!found.ok) return found;
1607 const { workspace, agent, page, space } = found.value;
1608 if (!space.can.suggest) return fail("forbidden", `${a.viewer!.username} can only read this page, so no change can be suggested for them.`);
1609 return this.fileSuggestion(workspace, page, space.row, agent, a.viewer!, edit.value);
1610 }
1611
1612 async applyEdit(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; edit: unknown }): Promise<Result<DocAgentEditResult>> {
1613 const edit = this.cleanEdit(a.edit);
1614 if (!edit.ok) return edit;
1615 const found = await this.agentPage(a);
1616 if (!found.ok) return found;
1617 const { workspace, agent, page, space } = found.value;
1618 const ref = this.ref(workspace.slug, space.row, page);
1619 if (!space.can.edit) {
1620 if (!space.can.suggest) return fail("forbidden", `${a.viewer!.username} can only read this page, so it can't be changed for them.`);
1621 const suggestion = await this.fileSuggestion(workspace, page, space.row, agent, a.viewer!, edit.value);
1622 return suggestion.ok ? ok({ mode: "suggested", suggestion: suggestion.value, page: ref }) : suggestion;
1623 }
1624 const room = this.room(page.id);
1625 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1626 const result = await room.edit(edit.value.target, edit.value.markdown, {
1627 key: principalKey({ kind: "agent", id: agent.id }),
1628 kind: "agent",
1629 note: edit.value.note ? `@${agent.handle} for @${a.viewer!.username}: ${edit.value.note}` : `@${agent.handle} for @${a.viewer!.username}`,
1630 authors: [principalKey({ kind: "agent", id: agent.id })],
1631 });
1632 if (!result.applied) return fail("not_found", "That part of the page isn't there. Read the page again and target what is there now.");
1633 this.defer(room.announce(principalKey({ kind: "agent", id: agent.id }), agent.display_name).catch(() => undefined));
1634 return ok({ mode: "applied", version_id: result.version_id, page: ref });
1635 }
1636
1637 async createPageAsAgent(a: {
1638 workspace: string;
1639 agent_id: string;
1640 viewer: Viewer;
1641 input: { space_id?: string | null; parent_id?: string | null; title: string; icon?: string | null; markdown: string; source?: { title: string; href: string } | null };
1642 }): Promise<Result<DocPageRef>> {
1643 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, null);
1644 if (!found.ok) return found;
1645 const { workspace, agent, spaces } = found.value;
1646 const input = a.input ?? ({} as typeof a.input);
1647 const space = input.space_id ? spaces.find((s) => s.row.id === input.space_id) : spaces.find((s) => s.row.is_default);
1648 if (!space) return fail("not_found", "No such space.");
1649 if (!atLeast(space.role, "edit")) return fail("forbidden", `${a.viewer!.username} can't add pages to ${space.row.name}.`);
1650 const title = cleanTitle(input.title);
1651 if (!title) return fail("invalid", "Give the page a title.");
1652 let markdown = String(input.markdown ?? "").slice(0, MAX_MARKDOWN);
1653 const source = input.source && typeof input.source.href === "string" && input.source.href.startsWith("/") ? input.source : null;
1654 if (source) markdown = `> [!NOTE]\n> Written up from [${String(source.title || "a conversation").replace(/[[\]]/g, "")}](${source.href}).\n\n${markdown}`;
1655 const parent = input.parent_id
1656 ? await this.db.prepare("SELECT id FROM pages WHERE id = ? AND space_id = ? AND archived_at IS NULL").bind(input.parent_id, space.row.id).first<{ id: string }>()
1657 : null;
1658 const agentKey = principalKey({ kind: "agent", id: agent.id });
1659 const row = await this.insertPage(workspace, space.row, agentKey, {
1660 parent_id: parent?.id ?? null,
1661 title,
1662 icon: cleanIcon(input.icon),
1663 markdown,
1664 owners: [this.userKey(a.viewer!)],
1665 });
1666 return ok(this.ref(workspace.slug, space.row, row));
1667 }
1668
1669 async threadsForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience: DocAudience | null }): Promise<Result<DocThread[]>> {
1670 const found = await this.agentPage(a);
1671 if (!found.ok) return found;
1672 return ok(await this.resolveThreads(found.value.workspace, await this.room(found.value.page.id).threads()));
1673 }
1674
1675 // ── Sockets and files ───────────────────────────────────────────────────
1676
1677 private viewerFrom(request: Request): Viewer {
1678 try {
1679 return JSON.parse(request.headers.get(DOCS_VIEWER_HEADER) ?? "null") as Viewer;
1680 } catch {
1681 return null;
1682 }
1683 }
1684
1685 /**
1686 * `GET /live?workspace=<slug>&page=<id>`, upgraded to a WebSocket. The
1687 * viewer comes in DOCS_VIEWER_HEADER, set by the site after checking the
1688 * session; trusted only because this Worker is reachable through service
1689 * bindings alone. Checked like any read, then handed to the page's room
1690 * with the viewer's role, which the room enforces.
1691 */
1692 async live(request: Request): Promise<Response> {
1693 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("Expected a WebSocket upgrade\n", { status: 426 });
1694 const viewer = this.viewerFrom(request);
1695 if (!viewer?.id) return new Response("Sign in to use Docs\n", { status: 401 });
1696 const url = new URL(request.url);
1697 const slug = (url.searchParams.get("workspace") ?? "").toLowerCase();
1698 const found = await this.pageFor(slug, url.searchParams.get("page") ?? "", viewer, "view");
1699 if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 });
1700 const { workspace, page, space } = found.value;
1701 if (page.archived_at) return new Response("That page is in the trash\n", { status: 410 });
1702 const room = this.room(page.id);
1703 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1704 const key = this.userKey(viewer);
1705 const who: RoomMember = { page_id: page.id, workspace_slug: workspace.slug, key, member: (await this.profiles(workspace, [key])).get(key)!, role: space.role! };
1706 const headers = new Headers(request.headers);
1707 headers.delete(DOCS_VIEWER_HEADER);
1708 headers.set(ROOM_MEMBER_HEADER, JSON.stringify(who));
1709 return room.fetch(new Request(request.url, { method: "GET", headers }));
1710 }
1711
1712 /** `PUT /files?workspace=&page=&name=`: a file for a page, from someone who can edit it. */
1713 async upload(request: Request): Promise<Response> {
1714 const viewer = this.viewerFrom(request);
1715 const url = new URL(request.url);
1716 const found = await this.pageFor((url.searchParams.get("workspace") ?? "").toLowerCase(), url.searchParams.get("page") ?? "", viewer, "edit");
1717 if (!found.ok) return Response.json(found);
1718 const bytes = Number(request.headers.get("content-length") ?? "0");
1719 if (!bytes || bytes > DOC_MAX_FILE_BYTES) return Response.json(fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`));
1720 const name = safeName(url.searchParams.get("name") ?? "file");
1721 const contentType = servedType(request.headers.get("content-type") ?? "");
1722 const random = crypto.getRandomValues(new Uint8Array(32));
1723 const key = [...random].map((b) => b.toString(16).padStart(2, "0")).join("");
1724 const id = newId("fil");
1725 await r2FileStore(this.env.FILES).put(`docs/${key}`, request.body ?? new Uint8Array(), contentType);
1726 await this.db
1727 .prepare("INSERT INTO files (id, workspace_id, page_id, key, name, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
1728 .bind(id, found.value.workspace.id, found.value.page.id, key, name, contentType, bytes, this.userKey(viewer!), now())
1729 .run();
1730 const file: DocFile = { id, url: `/docs-files/${key}`, name, content_type: contentType, bytes };
1731 return Response.json(ok(file));
1732 }
1733
1734 /**
1735 * `GET /files/<key>`: a page's file, for the usercontent origin. The key
1736 * is 256 random bits, so knowing it is the permission, as with any
1737 * shared link; it is never on the site's own origin.
1738 */
1739 async file(key: string): Promise<Response> {
1740 const row = await this.db.prepare("SELECT name, content_type FROM files WHERE key = ?").bind(key).first<{ name: string; content_type: string }>();
1741 if (!row) return new Response("Not found\n", { status: 404 });
1742 const stored = await r2FileStore(this.env.FILES).get(`docs/${key}`);
1743 if (!stored) return new Response("Not found\n", { status: 404 });
1744 const inline = row.content_type !== "application/octet-stream";
1745 return new Response(stored.body, {
1746 headers: {
1747 "content-type": row.content_type,
1748 "content-length": String(stored.bytes),
1749 etag: stored.etag,
1750 "content-disposition": `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(row.name)}`,
1751 "cache-control": "private, max-age=31536000, immutable",
1752 },
1753 });
1754 }
1755}
1756
1757/** One RPC method's answer. */
1758async function answer(service: Docs, method: string, args: any): Promise<Response> {
1759 switch (method) {
1760 case "sidebar":
1761 return Response.json(await service.sidebar(args));
1762 case "home":
1763 return Response.json(await service.home(args));
1764 case "space":
1765 return Response.json(await service.space(args));
1766 case "create_space":
1767 return Response.json(await service.createSpace(args));
1768 case "update_space":
1769 return Response.json(await service.updateSpace(args));
1770 case "set_space_member":
1771 return Response.json(await service.setSpaceMember(args));
1772 case "page":
1773 return Response.json(await service.page(args));
1774 case "create_page":
1775 return Response.json(await service.createPage(args));
1776 case "update_page":
1777 return Response.json(await service.updatePage(args));
1778 case "move_page":
1779 return Response.json(await service.movePage(args));
1780 case "duplicate_page":
1781 return Response.json(await service.duplicatePage(args));
1782 case "archive_page":
1783 return Response.json(await service.archivePage(args));
1784 case "restore_page":
1785 return Response.json(await service.restorePage(args));
1786 case "delete_page":
1787 return Response.json(await service.deletePage(args));
1788 case "trash":
1789 return Response.json(await service.trash(args));
1790 case "favorite":
1791 return Response.json(await service.favorite(args));
1792 case "search":
1793 return Response.json(await service.search(args));
1794 case "versions":
1795 return Response.json(await service.versions(args));
1796 case "version":
1797 return Response.json(await service.version(args));
1798 case "restore_version":
1799 return Response.json(await service.restoreVersion(args));
1800 case "templates":
1801 return Response.json(await service.templates(args));
1802 case "save_template":
1803 return Response.json(await service.saveTemplate(args));
1804 case "delete_template":
1805 return Response.json(await service.deleteTemplate(args));
1806 case "export_page":
1807 return Response.json(await service.exportPage(args));
1808 case "export_space":
1809 return Response.json(await service.exportSpace(args));
1810 case "suggestions":
1811 return Response.json(await service.suggestions(args));
1812 case "decide_suggestion":
1813 return Response.json(await service.decideSuggestion(args));
1814 case "accept_all":
1815 return Response.json(await service.acceptAll(args));
1816 case "thread":
1817 return Response.json(await service.thread(args));
1818 case "threads":
1819 return Response.json(await service.threads(args));
1820 case "spaces_for_agent":
1821 return Response.json(await service.spacesForAgent(args));
1822 case "page_markdown":
1823 return Response.json(await service.pageMarkdown(args));
1824 case "search_for_agent":
1825 return Response.json(await service.searchForAgent(args));
1826 case "suggest_edit":
1827 return Response.json(await service.suggestEdit(args));
1828 case "apply_edit":
1829 return Response.json(await service.applyEdit(args));
1830 case "create_page_as_agent":
1831 return Response.json(await service.createPageAsAgent(args));
1832 case "threads_for_agent":
1833 return Response.json(await service.threadsForAgent(args));
1834 default:
1835 return new Response("Unknown method\n", { status: 404 });
1836 }
1837}
1838
1839export default {
1840 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
1841 const url = new URL(request.url);
1842 const defer = (work: Promise<unknown>) => ctx.waitUntil(work);
1843 if (request.method === "GET" && url.pathname === "/live") return new Docs(env, defer).live(request);
1844 if (request.method === "PUT" && url.pathname === "/files") return new Docs(env, defer).upload(request);
1845 const file = /^\/files\/([0-9a-f]{64})$/.exec(url.pathname);
1846 if ((request.method === "GET" || request.method === "HEAD") && file) return new Docs(env, defer).file(file[1]!);
1847 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
1848 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
1849 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
1850 const opened = openD1(env.DB, request);
1851 const service = new Docs(Object.create(env, { DB: { value: opened.db } }) as Env, defer);
1852 const args = (await request.json().catch(() => ({}))) as any;
1853 try {
1854 return opened.finish(await answer(service, match[1]!, args));
1855 } catch (error) {
1856 console.error("docs:", match[1], error);
1857 return opened.finish(Response.json(fail("conflict", "Docs couldn't do that just now. Try again.")));
1858 }
1859 },
1860} satisfies ExportedHandler<Env>;