Skip to content
2,751 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 reposClient,
28 workspaceAgentsClient,
29 type DocAgentAbilities,
30 type DocCitation,
31 type DocDescribes,
32 type DocRepoPage,
33 type DocRepoSpace,
34 type DocStaleChange,
35 type DocStalePage,
36 type DocStaleness,
37 type G1tEvent,
38 type Repo,
39 type DocAgentEditResult,
40 type DocAgentMode,
41 type DocAgentPage,
42 type DocAudience,
43 type DocEditTarget,
44 type DocFile,
45 type DocMove,
46 type DocPage,
47 type DocPageChange,
48 type DocPageDetail,
49 type DocPageRef,
50 type DocPassage,
51 type DocRole,
52 type DocSearchHit,
53 type DocSearchQuery,
54 type DocSpace,
55 type DocSpaceChange,
56 type DocSpaceKind,
57 type DocSpaceMember,
58 type DocSuggestion,
59 type DocTemplate,
60 type DocThread,
61 type DocThreadAction,
62 type DocTreeNode,
63 type DocVersion,
64 type DocVersionDetail,
65 type DocsHome,
66 type DocsLiveEvent,
67 type DocsSidebar,
68 type Member,
69 type MemberProfile,
70 type NewDocPage,
71 type NewDocSpace,
72 type Principal,
73 type Result,
74 type ServiceBinding,
75 type User,
76 type Viewer,
77 type Workspace,
78 type WorkspaceAgent,
79} from "@g1t/contracts";
80
81import { RANK, agentAbilities, atLeast, isRole, leavesNoManager, memberKey, readableByAll, readableByWorkspace, roleOf, type Person, type SpaceRules } from "./access.ts";
82import { diffLines } from "./diff.ts";
83import { cleanDescribes } from "./citations.ts";
84import { publishDocEvent } from "./events.ts";
85import { fileStore, safeName, servedType, type FileStoreEnv } from "./files.ts";
86import { repoFileId } from "./chunks.ts";
87import { adapters, ensureIndexed, forgetDocs, indexPage, indexRepoFiles, runBackfill, startBackfill, type DocsJob } from "./indexer.ts";
88import { excerpt, searchText } from "./markdown.ts";
89import { QueryCache, fuseRanks, pickPassages, queryKey, recallLimit, requiredSpaces, vectorQueryPlan, MEANING_FLOOR, WORDS_SCORE, type Candidate } from "./recall.ts";
90import { ROOM_MEMBER_HEADER, type Origin, type PageRoom, type RoomMember } from "./room.ts";
91import { indexRepoSpace, reindexRepo, type RepoSpaceRow } from "./repo-spaces.ts";
92import { ftsAnyQuery, ftsQuery, inProject, projectRef, searchSpaces } from "./search.ts";
93import { freeSlug, pageSlug, validSpaceSlug } from "./slugs.ts";
94import { BUILTIN_TEMPLATES, builtinTemplate } from "./templates.ts";
95import type { ThreadResult } from "./threads.ts";
96import { onEvent } from "./staleness.ts";
97import { descendants, exportPaths, lastPosition, placeBefore, wouldCycle, ancestors } from "./tree.ts";
98
99export { PageRoom } from "./room.ts";
100
101type Env = FileStoreEnv & {
102 DB: D1Database;
103 IDENTITY: ServiceBinding;
104 AGENTS: ServiceBinding;
105 NOTIFY?: ServiceBinding;
106 /** Repositories: who may read one, what a change touched, a project's docs (src/staleness.ts, src/repo-spaces.ts). */
107 REPOS?: ServiceBinding;
108 /** Pull requests: what a merged one changed. */
109 WORK?: ServiceBinding;
110 /** The bus: `doc.page.*` events (src/events.ts). */
111 EVENTS?: ServiceBinding;
112 PAGES: DurableObjectNamespace<PageRoom>;
113 /** Workers AI: embeds passages and queries for the semantic index (src/vectors.ts). Without it, words only. */
114 AI?: Ai;
115 /** The semantic index, Vectorize `g1t-docs` (src/vectors.ts, src/indexer.ts). */
116 VECTORS?: Vectorize;
117 /** The docs service's own events queue, also carrying its backfill jobs (`docs.index`, src/indexer.ts). */
118 JOBS?: Queue<DocsJob>;
119};
120
121/** Queries' embeddings, a minute per isolate (src/recall.ts). */
122const queryVectors = new QueryCache();
123
124type SpaceRow = {
125 id: string;
126 workspace_id: string;
127 slug: string;
128 name: string;
129 description: string | null;
130 icon: string | null;
131 kind: DocSpaceKind;
132 team: string | null;
133 default_role: DocRole | null;
134 agent_mode: DocAgentMode;
135 is_default: number;
136 created_by: string;
137 created_at: string;
138 archived_at: string | null;
139};
140
141type PageRow = {
142 id: string;
143 workspace_id: string;
144 space_id: string;
145 parent_id: string | null;
146 position: number;
147 title: string;
148 icon: string | null;
149 cover: string | null;
150 markdown: string;
151 created_by: string;
152 created_at: string;
153 updated_by: string | null;
154 updated_at: string;
155 archived_at: string | null;
156 archived_by: string | null;
157};
158
159type SuggestionRow = {
160 id: string;
161 page_id: string;
162 author: string;
163 asked_by: string | null;
164 target: string;
165 before_markdown: string;
166 after_markdown: string;
167 note: string | null;
168 status: DocSuggestion["status"];
169 created_at: string;
170 decided_by: string | null;
171 decided_at: string | null;
172 marks_current?: number;
173};
174
175type ChangeRow = {
176 page_id: string;
177 repo: string;
178 repo_id: string;
179 commit_sha: string;
180 pull_number: number | null;
181 pull_title: string | null;
182 paths: string;
183 detected_at: string;
184 cleared_at: string | null;
185 cleared_by: string | null;
186};
187
188type VersionRow = { id: string; page_id: string; created_at: string; kind: DocVersion["kind"]; authors: string; note: string | null; markdown: string; state: ArrayBuffer | null };
189
190/** A passage as recall and search read it back (`passages`): its page's or file's title, and the page's space now. */
191type PassageRow = {
192 id: string;
193 page_id: string | null;
194 repo_file_id: string | null;
195 path: string | null;
196 heading: string | null;
197 text: string;
198 updated_at: string;
199 space_id: string;
200 title: string | null;
201 icon: string | null;
202 page_updated_at: string | null;
203};
204
205/** A space, with who is in it and the viewer's role. */
206type Space = { row: SpaceRow; members: { principal: string; role: DocRole }[]; projects: string[]; role: DocRole | null };
207
208/** The page columns lists read: everything but the Markdown. */
209const 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";
210
211const MAX_TITLE = 200;
212const MAX_MARKDOWN = 512 * 1024;
213const MAX_NOTE = 500;
214
215const now = () => new Date().toISOString();
216
217function isMember(viewer: Viewer, workspace: string): boolean {
218 return !!viewer?.workspaces?.some((m) => m.slug === String(workspace ?? "").toLowerCase());
219}
220
221function rulesOf(space: Space): SpaceRules {
222 return { kind: space.row.kind, team: space.row.team, default_role: space.row.default_role, members: space.members };
223}
224
225function cleanTitle(title: unknown): string {
226 return String(title ?? "")
227 .replace(/\s+/g, " ")
228 .trim()
229 .slice(0, MAX_TITLE);
230}
231
232/** One emoji (or a few characters), or null. */
233function cleanIcon(icon: unknown): string | null {
234 const s = String(icon ?? "").trim();
235 if (!s) return null;
236 return [...s].slice(0, 4).join("");
237}
238
239function cleanCover(cover: unknown): string | null {
240 const s = String(cover ?? "").trim();
241 if (!s) return null;
242 if (/^gradient:\d{1,2}$/.test(s)) return s;
243 if (/^https:\/\/[^\s"'<>]{1,500}$/.test(s)) return s;
244 return null;
245}
246
247function cleanProjects(list: unknown): string[] {
248 if (!Array.isArray(list)) return [];
249 return [...new Set(list.map((p) => projectRef(String(p))).filter((p): p is string => !!p))].slice(0, 20);
250}
251
252function cleanTarget(target: unknown): DocEditTarget | null {
253 const t = target as DocEditTarget | null;
254 if (!t || typeof t !== "object") return null;
255 switch (t.kind) {
256 case "append":
257 case "document":
258 return { kind: t.kind };
259 case "section":
260 return typeof t.heading === "string" && t.heading.trim() ? { kind: "section", heading: t.heading.trim().slice(0, 300) } : null;
261 case "blocks":
262 return typeof t.from_block === "string" && typeof t.to_block === "string" ? { kind: "blocks", from_block: t.from_block, to_block: t.to_block } : null;
263 default:
264 return null;
265 }
266}
267
268class Docs {
269 private readonly workspaces = new Map<string, Promise<Workspace | null>>();
270 private readonly people = new Map<string, Promise<Map<string, Member>>>();
271 private readonly teams = new Map<string, Promise<Map<string, Set<string>>>>();
272 private readonly usernames = new Map<string, string>();
273 private readonly agents = new Map<string, WorkspaceAgent | null>();
274
275 constructor(
276 private readonly env: Env,
277 private readonly defer: (work: Promise<unknown>) => void = () => {},
278 ) {}
279
280 private get db() {
281 return this.env.DB;
282 }
283
284 // ── Who and where ───────────────────────────────────────────────────────
285
286 private workspace(slug: string): Promise<Workspace | null> {
287 const key = String(slug ?? "").toLowerCase();
288 let found = this.workspaces.get(key);
289 if (!found) {
290 found = identityClient(this.env.IDENTITY).getWorkspace(key).catch(() => null);
291 this.workspaces.set(key, found);
292 }
293 return found;
294 }
295
296 /** The workspace acting for itself: how this service asks identity about its members. */
297 private actor(workspace: Workspace): User {
298 return { id: workspace.id, username: workspace.slug, kind: "workspace", verified: true, workspaces: [{ slug: workspace.slug, role: "member" }] };
299 }
300
301 /** The workspace's people by username. */
302 private members(workspace: Workspace): Promise<Map<string, Member>> {
303 let found = this.people.get(workspace.id);
304 if (!found) {
305 found = identityClient(this.env.IDENTITY)
306 .listMembers(workspace.slug, this.actor(workspace))
307 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), m]) : []))
308 .catch(() => new Map<string, Member>());
309 this.people.set(workspace.id, found);
310 }
311 return found;
312 }
313
314 /** Each member's teams (slugs, lowercased), by username. */
315 private teamsOf(workspace: Workspace): Promise<Map<string, Set<string>>> {
316 let found = this.teams.get(workspace.id);
317 if (!found) {
318 found = identityClient(this.env.IDENTITY)
319 .teamMemberships(this.actor(workspace), workspace.slug)
320 .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), new Set(m.teams.map((t) => t.slug.toLowerCase()))]) : []))
321 .catch(() => new Map<string, Set<string>>());
322 this.teams.set(workspace.id, found);
323 }
324 return found;
325 }
326
327 private async nameUsers(ids: string[]): Promise<void> {
328 const unnamed = [...new Set(ids)].filter((id) => !this.usernames.has(id));
329 if (!unnamed.length) return;
330 const named = await identityClient(this.env.IDENTITY)
331 .usernames(unnamed)
332 .catch(() => ({}) as Record<string, string>);
333 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
334 }
335
336 private async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> {
337 const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id));
338 if (wanted.length) {
339 let found: WorkspaceAgent[] = [];
340 try {
341 found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted);
342 } catch (error) {
343 console.error("docs could not resolve agents", error);
344 }
345 for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null);
346 }
347 return new Map(ids.map((id) => [id, this.agents.get(id) ?? null]));
348 }
349
350 /** How member keys show. Teams show by slug. */
351 private async profiles(workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
352 const principals = [...new Set(keys)].map((k) => parsePrincipalKey(k)).filter((p): p is Principal => !!p);
353 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
354 const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
355 const [, people, agents] = await Promise.all([this.nameUsers(userIds), userIds.length ? this.members(workspace) : new Map<string, Member>(), this.agentsById(agentIds)]);
356 const out = new Map<string, MemberProfile>();
357 for (const p of principals) {
358 if (p.kind === "user") {
359 const username = this.usernames.get(p.id) ?? null;
360 const person = username ? people.get(username.toLowerCase()) : undefined;
361 out.set(principalKey(p), {
362 ...p,
363 name: username ?? "ghost",
364 display_name: person?.name || username || "Former member",
365 avatar: person?.avatar ?? null,
366 role: null,
367 title: null,
368 avatar_seed: null,
369 });
370 } else {
371 const agent = agents.get(p.id) ?? null;
372 out.set(principalKey(p), {
373 ...p,
374 name: agent?.handle ?? p.id,
375 display_name: agent?.display_name ?? "Former agent",
376 avatar: agent?.avatar ?? null,
377 role: agent?.role ?? null,
378 title: agent?.title || null,
379 avatar_seed: agent?.avatar_seed ?? null,
380 });
381 }
382 }
383 // Anything else (system, ghost keys): a plain name.
384 for (const key of keys) {
385 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 });
386 }
387 return out;
388 }
389
390 private async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
391 if (!viewer?.id) return fail("unauthenticated", "Sign in to use Docs.");
392 if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its Docs.");
393 const workspace = await this.workspace(slug);
394 return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
395 }
396
397 /** A person as access sees them. */
398 private async personOf(workspace: Workspace, user: Pick<User, "id" | "username">, owner: boolean, needTeams: boolean): Promise<Person> {
399 const teams = needTeams ? ((await this.teamsOf(workspace)).get(user.username.toLowerCase()) ?? new Set<string>()) : new Set<string>();
400 return { user_id: user.id, owner, teams };
401 }
402
403 private viewerOwner(viewer: User, slug: string): boolean {
404 return !!viewer.workspaces?.some((m) => m.slug === slug.toLowerCase() && m.role === "owner");
405 }
406
407 /** Every space in the workspace (archived ones too when asked), with members and projects. */
408 private async allSpaces(workspace: Workspace, archived = false): Promise<Omit<Space, "role">[]> {
409 const [spaces, members, projects] = await Promise.all([
410 this.db
411 .prepare(`SELECT * FROM spaces WHERE workspace_id = ? ${archived ? "" : "AND archived_at IS NULL"} ORDER BY is_default DESC, name COLLATE NOCASE`)
412 .bind(workspace.id)
413 .all<SpaceRow>(),
414 this.db
415 .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 = ?")
416 .bind(workspace.id)
417 .all<{ space_id: string; principal: string; role: DocRole }>(),
418 this.db
419 .prepare("SELECT p.space_id, p.repo FROM space_projects p JOIN spaces s ON s.id = p.space_id WHERE s.workspace_id = ?")
420 .bind(workspace.id)
421 .all<{ space_id: string; repo: string }>(),
422 ]);
423 return spaces.results.map((row) => ({
424 row,
425 members: members.results.filter((m) => m.space_id === row.id).map((m) => ({ principal: m.principal, role: m.role })),
426 projects: projects.results.filter((p) => p.space_id === row.id).map((p) => p.repo),
427 }));
428 }
429
430 /** Whether any space's access depends on teams. */
431 private needsTeams(spaces: Omit<Space, "role">[]): boolean {
432 return spaces.some((s) => s.row.kind === "team" || s.members.some((m) => m.principal.startsWith("team:")));
433 }
434
435 /** The spaces, each with the viewer's role (null: they can't read it). */
436 private async spacesFor(workspace: Workspace, viewer: User, archived = false): Promise<Space[]> {
437 const spaces = await this.allSpaces(workspace, archived);
438 const person = await this.personOf(workspace, viewer, this.viewerOwner(viewer, workspace.slug), this.needsTeams(spaces));
439 return spaces.map((s) => ({ ...s, role: roleOf(rulesOf({ ...s, role: null }), person) }));
440 }
441
442 /** Makes the workspace's General space, once. */
443 private async ensureDefault(workspace: Workspace, viewer: User): Promise<boolean> {
444 const found = await this.db.prepare("SELECT id FROM spaces WHERE workspace_id = ? AND is_default = 1").bind(workspace.id).first<{ id: string }>();
445 if (found) return false;
446 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));
447 const id = newId("spc");
448 await this.db
449 .prepare(
450 "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, ?, ?)",
451 )
452 .bind(id, workspace.id, freeSlug("general", taken), principalKey({ kind: "user", id: viewer.id }), now())
453 .run();
454 return true;
455 }
456
457 private toSpace(space: Space, pageCount = 0): DocSpace {
458 const created = parsePrincipalKey(space.row.created_by) ?? { kind: "user" as const, id: space.row.created_by };
459 return {
460 id: space.row.id,
461 workspace_id: space.row.workspace_id,
462 slug: space.row.slug,
463 name: space.row.name,
464 description: space.row.description,
465 icon: space.row.icon,
466 kind: space.row.kind,
467 team: space.row.team,
468 default_role: space.row.kind === "private" ? null : space.row.default_role,
469 agent_mode: space.row.agent_mode,
470 is_default: !!space.row.is_default,
471 projects: space.projects,
472 created_by: created,
473 created_at: space.row.created_at,
474 archived_at: space.row.archived_at,
475 viewer_role: space.role ?? "view",
476 page_count: pageCount,
477 };
478 }
479
480 private ref(slug: string, space: Pick<SpaceRow, "id" | "slug">, row: Pick<PageRow, "id" | "title" | "icon">): DocPageRef {
481 const s = pageSlug(row.title, row.id);
482 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}` };
483 }
484
485 /** Pages as the site shows them, with owners, projects and people resolved. */
486 private async toPages(workspace: Workspace, spaces: Map<string, SpaceRow>, rows: PageRow[]): Promise<DocPage[]> {
487 if (!rows.length) return [];
488 const ids = rows.map((r) => r.id);
489 const marks = ids.map(() => "?").join(",");
490 const [owners, projects, kids, stale] = await Promise.all([
491 this.db.prepare(`SELECT page_id, principal FROM page_owners WHERE page_id IN (${marks})`).bind(...ids).all<{ page_id: string; principal: string }>(),
492 this.db.prepare(`SELECT page_id, repo FROM page_projects WHERE page_id IN (${marks})`).bind(...ids).all<{ page_id: string; repo: string }>(),
493 this.db
494 .prepare(`SELECT DISTINCT parent_id FROM pages WHERE parent_id IN (${marks}) AND archived_at IS NULL`)
495 .bind(...ids)
496 .all<{ parent_id: string }>(),
497 this.staleIds(ids),
498 ]);
499 const keys = [...rows.flatMap((r) => [r.created_by, r.updated_by ?? r.created_by]), ...owners.results.map((o) => o.principal)];
500 const people = await this.profiles(workspace, keys);
501 const parents = new Set(kids.results.map((k) => k.parent_id));
502 return rows.map((row) => {
503 const space = spaces.get(row.space_id)!;
504 return {
505 ...this.ref(workspace.slug, space, row),
506 parent_id: row.parent_id,
507 position: row.position,
508 cover: row.cover,
509 created_by: people.get(row.created_by)!,
510 created_at: row.created_at,
511 updated_by: row.updated_by ? (people.get(row.updated_by) ?? null) : null,
512 updated_at: row.updated_at,
513 archived_at: row.archived_at,
514 has_children: parents.has(row.id),
515 projects: projects.results.filter((p) => p.page_id === row.id).map((p) => p.repo),
516 owners: owners.results.filter((o) => o.page_id === row.id).map((o) => people.get(o.principal)!),
517 excerpt: excerpt(row.markdown ?? ""),
518 stale: stale.has(row.id),
519 };
520 });
521 }
522
523 /** Of these pages, those possibly out of date. */
524 private async staleIds(ids: string[]): Promise<Set<string>> {
525 if (!ids.length) return new Set();
526 const found = new Set<string>();
527 for (let i = 0; i < ids.length; i += 90) {
528 const part = ids.slice(i, i + 90);
529 const rows = await this.db
530 .prepare(`SELECT DISTINCT page_id FROM page_changes WHERE cleared_at IS NULL AND page_id IN (${part.map(() => "?").join(",")})`)
531 .bind(...part)
532 .all<{ page_id: string }>();
533 for (const r of rows.results) found.add(r.page_id);
534 }
535 return found;
536 }
537
538 /** A page and its space, with the viewer's role; not found when they can't read it. */
539 private async pageFor(slug: string, pageId: string, viewer: Viewer, need: DocRole): Promise<Result<{ workspace: Workspace; page: PageRow; space: Space; spaces: Space[] }>> {
540 const found = await this.viewerWorkspace(slug, viewer);
541 if (!found.ok) return found;
542 const workspace = found.value;
543 const page = await this.db.prepare("SELECT * FROM pages WHERE id = ? AND workspace_id = ?").bind(String(pageId ?? ""), workspace.id).first<PageRow>();
544 if (!page) return fail("not_found", "No such page.");
545 const spaces = await this.spacesFor(workspace, viewer!, true);
546 const space = spaces.find((s) => s.row.id === page.space_id);
547 if (!space || !space.role) return fail("not_found", "No such page.");
548 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.");
549 return ok({ workspace, page, space, spaces });
550 }
551
552 private room(pageId: string) {
553 return this.env.PAGES.get(this.env.PAGES.idFromName(pageId));
554 }
555
556 private tell(pageId: string, event: DocsLiveEvent): void {
557 this.defer(
558 this.room(pageId)
559 .notice(event)
560 .catch((error: unknown) => console.error("docs could not tell page", pageId, error)),
561 );
562 }
563
564 private userKey(viewer: User): string {
565 return principalKey({ kind: "user", id: viewer.id });
566 }
567
568 // ── Sidebar and home ────────────────────────────────────────────────────
569
570 async sidebar(a: { workspace: string; viewer: Viewer }): Promise<Result<DocsSidebar>> {
571 const found = await this.viewerWorkspace(a.workspace, a.viewer);
572 if (!found.ok) return found;
573 const workspace = found.value;
574 const viewer = a.viewer!;
575 await this.ensureDefault(workspace, viewer);
576 const spaces = (await this.spacesFor(workspace, viewer)).filter((s) => s.role);
577 const ids = spaces.map((s) => s.row.id);
578 const repos = await this.repoSpacesFor(workspace, viewer).catch((error: unknown) => {
579 console.error("docs could not list projects' docs", String(error));
580 return [] as DocRepoSpace[];
581 });
582 if (!ids.length) return ok({ spaces: [], favorites: [], recent: [], can_create_space: true, trash_count: 0, stale_count: 0, repos });
583 const marks = ids.map(() => "?").join(",");
584 const [pages, favorites, recent, trash, stale] = await Promise.all([
585 this.db
586 .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`)
587 .bind(...ids)
588 .all<Pick<PageRow, "id" | "space_id" | "parent_id" | "position" | "title" | "icon">>(),
589 this.db
590 .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`)
591 .bind(viewer.id, ...ids)
592 .all<Pick<PageRow, "id" | "space_id" | "title" | "icon">>(),
593 this.db
594 .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`)
595 .bind(viewer.id, ...ids)
596 .all<Pick<PageRow, "id" | "space_id" | "title" | "icon">>(),
597 this.db
598 .prepare(`SELECT COUNT(*) AS n FROM pages WHERE space_id IN (${marks}) AND archived_at IS NOT NULL`)
599 .bind(...ids)
600 .first<{ n: number }>(),
601 this.db
602 .prepare(`SELECT DISTINCT c.page_id FROM page_changes c JOIN pages p ON p.id = c.page_id WHERE c.cleared_at IS NULL AND p.space_id IN (${marks}) AND p.archived_at IS NULL`)
603 .bind(...ids)
604 .all<{ page_id: string }>(),
605 ]);
606 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
607 const ref = (r: Pick<PageRow, "id" | "space_id" | "title" | "icon">) => this.ref(workspace.slug, bySpace.get(r.space_id)!, r);
608 const staleSet = new Set(stale.results.map((r) => r.page_id));
609 return ok({
610 spaces: spaces.map((s) => {
611 const mine = pages.results.filter((p) => p.space_id === s.row.id);
612 return {
613 ...this.toSpace(s, mine.length),
614 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), stale: staleSet.has(p.id) })),
615 };
616 }),
617 favorites: favorites.results.map(ref),
618 recent: recent.results.map(ref),
619 can_create_space: true,
620 trash_count: trash?.n ?? 0,
621 stale_count: staleSet.size,
622 repos,
623 });
624 }
625
626 // ── A project's docs ────────────────────────────────────────────────────
627
628 /** The repository docs shown in the workspace that the viewer can read, with the repositories as they are now. */
629 private async readableRepoSpaces(workspace: Workspace, viewer: User): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
630 const rows = (await this.db.prepare("SELECT * FROM repo_spaces WHERE workspace_id = ? ORDER BY repo").bind(workspace.id).all<RepoSpaceRow>()).results;
631 if (!rows.length || !this.env.REPOS) return [];
632 const readable = await reposClient(this.env.REPOS).readable(
633 rows.map((r) => r.repo_id),
634 viewer,
635 );
636 const byId = new Map(readable.map((r) => [r.id, r]));
637 return rows.filter((r) => byId.has(r.repo_id)).map((row) => ({ row, repo: byId.get(row.repo_id)! }));
638 }
639
640 private async toRepoSpaces(workspace: Workspace, viewer: User, found: { row: RepoSpaceRow; repo: Repo }[]): Promise<DocRepoSpace[]> {
641 if (!found.length) return [];
642 const ids = found.map((f) => f.row.id);
643 const [files, people] = await Promise.all([
644 this.db
645 .prepare(`SELECT space_id, path, title FROM repo_files WHERE space_id IN (${ids.map(() => "?").join(",")})`)
646 .bind(...ids)
647 .all<{ space_id: string; path: string; title: string }>(),
648 this.profiles(
649 workspace,
650 found.map((f) => f.row.added_by),
651 ),
652 ]);
653 const me = this.userKey(viewer);
654 const owner = this.viewerOwner(viewer, workspace.slug);
655 const readme = (path: string) => (/^readme\./i.test(path) ? 0 : 1);
656 return found.map(({ row, repo }) => ({
657 id: row.id,
658 repo: `${repo.namespace}/${repo.name}`,
659 default_branch: repo.defaultBranch,
660 commit: row.commit_sha,
661 indexed_at: row.indexed_at,
662 added_by: people.get(row.added_by)!,
663 files: files.results
664 .filter((f) => f.space_id === row.id)
665 .sort((a, b) => readme(a.path) - readme(b.path) || a.path.localeCompare(b.path))
666 .map((f) => ({ path: f.path, title: f.title })),
667 can_remove: row.added_by === me || owner,
668 }));
669 }
670
671 private async repoSpacesFor(workspace: Workspace, viewer: User): Promise<DocRepoSpace[]> {
672 return this.toRepoSpaces(workspace, viewer, await this.readableRepoSpaces(workspace, viewer));
673 }
674
675 async addRepoSpace(a: { workspace: string; viewer: Viewer; repo: string }): Promise<Result<DocRepoSpace>> {
676 const found = await this.viewerWorkspace(a.workspace, a.viewer);
677 if (!found.ok) return found;
678 const workspace = found.value;
679 const viewer = a.viewer!;
680 if (!this.env.REPOS) return fail("conflict", "Projects' docs aren't available here.");
681 const ref = projectRef(String(a.repo ?? ""));
682 if (!ref) return fail("invalid", "Choose a repository: owner/name.");
683 const [namespace, name] = ref.split("/") as [string, string];
684 const repo = await reposClient(this.env.REPOS).get({ namespace, name }, viewer);
685 if (!repo.ok) return fail("not_found", "No such repository, or you can't read it.");
686 const id = newId("rds");
687 const row: RepoSpaceRow = {
688 id,
689 workspace_id: workspace.id,
690 repo_id: repo.value.id,
691 repo: `${repo.value.namespace}/${repo.value.name}`.toLowerCase(),
692 default_branch: repo.value.defaultBranch,
693 commit_sha: null,
694 indexed_at: null,
695 added_by: this.userKey(viewer),
696 added_at: now(),
697 };
698 const inserted = await this.db
699 .prepare("INSERT INTO repo_spaces (id, workspace_id, repo_id, repo, default_branch, added_by, added_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (workspace_id, repo_id) DO NOTHING RETURNING id")
700 .bind(row.id, row.workspace_id, row.repo_id, row.repo, row.default_branch, row.added_by, row.added_at)
701 .first<{ id: string }>();
702 if (!inserted) return fail("conflict", `${ref}'s docs are already in Docs.`);
703 try {
704 const read = await indexRepoSpace({ DB: this.db, REPOS: this.env.REPOS }, row);
705 this.defer(indexRepoFiles(this.env, row.id, read.changed, read.gone));
706 } catch (error) {
707 console.error("docs could not read a project's docs", row.repo, String(error));
708 }
709 const fresh = (await this.db.prepare("SELECT * FROM repo_spaces WHERE id = ?").bind(id).first<RepoSpaceRow>()) ?? row;
710 const [space] = await this.toRepoSpaces(workspace, viewer, [{ row: fresh, repo: repo.value }]);
711 return ok(space!);
712 }
713
714 async removeRepoSpace(a: { workspace: string; viewer: Viewer; id: string }): Promise<Result<boolean>> {
715 const found = await this.viewerWorkspace(a.workspace, a.viewer);
716 if (!found.ok) return found;
717 const row = await this.db.prepare("SELECT * FROM repo_spaces WHERE id = ? AND workspace_id = ?").bind(String(a.id ?? ""), found.value.id).first<RepoSpaceRow>();
718 if (!row) return fail("not_found", "No such project's docs.");
719 if (row.added_by !== this.userKey(a.viewer!) && !this.viewerOwner(a.viewer!, a.workspace)) return fail("forbidden", "Only whoever added a project's docs, or an owner, can remove them.");
720 await this.db.batch([this.db.prepare("DELETE FROM repo_files_fts WHERE space_id = ?").bind(row.id), this.db.prepare("DELETE FROM repo_spaces WHERE id = ?").bind(row.id)]);
721 this.defer(forgetDocs(this.env, { space_id: row.id }));
722 return ok(true);
723 }
724
725 async repoPage(a: { workspace: string; viewer: Viewer; repo: string; path: string }): Promise<Result<DocRepoPage>> {
726 const found = await this.viewerWorkspace(a.workspace, a.viewer);
727 if (!found.ok) return found;
728 const workspace = found.value;
729 const ref = projectRef(String(a.repo ?? ""));
730 if (!ref) return fail("not_found", "No such file.");
731 const spaces = await this.readableRepoSpaces(workspace, a.viewer!);
732 const match = spaces.find((s) => `${s.repo.namespace}/${s.repo.name}`.toLowerCase() === ref || s.row.repo === ref);
733 if (!match) return fail("not_found", "No such file.");
734 const path = String(a.path ?? "").replace(/^\/+/, "");
735 const file = await this.db.prepare("SELECT path, title, markdown FROM repo_files WHERE space_id = ? AND path = ?").bind(match.row.id, path).first<{ path: string; title: string; markdown: string }>();
736 if (!file) return fail("not_found", "No such file.");
737 const [space] = await this.toRepoSpaces(workspace, a.viewer!, [match]);
738 const repoPath = `${match.repo.namespace}/${match.repo.name}`;
739 const encoded = file.path.split("/").map(encodeURIComponent).join("/");
740 return ok({
741 space: space!,
742 file: {
743 path: file.path,
744 title: file.title,
745 markdown: file.markdown,
746 href: `/${workspace.slug}/-/docs/repo/${repoPath}/${encoded}`,
747 code_href: `/${repoPath}/blob/${encodeURIComponent(match.repo.defaultBranch)}/${encoded}`,
748 },
749 });
750 }
751
752 async home(a: { workspace: string; viewer: Viewer; project: string | null }): Promise<Result<DocsHome>> {
753 const found = await this.viewerWorkspace(a.workspace, a.viewer);
754 if (!found.ok) return found;
755 const workspace = found.value;
756 const viewer = a.viewer!;
757 await this.ensureDefault(workspace, viewer);
758 const spaces = (await this.spacesFor(workspace, viewer)).filter((s) => s.role);
759 const project = a.project ? projectRef(a.project) : null;
760 const ids = spaces.map((s) => s.row.id);
761 const allProjects = new Set(spaces.flatMap((s) => s.projects));
762 if (!ids.length) return ok({ recent: [], mine: [], stale: [], spaces: [], projects: [...allProjects].sort(), project });
763 const marks = ids.map(() => "?").join(",");
764 const [recentRows, mineRows, pageProjects, counts, staleRows] = await Promise.all([
765 this.db
766 .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`)
767 .bind(...ids)
768 .all<PageRow>(),
769 this.db
770 .prepare(
771 `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`,
772 )
773 .bind(...ids, this.userKey(viewer), this.userKey(viewer))
774 .all<PageRow>(),
775 this.db
776 .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})`)
777 .bind(...ids)
778 .all<{ page_id: string; repo: string }>(),
779 this.db
780 .prepare(`SELECT space_id, COUNT(*) AS n FROM pages WHERE space_id IN (${marks}) AND archived_at IS NULL GROUP BY space_id`)
781 .bind(...ids)
782 .all<{ space_id: string; n: number }>(),
783 this.staleRows(ids, null, 24),
784 ]);
785 for (const p of pageProjects.results) allProjects.add(p.repo);
786 const projectsOf = (pageId: string) => pageProjects.results.filter((p) => p.page_id === pageId).map((p) => p.repo);
787 const spaceProjects = new Map(spaces.map((s) => [s.row.id, s.projects]));
788 const keep = (r: PageRow) => inProject(project, projectsOf(r.id), spaceProjects.get(r.space_id) ?? []);
789 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
790 const [recent, mine, stale] = await Promise.all([
791 this.toPages(workspace, bySpace, recentRows.results.filter(keep).slice(0, 12)),
792 this.toPages(workspace, bySpace, mineRows.results.filter(keep).slice(0, 8)),
793 this.toPages(workspace, bySpace, staleRows.filter(keep).slice(0, 8)),
794 ]);
795 const count = new Map(counts.results.map((c) => [c.space_id, c.n]));
796 return ok({
797 recent,
798 mine,
799 stale,
800 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)),
801 projects: [...allProjects].sort(),
802 project,
803 });
804 }
805
806 // ── Spaces ──────────────────────────────────────────────────────────────
807
808 private async spaceMembers(workspace: Workspace, space: Space): Promise<DocSpaceMember[]> {
809 const people = await this.profiles(
810 workspace,
811 space.members.filter((m) => !m.principal.startsWith("team:")).map((m) => m.principal),
812 );
813 return space.members
814 .map((m): DocSpaceMember => {
815 const key = memberKey(m.principal);
816 if (key?.kind === "team") return { key: m.principal, kind: "team", name: key.id, display_name: `@${workspace.slug}/${key.id}`, avatar: null, role: m.role };
817 const p = people.get(m.principal)!;
818 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 };
819 })
820 .sort((x, y) => RANK[y.role] - RANK[x.role] || x.display_name.localeCompare(y.display_name));
821 }
822
823 async space(a: { workspace: string; space: string; viewer: Viewer }): Promise<Result<{ space: DocSpace; members: DocSpaceMember[]; pages: DocPage[] }>> {
824 const found = await this.viewerWorkspace(a.workspace, a.viewer);
825 if (!found.ok) return found;
826 const workspace = found.value;
827 const spaces = await this.spacesFor(workspace, a.viewer!, true);
828 const space = spaces.find((s) => s.row.slug === String(a.space ?? "").toLowerCase() || s.row.id === a.space);
829 if (!space?.role) return fail("not_found", "No such space.");
830 const rows = (
831 await this.db
832 .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")
833 .bind(space.row.id)
834 .all<PageRow>()
835 ).results;
836 const [members, pages] = await Promise.all([this.spaceMembers(workspace, space), this.toPages(workspace, new Map([[space.row.id, space.row]]), rows)]);
837 return ok({ space: this.toSpace(space, rows.length), members, pages });
838 }
839
840 async createSpace(a: { workspace: string; viewer: Viewer; input: NewDocSpace }): Promise<Result<DocSpace>> {
841 const found = await this.viewerWorkspace(a.workspace, a.viewer);
842 if (!found.ok) return found;
843 const workspace = found.value;
844 const viewer = a.viewer!;
845 const input = a.input ?? ({} as NewDocSpace);
846 const name = cleanTitle(input.name).slice(0, 80);
847 if (!name) return fail("invalid", "Name the space.");
848 const kind: DocSpaceKind = input.kind === "team" || input.kind === "private" ? input.kind : "workspace";
849 const team = kind === "team" ? String(input.team ?? "").trim().toLowerCase() : null;
850 if (kind === "team") {
851 if (!team) return fail("invalid", "Choose the team the space is for.");
852 const teams = await this.teamsOf(workspace);
853 const known = [...teams.values()].some((set) => set.has(team));
854 if (!known) return fail("invalid", "No such team in this workspace.");
855 }
856 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));
857 let slug: string;
858 if (input.slug) {
859 const wanted = validSpaceSlug(input.slug);
860 if (!wanted) return fail("invalid", "A space's address is lowercase letters, numbers and hyphens.");
861 if (taken.has(wanted)) return fail("conflict", "Another space has that address.");
862 slug = wanted;
863 } else slug = freeSlug(name, taken);
864 const defaultRole: DocRole | null = kind === "private" ? null : isRole(input.default_role) ? input.default_role : "edit";
865 const id = newId("spc");
866 const at = now();
867 const me = this.userKey(viewer);
868 const projects = cleanProjects(input.projects);
869 await this.db.batch([
870 this.db
871 .prepare(
872 "INSERT INTO spaces (id, workspace_id, slug, name, description, icon, kind, team, default_role, agent_mode, is_default, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)",
873 )
874 .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),
875 // Whoever makes a space manages it.
876 this.db.prepare("INSERT INTO space_members (space_id, principal, role, added_by, added_at) VALUES (?, ?, 'manage', ?, ?)").bind(id, me, me, at),
877 ...projects.map((repo) => this.db.prepare("INSERT INTO space_projects (space_id, repo) VALUES (?, ?)").bind(id, repo)),
878 ]);
879 const spaces = await this.spacesFor(workspace, viewer);
880 const space = spaces.find((s) => s.row.id === id)!;
881 return ok(this.toSpace(space));
882 }
883
884 async updateSpace(a: { workspace: string; space_id: string; viewer: Viewer; change: DocSpaceChange }): Promise<Result<DocSpace>> {
885 const found = await this.viewerWorkspace(a.workspace, a.viewer);
886 if (!found.ok) return found;
887 const workspace = found.value;
888 const spaces = await this.spacesFor(workspace, a.viewer!, true);
889 const space = spaces.find((s) => s.row.id === a.space_id);
890 if (!space?.role) return fail("not_found", "No such space.");
891 if (!atLeast(space.role, "manage")) return fail("forbidden", "Only people with full access can change a space.");
892 const c = a.change ?? {};
893 const sets: string[] = [];
894 const values: unknown[] = [];
895 const set = (column: string, value: unknown) => {
896 sets.push(`${column} = ?`);
897 values.push(value);
898 };
899 if (c.name !== undefined) {
900 const name = cleanTitle(c.name).slice(0, 80);
901 if (!name) return fail("invalid", "Name the space.");
902 set("name", name);
903 }
904 if (c.description !== undefined) set("description", String(c.description ?? "").trim().slice(0, 300) || null);
905 if (c.icon !== undefined) set("icon", cleanIcon(c.icon));
906 if (c.slug !== undefined && c.slug !== space.row.slug) {
907 const wanted = validSpaceSlug(String(c.slug ?? ""));
908 if (!wanted) return fail("invalid", "A space's address is lowercase letters, numbers and hyphens.");
909 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();
910 if (clash) return fail("conflict", "Another space has that address.");
911 set("slug", wanted);
912 }
913 if (c.kind !== undefined && c.kind !== space.row.kind) {
914 if (space.row.is_default) return fail("invalid", "The General space is always the whole workspace's.");
915 if (c.kind !== "workspace" && c.kind !== "team" && c.kind !== "private") return fail("invalid", "Choose who the space is for.");
916 set("kind", c.kind);
917 if (c.kind === "private") set("default_role", null);
918 else if (!space.row.default_role) set("default_role", "edit");
919 if (c.kind === "private" && !space.members.some((m) => m.role === "manage")) {
920 // Someone must still manage it: whoever made it private.
921 await this.db
922 .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'")
923 .bind(space.row.id, this.userKey(a.viewer!), this.userKey(a.viewer!), now())
924 .run();
925 }
926 }
927 if (c.team !== undefined) set("team", c.team ? String(c.team).trim().toLowerCase() : null);
928 if (c.default_role !== undefined && (c.kind ?? space.row.kind) !== "private") set("default_role", isRole(c.default_role) ? c.default_role : null);
929 if (c.agent_mode !== undefined) set("agent_mode", c.agent_mode === "edit" ? "edit" : "suggest");
930 if (c.archived !== undefined) {
931 if (space.row.is_default && c.archived) return fail("invalid", "The General space can't be archived.");
932 set("archived_at", c.archived ? now() : null);
933 }
934 const statements: D1PreparedStatement[] = [];
935 if (sets.length) statements.push(this.db.prepare(`UPDATE spaces SET ${sets.join(", ")} WHERE id = ?`).bind(...values, space.row.id));
936 if (c.projects !== undefined) {
937 statements.push(this.db.prepare("DELETE FROM space_projects WHERE space_id = ?").bind(space.row.id));
938 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));
939 }
940 if (statements.length) await this.db.batch(statements);
941 this.workspaces.clear();
942 const after = (await this.spacesFor(workspace, a.viewer!, true)).find((s) => s.row.id === space.row.id)!;
943 return ok(this.toSpace(after));
944 }
945
946 async setSpaceMember(a: { workspace: string; space_id: string; viewer: Viewer; member: string; role: DocRole | null }): Promise<Result<DocSpaceMember[]>> {
947 const found = await this.viewerWorkspace(a.workspace, a.viewer);
948 if (!found.ok) return found;
949 const workspace = found.value;
950 const spaces = await this.spacesFor(workspace, a.viewer!, true);
951 const space = spaces.find((s) => s.row.id === a.space_id);
952 if (!space?.role) return fail("not_found", "No such space.");
953 if (!atLeast(space.role, "manage")) return fail("forbidden", "Only people with full access can change who is in a space.");
954 const key = memberKey(String(a.member ?? ""));
955 if (!key) return fail("invalid", "Choose a person, agent or team.");
956 const role = a.role === null ? null : isRole(a.role) ? a.role : null;
957 if (a.role !== null && !role) return fail("invalid", "Choose a role.");
958 // They must belong to the workspace.
959 if (key.kind === "user") {
960 await this.nameUsers([key.id]);
961 const username = this.usernames.get(key.id);
962 if (!username || !(await this.members(workspace)).has(username.toLowerCase())) return fail("invalid", "Only members of the workspace can be added.");
963 } else if (key.kind === "agent") {
964 const agent = (await this.agentsById([key.id])).get(key.id);
965 if (!agent || agent.workspace_id !== workspace.id || agent.archived_at) return fail("invalid", "No such agent in this workspace.");
966 } else {
967 const teams = await this.teamsOf(workspace);
968 if (![...teams.values()].some((set) => set.has(key.id.toLowerCase()))) return fail("invalid", "No such team in this workspace.");
969 }
970 const principal = key.kind === "team" ? `team:${key.id.toLowerCase()}` : `${key.kind}:${key.id}`;
971 if (leavesNoManager(space.row.kind, space.members, principal, role)) return fail("invalid", "Someone must keep full access to a private space.");
972 if (role) {
973 await this.db
974 .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")
975 .bind(space.row.id, principal, role, this.userKey(a.viewer!), now())
976 .run();
977 } else {
978 await this.db.prepare("DELETE FROM space_members WHERE space_id = ? AND principal = ?").bind(space.row.id, principal).run();
979 }
980 const after = (await this.spacesFor(workspace, a.viewer!, true)).find((s) => s.row.id === space.row.id)!;
981 return ok(await this.spaceMembers(workspace, after));
982 }
983
984 // ── Pages ───────────────────────────────────────────────────────────────
985
986 async page(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPageDetail>> {
987 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
988 if (!found.ok) return found;
989 const { workspace, page, space, spaces } = found.value;
990 const viewer = a.viewer!;
991 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
992 const readable = new Set(spaces.filter((s) => s.role).map((s) => s.row.id));
993 const [tree, backlinks, children, favorite, viewed, suggestions, cited, staleness] = await Promise.all([
994 this.db.prepare("SELECT id, space_id, parent_id, position, title, icon FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>(),
995 this.db
996 .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")
997 .bind(page.id)
998 .all<PageRow>(),
999 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>(),
1000 this.db.prepare("SELECT 1 AS yes FROM favorites WHERE user_id = ? AND page_id = ?").bind(viewer.id, page.id).first<{ yes: number }>(),
1001 this.db.prepare("SELECT viewed_at FROM page_views WHERE user_id = ? AND page_id = ?").bind(viewer.id, page.id).first<{ viewed_at: string }>(),
1002 this.openSuggestions(workspace, page.id),
1003 this.citationsOf([page.id]),
1004 this.stalenessFor(page.id, viewer),
1005 ]);
1006 this.defer(
1007 this.db
1008 .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")
1009 .bind(page.id, viewer.id, now())
1010 .run(),
1011 );
1012 const [detail] = await this.toPages(workspace, bySpace, [page]);
1013 return ok({
1014 page: detail!,
1015 space: this.toSpace(space),
1016 breadcrumbs: ancestors(tree.results, page.id).map((r) => this.ref(workspace.slug, space.row, r)),
1017 markdown: page.markdown,
1018 role: space.role!,
1019 backlinks: backlinks.results.filter((r) => readable.has(r.space_id)).map((r) => this.ref(workspace.slug, bySpace.get(r.space_id)!, r)),
1020 children: children.results.map((r) => this.ref(workspace.slug, space.row, r)),
1021 favorite: !!favorite,
1022 last_viewed_at: viewed?.viewed_at ?? null,
1023 suggestions,
1024 citations: cited.get(page.id) ?? [],
1025 describes: (cited.get(page.id) ?? []).filter((c) => c.source === "header").map((c) => ({ repo: c.repo, path: c.path })),
1026 staleness,
1027 });
1028 }
1029
1030 // ── Citations and staleness ─────────────────────────────────────────────
1031
1032 /** Each page's citations, by page. */
1033 private async citationsOf(pageIds: string[]): Promise<Map<string, DocCitation[]>> {
1034 const out = new Map<string, DocCitation[]>();
1035 if (!pageIds.length) return out;
1036 const rows = await this.db
1037 .prepare(`SELECT page_id, repo, path, kind, label, ref, source FROM citations WHERE page_id IN (${pageIds.map(() => "?").join(",")}) ORDER BY source DESC, repo, path`)
1038 .bind(...pageIds)
1039 .all<Omit<DocCitation, "label"> & { page_id: string; label: string }>();
1040 for (const r of rows.results) {
1041 const list = out.get(r.page_id) ?? [];
1042 list.push({ repo: r.repo, path: r.path, kind: r.kind, label: r.label || null, ref: r.ref, source: r.source });
1043 out.set(r.page_id, list);
1044 }
1045 return out;
1046 }
1047
1048 /** Of these repositories (`owner/name`), those the viewer can read. */
1049 private async readableRepos(viewer: User, repos: string[]): Promise<Set<string>> {
1050 const out = new Set<string>();
1051 if (!this.env.REPOS) return out;
1052 const client = reposClient(this.env.REPOS);
1053 await Promise.all(
1054 [...new Set(repos)].slice(0, 25).map(async (repo) => {
1055 const [namespace, name] = repo.split("/") as [string, string];
1056 const found = await client.get({ namespace, name }, viewer).catch(() => null);
1057 if (found?.ok) out.add(repo);
1058 }),
1059 );
1060 return out;
1061 }
1062
1063 /** Open changes on these pages, newest first. */
1064 private async openChanges(pageIds: string[]): Promise<ChangeRow[]> {
1065 if (!pageIds.length) return [];
1066 const out: ChangeRow[] = [];
1067 for (let i = 0; i < pageIds.length; i += 90) {
1068 const part = pageIds.slice(i, i + 90);
1069 const rows = await this.db
1070 .prepare(`SELECT * FROM page_changes WHERE cleared_at IS NULL AND page_id IN (${part.map(() => "?").join(",")}) ORDER BY detected_at DESC`)
1071 .bind(...part)
1072 .all<ChangeRow>();
1073 out.push(...rows.results);
1074 }
1075 return out.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
1076 }
1077
1078 /** A change as a reader sees it: named only when they can read its repository. */
1079 private toChange(row: ChangeRow, readable: Set<string>): DocStaleChange {
1080 if (!readable.has(row.repo)) return { visible: false, repo: null, commit: null, pull: null, paths: [], at: row.detected_at };
1081 let paths: string[] = [];
1082 try {
1083 paths = JSON.parse(row.paths) as string[];
1084 } catch {
1085 paths = [];
1086 }
1087 return {
1088 visible: true,
1089 repo: row.repo,
1090 commit: row.commit_sha,
1091 pull: row.pull_number ? { number: row.pull_number, title: row.pull_title } : null,
1092 paths,
1093 at: row.detected_at,
1094 };
1095 }
1096
1097 /** Why a page is possibly out of date, as this viewer may see it; null when it isn't. */
1098 private async stalenessFor(pageId: string, viewer: User): Promise<DocStaleness | null> {
1099 const rows = await this.openChanges([pageId]);
1100 if (!rows.length) return null;
1101 const readable = await this.readableRepos(
1102 viewer,
1103 rows.map((r) => r.repo),
1104 );
1105 const changes = rows.slice(0, 20).map((r) => this.toChange(r, readable));
1106 return { since: rows[rows.length - 1]!.detected_at, changes };
1107 }
1108
1109 /** Live pages in these spaces that are possibly out of date, most recently flagged first; `repo` narrows to changes there. */
1110 private async staleRows(spaceIds: string[], repo: string | null, limit: number): Promise<PageRow[]> {
1111 if (!spaceIds.length) return [];
1112 const marks = spaceIds.map(() => "?").join(",");
1113 return (
1114 await this.db
1115 .prepare(
1116 `SELECT p.id, p.workspace_id, p.space_id, p.parent_id, p.position, p.title, p.icon, p.cover, substr(p.markdown, 1, 600) AS markdown, p.created_by, p.created_at, p.updated_by, p.updated_at, p.archived_at, p.archived_by
1117 FROM pages p JOIN (SELECT page_id, MAX(detected_at) AS flagged FROM page_changes WHERE cleared_at IS NULL ${repo ? "AND repo = ?" : ""} GROUP BY page_id) c ON c.page_id = p.id
1118 WHERE p.space_id IN (${marks}) AND p.archived_at IS NULL ORDER BY c.flagged DESC LIMIT ?`,
1119 )
1120 .bind(...(repo ? [repo] : []), ...spaceIds, limit)
1121 .all<PageRow>()
1122 ).results;
1123 }
1124
1125 async stalePages(a: { workspace: string; viewer: Viewer; repo?: string | null }): Promise<Result<DocPage[]>> {
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 const rows = await this.staleRows(
1131 spaces.map((s) => s.row.id),
1132 a.repo ? projectRef(a.repo) : null,
1133 200,
1134 );
1135 return ok(await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), rows));
1136 }
1137
1138 /** Clears every open change on a page. */
1139 private async clearStale(pageId: string, by: string): Promise<boolean> {
1140 const done = await this.db.prepare("UPDATE page_changes SET cleared_at = ?, cleared_by = ? WHERE page_id = ? AND cleared_at IS NULL").bind(now(), by, pageId).run();
1141 const cleared = (done.meta?.changes ?? 0) > 0;
1142 if (cleared) this.tell(pageId, { type: "page.staleness" });
1143 return cleared;
1144 }
1145
1146 async markCurrent(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1147 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1148 if (!found.ok) return found;
1149 await this.clearStale(found.value.page.id, this.userKey(a.viewer!));
1150 return ok(true);
1151 }
1152
1153 async stalePagesForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; repo?: string | null; since?: string | null; audience: DocAudience | null }): Promise<Result<DocStalePage[]>> {
1154 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
1155 if (!found.ok) return found;
1156 const { workspace, spaces } = found.value;
1157 const repo = a.repo ? projectRef(a.repo) : null;
1158 if (a.repo && !repo) return fail("invalid", "Name the repository as owner/name.");
1159 const since = a.since && !Number.isNaN(Date.parse(a.since)) ? new Date(a.since).toISOString() : null;
1160 const rows = await this.staleRows(
1161 spaces.map((s) => s.row.id),
1162 repo,
1163 200,
1164 );
1165 const [changes, cited, pages] = await Promise.all([
1166 this.openChanges(rows.map((r) => r.id)),
1167 this.citationsOf(rows.map((r) => r.id)),
1168 this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), rows),
1169 ]);
1170 // The agent learns only of code its person can read.
1171 const readable = await this.readableRepos(a.viewer!, [...changes.map((c) => c.repo), ...[...cited.values()].flat().map((c) => c.repo)]);
1172 const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
1173 const out: DocStalePage[] = [];
1174 for (const row of rows) {
1175 const mine = changes.filter((c) => c.page_id === row.id && readable.has(c.repo) && (!repo || c.repo === repo));
1176 if (!mine.length) continue;
1177 const newest = mine[0]!.detected_at;
1178 if (since && newest < since) continue;
1179 const space = bySpace.get(row.space_id)!;
1180 const page = pages.find((p) => p.id === row.id)!;
1181 out.push({
1182 page: { ...this.ref(workspace.slug, space.row, row), updated_at: row.updated_at },
1183 space: { id: space.row.id, slug: space.row.slug, name: space.row.name, agent_mode: space.row.agent_mode },
1184 can: space.can,
1185 owners: page.owners,
1186 citations: (cited.get(row.id) ?? []).filter((c) => readable.has(c.repo)),
1187 changes: mine.slice(0, 20).map((c) => this.toChange(c, readable)),
1188 since: mine[mine.length - 1]!.detected_at,
1189 });
1190 if (out.length >= 50) break;
1191 }
1192 return ok(out);
1193 }
1194
1195 /** Where a new page in `space` from `input` starts: its Markdown and title. */
1196 private async startingPoint(workspace: Workspace, input: NewDocPage): Promise<{ markdown: string; title: string; icon: string | null }> {
1197 let markdown = String(input.markdown ?? "").slice(0, MAX_MARKDOWN);
1198 let title = cleanTitle(input.title);
1199 let icon = cleanIcon(input.icon);
1200 if (input.template_id) {
1201 const template = builtinTemplate(input.template_id) ?? (await this.savedTemplate(workspace, input.template_id));
1202 if (template) {
1203 markdown = template.markdown;
1204 if (!title) title = template.name;
1205 if (!icon) icon = template.icon;
1206 }
1207 }
1208 return { markdown, title, icon };
1209 }
1210
1211 private async savedTemplate(workspace: Workspace, id: string): Promise<DocTemplate | null> {
1212 const row = await this.db
1213 .prepare("SELECT * FROM templates WHERE id = ? AND workspace_id = ?")
1214 .bind(id, workspace.id)
1215 .first<{ id: string; name: string; description: string; icon: string; markdown: string; created_by: string }>();
1216 if (!row) return null;
1217 return { id: row.id, name: row.name, description: row.description, icon: row.icon, markdown: row.markdown, builtin: false, created_by: parsePrincipalKey(row.created_by) };
1218 }
1219
1220 /** Inserts a page row and fills its room. */
1221 private async insertPage(
1222 workspace: Workspace,
1223 space: SpaceRow,
1224 author: string,
1225 input: { parent_id: string | null; title: string; icon: string | null; markdown: string; state?: Uint8Array | null; projects?: string[]; owners?: string[]; position?: number },
1226 ): Promise<PageRow> {
1227 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;
1228 const id = newId("pag");
1229 const at = now();
1230 const position = input.position ?? lastPosition(rows, input.parent_id);
1231 const row: PageRow = {
1232 id,
1233 workspace_id: workspace.id,
1234 space_id: space.id,
1235 parent_id: input.parent_id,
1236 position,
1237 title: input.title,
1238 icon: input.icon,
1239 cover: null,
1240 markdown: input.markdown,
1241 created_by: author,
1242 created_at: at,
1243 updated_by: author,
1244 updated_at: at,
1245 archived_at: null,
1246 archived_by: null,
1247 };
1248 await this.db.batch([
1249 this.db
1250 .prepare(
1251 "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, ?, ?, ?, ?, ?)",
1252 )
1253 .bind(id, workspace.id, space.id, input.parent_id, position, input.title, input.icon, input.markdown, author, at, author, at),
1254 this.db.prepare("INSERT INTO pages_fts (page_id, title, body) VALUES (?, ?, ?)").bind(id, input.title, searchText(input.markdown)),
1255 this.db.prepare("INSERT INTO page_owners (page_id, principal) VALUES (?, ?)").bind(id, author),
1256 ...(input.owners ?? []).filter((o) => o !== author).map((o) => this.db.prepare("INSERT OR IGNORE INTO page_owners (page_id, principal) VALUES (?, ?)").bind(id, o)),
1257 ...(input.projects ?? []).map((repo) => this.db.prepare("INSERT OR IGNORE INTO page_projects (page_id, repo) VALUES (?, ?)").bind(id, repo)),
1258 this.db
1259 .prepare("INSERT INTO page_versions (id, page_id, created_at, kind, authors, note, markdown, state) VALUES (?, ?, ?, 'created', ?, NULL, ?, NULL)")
1260 .bind(newId("ver"), id, at, JSON.stringify([author]), input.markdown),
1261 ]);
1262 await this.room(id).ensure({ page_id: id, workspace_slug: workspace.slug, markdown: input.markdown, state: input.state ?? null });
1263 this.defer(publishDocEvent(this.env.EVENTS, "doc.page.created", this.eventData(workspace, space, row), author));
1264 // Its passages, for agents' recall (src/indexer.ts); later edits are indexed by its room.
1265 if (input.markdown.trim()) this.defer(indexPage(this.env, id));
1266 return row;
1267 }
1268
1269 /** What every `doc.page.*` event says of a page. */
1270 private eventData(workspace: Workspace, space: Pick<SpaceRow, "id" | "slug">, row: Pick<PageRow, "id" | "title" | "icon">) {
1271 return { workspace: workspace.slug, workspaceId: workspace.id, pageId: row.id, spaceId: space.id, title: row.title, path: this.ref(workspace.slug, space, row).path };
1272 }
1273
1274 async createPage(a: { workspace: string; viewer: Viewer; input: NewDocPage }): Promise<Result<DocPage>> {
1275 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1276 if (!found.ok) return found;
1277 const workspace = found.value;
1278 const input = a.input ?? ({} as NewDocPage);
1279 const spaces = await this.spacesFor(workspace, a.viewer!);
1280 const space = spaces.find((s) => s.row.id === input.space_id) ?? (input.space_id ? null : spaces.find((s) => s.row.is_default));
1281 if (!space?.role) return fail("not_found", "No such space.");
1282 if (!atLeast(space.role, "edit")) return fail("forbidden", "You can read this space but not add pages to it.");
1283 const parent = input.parent_id
1284 ? 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 }>()
1285 : null;
1286 if (input.parent_id && !parent) return fail("not_found", "No such parent page.");
1287 const start = await this.startingPoint(workspace, input);
1288 const row = await this.insertPage(workspace, space.row, this.userKey(a.viewer!), {
1289 parent_id: parent?.id ?? null,
1290 title: start.title,
1291 icon: start.icon,
1292 markdown: start.markdown,
1293 projects: cleanProjects(input.projects),
1294 });
1295 const [page] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [row]);
1296 return ok(page!);
1297 }
1298
1299 async updatePage(a: { workspace: string; page_id: string; viewer: Viewer; change: DocPageChange }): Promise<Result<DocPage>> {
1300 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1301 if (!found.ok) return found;
1302 const { workspace, page, space } = found.value;
1303 const c = a.change ?? {};
1304 const statements: D1PreparedStatement[] = [];
1305 const sets: string[] = [];
1306 const values: unknown[] = [];
1307 if (c.title !== undefined) {
1308 sets.push("title = ?");
1309 values.push(cleanTitle(c.title));
1310 statements.push(this.db.prepare("UPDATE pages_fts SET title = ? WHERE page_id = ?").bind(cleanTitle(c.title), page.id));
1311 }
1312 if (c.icon !== undefined) {
1313 sets.push("icon = ?");
1314 values.push(cleanIcon(c.icon));
1315 }
1316 if (c.cover !== undefined) {
1317 sets.push("cover = ?");
1318 values.push(cleanCover(c.cover));
1319 }
1320 if (sets.length) {
1321 sets.push("updated_at = ?", "updated_by = ?");
1322 values.push(now(), this.userKey(a.viewer!));
1323 statements.unshift(this.db.prepare(`UPDATE pages SET ${sets.join(", ")} WHERE id = ?`).bind(...values, page.id));
1324 }
1325 if (c.projects !== undefined) {
1326 statements.push(this.db.prepare("DELETE FROM page_projects WHERE page_id = ?").bind(page.id));
1327 for (const repo of cleanProjects(c.projects)) statements.push(this.db.prepare("INSERT INTO page_projects (page_id, repo) VALUES (?, ?)").bind(page.id, repo));
1328 }
1329 if (c.describes !== undefined) {
1330 statements.push(this.db.prepare("DELETE FROM citations WHERE page_id = ? AND source = 'header'").bind(page.id));
1331 for (const d of cleanDescribes(c.describes)) {
1332 statements.push(this.db.prepare("INSERT OR IGNORE INTO citations (page_id, repo, path, kind, label, ref, source) VALUES (?, ?, ?, 'path', '', NULL, 'header')").bind(page.id, d.repo, d.path));
1333 }
1334 }
1335 if (c.owners !== undefined) {
1336 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);
1337 statements.push(this.db.prepare("DELETE FROM page_owners WHERE page_id = ?").bind(page.id));
1338 for (const o of owners) statements.push(this.db.prepare("INSERT INTO page_owners (page_id, principal) VALUES (?, ?)").bind(page.id, o));
1339 }
1340 if (statements.length) await this.db.batch(statements);
1341 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1342 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1343 this.tell(page.id, { type: "page.updated", page: detail! });
1344 // The title is part of what each passage is embedded with.
1345 if (c.title !== undefined && cleanTitle(c.title) !== page.title) this.defer(indexPage(this.env, page.id));
1346 return ok(detail!);
1347 }
1348
1349 async movePage(a: { workspace: string; page_id: string; viewer: Viewer; move: DocMove }): Promise<Result<DocPage>> {
1350 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1351 if (!found.ok) return found;
1352 const { workspace, page, spaces } = found.value;
1353 const move = a.move ?? ({ parent_id: null } as DocMove);
1354 const target = move.space_id ? spaces.find((s) => s.row.id === move.space_id) : spaces.find((s) => s.row.id === page.space_id);
1355 if (!target?.role || target.row.archived_at) return fail("not_found", "No such space.");
1356 if (!atLeast(target.role, "edit")) return fail("forbidden", "You can't add pages to that space.");
1357 const [sourceRows, targetRows] = await Promise.all([
1358 this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(page.space_id).all<PageRow>(),
1359 this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(target.row.id).all<PageRow>(),
1360 ]);
1361 const parent = move.parent_id ?? null;
1362 if (parent && !targetRows.results.some((r) => r.id === parent)) return fail("not_found", "No such parent page in that space.");
1363 if (wouldCycle(sourceRows.results, page.id, parent)) return fail("invalid", "A page can't go inside itself.");
1364 const placed = placeBefore(targetRows.results, page.id, parent, move.before_id ?? null);
1365 const statements: D1PreparedStatement[] = [this.db.prepare("UPDATE pages SET parent_id = ?, position = ? WHERE id = ?").bind(parent, placed.position, page.id)];
1366 for (const [id, position] of placed.renumber) statements.push(this.db.prepare("UPDATE pages SET position = ? WHERE id = ?").bind(position, id));
1367 let moved: string[] = [];
1368 if (target.row.id !== page.space_id) {
1369 // The page and everything under it move to the other space.
1370 const all = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>()).results;
1371 moved = descendants(all, page.id);
1372 for (const id of moved) statements.push(this.db.prepare("UPDATE pages SET space_id = ? WHERE id = ?").bind(target.row.id, id));
1373 }
1374 await this.db.batch(statements);
1375 // Their passages are filed under the new space (no new embeddings: they only moved).
1376 if (moved.length) this.defer(this.reindexPages(moved));
1377 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1378 const [detail] = await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), [after!]);
1379 this.tell(page.id, { type: "page.updated", page: detail! });
1380 return ok(detail!);
1381 }
1382
1383 async duplicatePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
1384 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1385 if (!found.ok) return found;
1386 const { workspace, page, space } = found.value;
1387 const room = this.room(page.id);
1388 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1389 const [state, read] = await Promise.all([room.state(), room.read()]);
1390 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;
1391 const next = rows.filter((r) => r.parent_id === page.parent_id).sort((x, y) => x.position - y.position).find((r) => r.position > page.position);
1392 const row = await this.insertPage(workspace, space.row, this.userKey(a.viewer!), {
1393 parent_id: page.parent_id,
1394 title: `${page.title || "Untitled"} (copy)`.slice(0, MAX_TITLE),
1395 icon: page.icon,
1396 markdown: read.markdown,
1397 state,
1398 position: next ? (page.position + next.position) / 2 : page.position + 1024,
1399 });
1400 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [row]);
1401 return ok(detail!);
1402 }
1403
1404 async archivePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
1405 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1406 if (!found.ok) return found;
1407 const { workspace, page, space } = found.value;
1408 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;
1409 const ids = descendants(all, page.id);
1410 const at = now();
1411 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)));
1412 for (const id of ids) this.defer(this.room(id).closeAll("Moved to the trash").catch(() => undefined));
1413 // Out of agents' recall while in the trash; restoring indexes them again.
1414 this.defer(forgetDocs(this.env, { page_ids: ids }));
1415 this.defer(publishDocEvent(this.env.EVENTS, "doc.page.archived", this.eventData(workspace, space.row, page), this.userKey(a.viewer!)));
1416 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1417 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1418 return ok(detail!);
1419 }
1420
1421 async restorePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
1422 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1423 if (!found.ok) return found;
1424 const { workspace, page, space } = found.value;
1425 if (!page.archived_at) return fail("invalid", "That page isn't in the trash.");
1426 // It comes back with what was trashed with it; under its parent if that is still there.
1427 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;
1428 const parent = page.parent_id ? all.find((r) => r.id === page.parent_id) : null;
1429 const parentGone = !!page.parent_id && (!parent || !!parent.archived_at);
1430 const ids = descendants(all, page.id).filter((id) => all.find((r) => r.id === id)?.archived_at === page.archived_at);
1431 const statements = ids.map((id) => this.db.prepare("UPDATE pages SET archived_at = NULL, archived_by = NULL WHERE id = ?").bind(id));
1432 if (parentGone) statements.push(this.db.prepare("UPDATE pages SET parent_id = NULL WHERE id = ?").bind(page.id));
1433 await this.db.batch(statements);
1434 this.defer(this.reindexPages(ids));
1435 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1436 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1437 return ok(detail!);
1438 }
1439
1440 async deletePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1441 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "manage");
1442 if (!found.ok) return found;
1443 const { page } = found.value;
1444 if (!page.archived_at) return fail("invalid", "Move the page to the trash first.");
1445 const all = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>()).results;
1446 const ids = descendants(all, page.id);
1447 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)]));
1448 this.defer(forgetDocs(this.env, { page_ids: ids }));
1449 return ok(true);
1450 }
1451
1452 async trash(a: { workspace: string; viewer: Viewer }): Promise<Result<DocPage[]>> {
1453 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1454 if (!found.ok) return found;
1455 const workspace = found.value;
1456 const spaces = (await this.spacesFor(workspace, a.viewer!)).filter((s) => atLeast(s.role, "edit"));
1457 if (!spaces.length) return ok([]);
1458 const marks = spaces.map(() => "?").join(",");
1459 const rows = (
1460 await this.db
1461 .prepare(`SELECT ${PAGE_COLUMNS} FROM pages WHERE space_id IN (${marks}) AND archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT 200`)
1462 .bind(...spaces.map((s) => s.row.id))
1463 .all<PageRow>()
1464 ).results;
1465 return ok(await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), rows));
1466 }
1467
1468 async favorite(a: { workspace: string; page_id: string; viewer: Viewer; on: boolean }): Promise<Result<boolean>> {
1469 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1470 if (!found.ok) return found;
1471 if (a.on) {
1472 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();
1473 } else {
1474 await this.db.prepare("DELETE FROM favorites WHERE user_id = ? AND page_id = ?").bind(a.viewer!.id, a.page_id).run();
1475 }
1476 return ok(!!a.on);
1477 }
1478
1479 // ── Search ──────────────────────────────────────────────────────────────
1480
1481 /** Full text over `spaceIds`, best first. */
1482 private async searchIn(workspace: Workspace, spaces: Space[], query: DocSearchQuery): Promise<DocSearchHit[]> {
1483 const q = ftsQuery(query.query);
1484 const ids = searchSpaces(
1485 spaces.map((s) => s.row.id),
1486 query.space_id ?? null,
1487 );
1488 if (!ids.length) return [];
1489 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1490 const marks = ids.map(() => "?").join(",");
1491 const project = query.project ? projectRef(query.project) : null;
1492 type Hit = { id: string; space_id: string; title: string; icon: string | null; updated_at: string; snippet: string };
1493 let rows: Hit[];
1494 if (q) {
1495 rows = (
1496 await this.db
1497 .prepare(
1498 `SELECT p.id, p.space_id, p.title, p.icon, p.updated_at, snippet(pages_fts, 2, '[[', ']]', '…', 16) AS snippet
1499 FROM pages_fts JOIN pages p ON p.id = pages_fts.page_id
1500 WHERE pages_fts MATCH ? AND p.space_id IN (${marks}) AND p.archived_at IS NULL
1501 ORDER BY bm25(pages_fts, 0, 8.0, 1.0) LIMIT ?`,
1502 )
1503 .bind(q, ...ids, limit * 3)
1504 .all<Hit>()
1505 ).results;
1506 } else {
1507 rows = (
1508 await this.db
1509 .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 ?`)
1510 .bind(...ids, limit * 3)
1511 .all<Hit>()
1512 ).results;
1513 }
1514 const pageIds = rows.map((r) => r.id);
1515 const projects = pageIds.length
1516 ? (
1517 await this.db
1518 .prepare(`SELECT page_id, repo FROM page_projects WHERE page_id IN (${pageIds.map(() => "?").join(",")})`)
1519 .bind(...pageIds)
1520 .all<{ page_id: string; repo: string }>()
1521 ).results
1522 : [];
1523 const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
1524 return rows
1525 .map((r) => {
1526 const space = bySpace.get(r.space_id)!;
1527 const own = projects.filter((p) => p.page_id === r.id).map((p) => p.repo);
1528 return { r, space, own };
1529 })
1530 .filter(({ space, own }) => inProject(project, own, space.projects))
1531 .slice(0, limit)
1532 .map(({ r, space, own }) => ({
1533 ...this.ref(workspace.slug, space.row, r),
1534 space_name: space.row.name,
1535 snippet: q ? r.snippet : excerpt(r.snippet, 140),
1536 updated_at: r.updated_at,
1537 projects: [...new Set([...own, ...space.projects])],
1538 }));
1539 }
1540
1541 async search(a: { workspace: string; viewer: Viewer; query: DocSearchQuery }): Promise<Result<DocSearchHit[]>> {
1542 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1543 if (!found.ok) return found;
1544 const workspace = found.value;
1545 const query = a.query ?? { query: "" };
1546 const spaces = (await this.spacesFor(workspace, a.viewer!)).filter((s) => s.role);
1547 const hybrid = query.mode === "hybrid" && !!ftsQuery(query.query);
1548 // A project's docs, when the search isn't narrowed to one of the workspace's spaces.
1549 const repoSpaces = query.space_id || !ftsQuery(query.query)
1550 ? []
1551 : await this.repoSpacesMatching(workspace, a.viewer!, query).catch((error: unknown) => {
1552 console.error("docs could not list projects' docs for search", String(error));
1553 return [] as { row: RepoSpaceRow; repo: Repo }[];
1554 });
1555 const [pages, files, meaning] = await Promise.all([
1556 this.searchIn(workspace, spaces, query),
1557 this.searchRepoFiles(workspace, repoSpaces, query).catch((error: unknown) => {
1558 console.error("docs could not search projects' docs", String(error));
1559 return [] as DocSearchHit[];
1560 }),
1561 hybrid
1562 ? this.meaningHits(workspace, spaces, repoSpaces, query).catch((error: unknown) => {
1563 console.error("docs could not search by meaning", String(error));
1564 return null;
1565 })
1566 : Promise.resolve(null),
1567 ]);
1568 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1569 // Words only: pages first, then files, as many as asked for.
1570 if (!hybrid) return ok([...pages, ...files].slice(0, limit));
1571 return ok(await this.fuseHits(workspace, spaces, repoSpaces, [...pages, ...files], meaning ?? [], query, limit));
1572 }
1573
1574 /** The projects' docs the viewer can read, narrowed to the search's project. */
1575 private async repoSpacesMatching(workspace: Workspace, viewer: User, query: DocSearchQuery): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
1576 const spaces = await this.readableRepoSpaces(workspace, viewer);
1577 const project = query.project ? projectRef(query.project) : null;
1578 return project ? spaces.filter((s) => `${s.repo.namespace}/${s.repo.name}`.toLowerCase() === project) : spaces;
1579 }
1580
1581 /** A search hit's key: a page's id, or `repo:<space>:<path>` for a project's docs file. */
1582 private hitKey(row: Pick<PassageRow, "page_id" | "space_id" | "path">): string {
1583 return row.page_id ?? `repo:${row.space_id}:${row.path}`;
1584 }
1585
1586 /** By meaning: each page's or file's closest passage above the floor, closest first. */
1587 private async meaningHits(workspace: Workspace, spaces: Space[], repoSpaces: { row: RepoSpaceRow }[], query: DocSearchQuery): Promise<{ key: string; row: PassageRow; score: number }[]> {
1588 const allowed = [...searchSpaces(spaces.map((s) => s.row.id), query.space_id ?? null), ...repoSpaces.map((r) => r.row.id)];
1589 if (!allowed.length) return [];
1590 const vector = await this.queryVector(query.query);
1591 if (!vector) return [];
1592 const matches = (await this.meaningMatches(workspace.id, allowed, vector)).filter((m) => m.score >= MEANING_FLOOR);
1593 const rows = await this.passages(workspace.id, matches.map((m) => m.id));
1594 const may = new Set(allowed);
1595 const best = new Map<string, { key: string; row: PassageRow; score: number }>();
1596 for (const m of matches) {
1597 const row = rows.get(m.id);
1598 if (!row || !may.has(row.space_id)) continue;
1599 const key = this.hitKey(row);
1600 if ((best.get(key)?.score ?? -1) < m.score) best.set(key, { key, row, score: m.score });
1601 }
1602 return [...best.values()].sort((a, b) => b.score - a.score);
1603 }
1604
1605 /**
1606 * Hybrid search's answer: word hits and meaning hits fused by rank, each
1607 * with the passage that matched and its heading. Word hits get theirs
1608 * from the passages' full text; meaning-only hits show their passage.
1609 */
1610 private async fuseHits(
1611 workspace: Workspace,
1612 spaces: Space[],
1613 repoSpaces: { row: RepoSpaceRow; repo: Repo }[],
1614 words: DocSearchHit[],
1615 meaning: { key: string; row: PassageRow; score: number }[],
1616 query: DocSearchQuery,
1617 limit: number,
1618 ): Promise<DocSearchHit[]> {
1619 const order = fuseRanks(
1620 words.map((h) => h.id),
1621 meaning.map((m) => m.key),
1622 );
1623 const byWords = new Map(words.map((h) => [h.id, h]));
1624 const byMeaning = new Map(meaning.map((m) => [m.key, m]));
1625 // The passage each word hit matched in, for its heading and a closer snippet.
1626 const docIds = new Map(words.map((h) => [h.repo_file ? repoFileId(h.space_id, h.repo_file.path) : h.id, h.id]));
1627 const passageOf = new Map<string, { heading: string; snippet: string }>();
1628 const q = ftsQuery(query.query);
1629 if (q && docIds.size) {
1630 // The best-ranked 90, within D1's bound parameters.
1631 const ids = [...docIds.keys()].slice(0, 90);
1632 const found = await this.db
1633 .prepare(
1634 `SELECT doc_id, heading, snippet(doc_chunks_fts, 4, '[[', ']]', '…', 16) AS snippet FROM doc_chunks_fts
1635 WHERE doc_chunks_fts MATCH ? AND doc_id IN (${ids.map(() => "?").join(",")}) ORDER BY bm25(doc_chunks_fts, 0, 0, 0, 4.0, 1.0) LIMIT 200`,
1636 )
1637 .bind(q, ...ids)
1638 .all<{ doc_id: string; heading: string; snippet: string }>()
1639 .catch(() => ({ results: [] as { doc_id: string; heading: string; snippet: string }[] }));
1640 for (const r of found.results) {
1641 const key = docIds.get(r.doc_id);
1642 if (key && !passageOf.has(key)) passageOf.set(key, { heading: r.heading, snippet: r.snippet });
1643 }
1644 }
1645 // Meaning-only pages: their projects, for the project filter and the hit.
1646 const onlyMeaning = meaning.filter((m) => !byWords.has(m.key) && m.row.page_id);
1647 const pageIds = onlyMeaning.map((m) => m.row.page_id!);
1648 const projects = pageIds.length
1649 ? (
1650 await this.db
1651 .prepare(`SELECT page_id, repo FROM page_projects WHERE page_id IN (${pageIds.map(() => "?").join(",")})`)
1652 .bind(...pageIds)
1653 .all<{ page_id: string; repo: string }>()
1654 ).results
1655 : [];
1656 const project = query.project ? projectRef(query.project) : null;
1657 const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
1658 const byRepo = new Map(repoSpaces.map((r) => [r.row.id, r]));
1659 const out: DocSearchHit[] = [];
1660 for (const key of order) {
1661 if (out.length >= limit) break;
1662 const w = byWords.get(key);
1663 const m = byMeaning.get(key);
1664 if (w) {
1665 const passage = passageOf.get(key);
1666 out.push({
1667 ...w,
1668 snippet: passage?.snippet || w.snippet,
1669 heading: (passage ? passage.heading || null : null) ?? m?.row.heading ?? null,
1670 matched: m ? "both" : "words",
1671 });
1672 continue;
1673 }
1674 if (!m) continue;
1675 const row = m.row;
1676 const snippet = excerpt(row.text, 200);
1677 if (row.page_id) {
1678 const space = bySpace.get(row.space_id);
1679 if (!space) continue;
1680 const own = projects.filter((p) => p.page_id === row.page_id).map((p) => p.repo);
1681 if (!inProject(project, own, space.projects)) continue;
1682 out.push({
1683 ...this.ref(workspace.slug, space.row, { id: row.page_id, title: row.title ?? "", icon: row.icon }),
1684 space_name: space.row.name,
1685 snippet,
1686 updated_at: row.page_updated_at ?? row.updated_at,
1687 projects: [...new Set([...own, ...space.projects])],
1688 heading: row.heading,
1689 matched: "meaning",
1690 });
1691 } else {
1692 const r = byRepo.get(row.space_id);
1693 if (!r || !row.path) continue;
1694 const repo = `${r.repo.namespace}/${r.repo.name}`;
1695 out.push({
1696 id: key,
1697 space_id: row.space_id,
1698 space_slug: "repo",
1699 title: row.title ?? row.path,
1700 icon: null,
1701 slug: row.path,
1702 path: `/${workspace.slug}/-/docs/repo/${repo}/${row.path.split("/").map(encodeURIComponent).join("/")}`,
1703 space_name: repo,
1704 snippet,
1705 updated_at: r.row.indexed_at ?? r.row.added_at,
1706 projects: [repo.toLowerCase()],
1707 repo_file: { repo, path: row.path },
1708 heading: row.heading,
1709 matched: "meaning",
1710 });
1711 }
1712 }
1713 return out;
1714 }
1715
1716 /** Full text over these projects' docs (the viewer's to read). */
1717 private async searchRepoFiles(workspace: Workspace, spaces: { row: RepoSpaceRow; repo: Repo }[], query: DocSearchQuery): Promise<DocSearchHit[]> {
1718 const q = ftsQuery(query.query);
1719 if (!q) return [];
1720 if (!spaces.length) return [];
1721 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1722 const rows = (
1723 await this.db
1724 .prepare(
1725 `SELECT space_id, path, title, snippet(repo_files_fts, 3, '[[', ']]', '…', 16) AS snippet FROM repo_files_fts
1726 WHERE repo_files_fts MATCH ? AND space_id IN (${spaces.map(() => "?").join(",")}) ORDER BY bm25(repo_files_fts, 0, 0, 8.0, 1.0) LIMIT ?`,
1727 )
1728 .bind(q, ...spaces.map((s) => s.row.id), limit)
1729 .all<{ space_id: string; path: string; title: string; snippet: string }>()
1730 ).results;
1731 const byId = new Map(spaces.map((s) => [s.row.id, s]));
1732 return rows.map((r) => {
1733 const s = byId.get(r.space_id)!;
1734 const repo = `${s.repo.namespace}/${s.repo.name}`;
1735 return {
1736 id: `repo:${r.space_id}:${r.path}`,
1737 space_id: r.space_id,
1738 space_slug: "repo",
1739 title: r.title,
1740 icon: null,
1741 slug: r.path,
1742 path: `/${workspace.slug}/-/docs/repo/${repo}/${r.path.split("/").map(encodeURIComponent).join("/")}`,
1743 space_name: repo,
1744 snippet: r.snippet,
1745 updated_at: s.row.indexed_at ?? s.row.added_at,
1746 projects: [repo.toLowerCase()],
1747 repo_file: { repo, path: r.path },
1748 };
1749 });
1750 }
1751
1752 // ── History ─────────────────────────────────────────────────────────────
1753
1754 private async toVersions(workspace: Workspace, rows: Omit<VersionRow, "markdown" | "state">[]): Promise<DocVersion[]> {
1755 const authors = rows.map((r) => {
1756 try {
1757 return JSON.parse(r.authors) as string[];
1758 } catch {
1759 return [];
1760 }
1761 });
1762 const people = await this.profiles(workspace, authors.flat());
1763 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) }));
1764 }
1765
1766 async versions(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocVersion[]>> {
1767 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1768 if (!found.ok) return found;
1769 // Whatever is unsaved goes in first, so the newest version is now.
1770 await this.room(a.page_id)
1771 .flush()
1772 .catch(() => undefined);
1773 const rows = (
1774 await this.db
1775 .prepare("SELECT id, page_id, created_at, kind, authors, note FROM page_versions WHERE page_id = ? ORDER BY created_at DESC LIMIT 200")
1776 .bind(a.page_id)
1777 .all<Omit<VersionRow, "markdown" | "state">>()
1778 ).results;
1779 return ok(await this.toVersions(found.value.workspace, rows));
1780 }
1781
1782 async version(a: { workspace: string; page_id: string; version_id: string; viewer: Viewer }): Promise<Result<DocVersionDetail>> {
1783 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1784 if (!found.ok) return found;
1785 const row = await this.db
1786 .prepare("SELECT id, page_id, created_at, kind, authors, note, markdown FROM page_versions WHERE id = ? AND page_id = ?")
1787 .bind(a.version_id, a.page_id)
1788 .first<Omit<VersionRow, "state">>();
1789 if (!row) return fail("not_found", "No such version.");
1790 const before = await this.db
1791 .prepare("SELECT markdown FROM page_versions WHERE page_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT 1")
1792 .bind(a.page_id, row.created_at)
1793 .first<{ markdown: string }>();
1794 const [version] = await this.toVersions(found.value.workspace, [row]);
1795 return ok({ ...version!, markdown: row.markdown, diff: diffLines(before?.markdown ?? "", row.markdown) });
1796 }
1797
1798 async restoreVersion(a: { workspace: string; page_id: string; version_id: string; viewer: Viewer }): Promise<Result<DocVersion>> {
1799 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1800 if (!found.ok) return found;
1801 const { workspace, page } = found.value;
1802 const row = await this.db.prepare("SELECT * FROM page_versions WHERE id = ? AND page_id = ?").bind(a.version_id, page.id).first<VersionRow>();
1803 if (!row) return fail("not_found", "No such version.");
1804 const room = this.room(page.id);
1805 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1806 const when = new Date(row.created_at).toISOString().slice(0, 16).replace("T", " ");
1807 const origin: Origin = { key: this.userKey(a.viewer!), kind: "restore", note: `Restored the version of ${when} UTC` };
1808 const versionId = await room.restore({ state: row.state ? new Uint8Array(row.state) : null, markdown: row.markdown }, origin);
1809 const created = versionId
1810 ? 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">>()
1811 : null;
1812 if (!created) return fail("conflict", "The page could not be restored. Try again.");
1813 const [version] = await this.toVersions(workspace, [created]);
1814 this.tell(page.id, { type: "version.created", version: version! });
1815 return ok(version!);
1816 }
1817
1818 // ── Templates and export ────────────────────────────────────────────────
1819
1820 async templates(a: { workspace: string; viewer: Viewer }): Promise<Result<DocTemplate[]>> {
1821 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1822 if (!found.ok) return found;
1823 const rows = (
1824 await this.db
1825 .prepare("SELECT * FROM templates WHERE workspace_id = ? ORDER BY name COLLATE NOCASE")
1826 .bind(found.value.id)
1827 .all<{ id: string; name: string; description: string; icon: string; markdown: string; created_by: string }>()
1828 ).results;
1829 return ok([
1830 ...BUILTIN_TEMPLATES,
1831 ...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) })),
1832 ]);
1833 }
1834
1835 async saveTemplate(a: { workspace: string; viewer: Viewer; input: { page_id: string; name: string; description?: string | null } }): Promise<Result<DocTemplate>> {
1836 const found = await this.pageFor(a.workspace, a.input?.page_id, a.viewer, "view");
1837 if (!found.ok) return found;
1838 const { workspace, page } = found.value;
1839 const name = cleanTitle(a.input.name || page.title).slice(0, 80);
1840 if (!name) return fail("invalid", "Name the template.");
1841 const room = this.room(page.id);
1842 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1843 const { markdown } = await room.read();
1844 const id = newId("tpl");
1845 const description = String(a.input.description ?? "").trim().slice(0, 200);
1846 await this.db
1847 .prepare("INSERT INTO templates (id, workspace_id, name, description, icon, markdown, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
1848 .bind(id, workspace.id, name, description, page.icon ?? "📄", markdown, this.userKey(a.viewer!), now())
1849 .run();
1850 return ok({ id, name, description, icon: page.icon ?? "📄", markdown, builtin: false, created_by: { kind: "user", id: a.viewer!.id } });
1851 }
1852
1853 async deleteTemplate(a: { workspace: string; template_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1854 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1855 if (!found.ok) return found;
1856 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 }>();
1857 if (!row) return fail("not_found", "No such template.");
1858 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.");
1859 await this.db.prepare("DELETE FROM templates WHERE id = ?").bind(a.template_id).run();
1860 return ok(true);
1861 }
1862
1863 async exportPage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<{ filename: string; markdown: string }>> {
1864 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1865 if (!found.ok) return found;
1866 const { workspace, page } = found.value;
1867 const room = this.room(page.id);
1868 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1869 const { markdown } = await room.read();
1870 const title = page.title || "Untitled";
1871 return ok({ filename: `${title.replace(/[\\/:*?"<>|]+/g, " ").trim() || "page"}.md`, markdown: `# ${title}\n\n${markdown}` });
1872 }
1873
1874 async exportSpace(a: { workspace: string; space_id: string; viewer: Viewer }): Promise<Result<{ name: string; files: { path: string; markdown: string }[] }>> {
1875 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1876 if (!found.ok) return found;
1877 const spaces = await this.spacesFor(found.value, a.viewer!);
1878 const space = spaces.find((s) => s.row.id === a.space_id);
1879 if (!space?.role) return fail("not_found", "No such space.");
1880 const rows = (
1881 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>()
1882 ).results;
1883 const paths = exportPaths(rows.map((r) => ({ ...r, title: r.title || "Untitled" })));
1884 return ok({
1885 name: space.row.slug,
1886 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)),
1887 });
1888 }
1889
1890 // ── Suggestions ─────────────────────────────────────────────────────────
1891
1892 private async toSuggestions(workspace: Workspace, rows: SuggestionRow[], blocks: (string[] | null)[] = []): Promise<DocSuggestion[]> {
1893 const people = await this.profiles(
1894 workspace,
1895 rows.flatMap((r) => [r.author, r.asked_by, r.decided_by].filter((k): k is string => !!k)),
1896 );
1897 return rows.map((r, i) => ({
1898 id: r.id,
1899 page_id: r.page_id,
1900 author: people.get(r.author)!,
1901 asked_by: r.asked_by ? (people.get(r.asked_by) ?? null) : null,
1902 target: JSON.parse(r.target) as DocEditTarget,
1903 before_markdown: r.before_markdown,
1904 after_markdown: r.after_markdown,
1905 note: r.note,
1906 status: r.status,
1907 created_at: r.created_at,
1908 decided_by: r.decided_by ? (people.get(r.decided_by) ?? null) : null,
1909 decided_at: r.decided_at,
1910 block_ids: blocks[i] ?? [],
1911 }));
1912 }
1913
1914 /** A page's open suggestions, with the blocks each covers now; ones whose target is gone become stale. */
1915 private async openSuggestions(workspace: Workspace, pageId: string): Promise<DocSuggestion[]> {
1916 const rows = (await this.db.prepare("SELECT * FROM suggestions WHERE page_id = ? AND status = 'open' ORDER BY created_at").bind(pageId).all<SuggestionRow>()).results;
1917 if (!rows.length) return [];
1918 let blocks: (string[] | null)[] = rows.map(() => []);
1919 try {
1920 blocks = await this.room(pageId).targets(rows.map((r) => JSON.parse(r.target) as DocEditTarget));
1921 } catch (error) {
1922 console.error("docs could not place suggestions", error);
1923 }
1924 const gone = rows.filter((_, i) => blocks[i] === null);
1925 if (gone.length) {
1926 await this.db.batch(gone.map((r) => this.db.prepare("UPDATE suggestions SET status = 'stale' WHERE id = ?").bind(r.id)));
1927 }
1928 const live = rows.map((r, i) => ({ r, b: blocks[i] })).filter((x) => x.b !== null);
1929 return this.toSuggestions(
1930 workspace,
1931 live.map((x) => x.r),
1932 live.map((x) => x.b!),
1933 );
1934 }
1935
1936 async suggestions(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocSuggestion[]>> {
1937 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1938 if (!found.ok) return found;
1939 return ok(await this.openSuggestions(found.value.workspace, a.page_id));
1940 }
1941
1942 async decideSuggestion(a: { workspace: string; suggestion_id: string; viewer: Viewer; decision: "accept" | "reject" }): Promise<Result<DocSuggestion>> {
1943 const row = await this.db.prepare("SELECT * FROM suggestions WHERE id = ?").bind(String(a.suggestion_id ?? "")).first<SuggestionRow>();
1944 if (!row) return fail("not_found", "No such suggestion.");
1945 const found = await this.pageFor(a.workspace, row.page_id, a.viewer, "edit");
1946 if (!found.ok) return found.error.code === "forbidden" ? fail("forbidden", "Only people who can edit the page can accept or reject a suggestion.") : found;
1947 const { workspace, page } = found.value;
1948 if (row.status !== "open") return fail("conflict", "That suggestion was already decided.");
1949 const me = this.userKey(a.viewer!);
1950 let status: DocSuggestion["status"] = a.decision === "accept" ? "accepted" : "rejected";
1951 if (a.decision === "accept") {
1952 const people = await this.profiles(workspace, [row.author, me]);
1953 const room = this.room(page.id);
1954 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1955 const result = await room.edit(JSON.parse(row.target) as DocEditTarget, row.after_markdown, {
1956 key: me,
1957 kind: "suggestion",
1958 note: `Suggested by @${people.get(row.author)!.name}, accepted by @${people.get(me)!.name}`,
1959 authors: [row.author, me],
1960 });
1961 if (!result.applied) status = "stale";
1962 else if (row.marks_current) await this.clearStale(page.id, row.author);
1963 }
1964 await this.db.prepare("UPDATE suggestions SET status = ?, decided_by = ?, decided_at = ? WHERE id = ?").bind(status, me, now(), row.id).run();
1965 const [after] = await this.toSuggestions(workspace, [{ ...row, status, decided_by: me, decided_at: now() }]);
1966 this.tell(page.id, { type: "suggestion.updated", suggestion: after! });
1967 if (status === "stale") return fail("conflict", "The part of the page this suggestion changes is gone, so it can't be applied.");
1968 return ok(after!);
1969 }
1970
1971 async acceptAll(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocSuggestion[]>> {
1972 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1973 if (!found.ok) return found;
1974 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;
1975 const out: DocSuggestion[] = [];
1976 for (const r of rows) {
1977 const decided = await this.decideSuggestion({ workspace: a.workspace, suggestion_id: r.id, viewer: a.viewer, decision: "accept" });
1978 if (decided.ok) out.push(decided.value);
1979 }
1980 return ok(out);
1981 }
1982
1983 // ── Comments ────────────────────────────────────────────────────────────
1984
1985 async thread(a: { workspace: string; page_id: string; viewer: Viewer; action: DocThreadAction }): Promise<Result<unknown>> {
1986 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "comment");
1987 if (!found.ok) return found;
1988 const { workspace, page, space } = found.value;
1989 const me = this.userKey(a.viewer!);
1990 const room = this.room(page.id);
1991 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1992 const result = (await room.thread(me, space.role!, a.action)) as ThreadResult;
1993 if (!result.ok) return fail(result.code, result.message);
1994 if (result.mentions?.length) this.defer(this.notifyMentioned(workspace, page, space.row, me, result.mentions, result.text ?? "", result.thread_id ?? null));
1995 return ok(result.value);
1996 }
1997
1998 /** People mentioned in a comment hear of it, if they can read the page. */
1999 private async notifyMentioned(workspace: Workspace, page: PageRow, space: SpaceRow, author: string, mentions: string[], text: string, threadId: string | null): Promise<void> {
2000 if (!this.env.NOTIFY) return;
2001 // Comment mentions name people by username (`user:<username>`).
2002 const me = author.startsWith("user:") ? (this.usernames.get(author.slice(5)) ?? "").toLowerCase() : "";
2003 const names = [...new Set(mentions.filter((k) => k.startsWith("user:")).map((k) => k.slice(5).toLowerCase()))].filter((n) => n && n !== me);
2004 if (!names.length) return;
2005 const [spaces, people] = await Promise.all([this.allSpaces(workspace), this.profiles(workspace, [author])]);
2006 const s = spaces.find((x) => x.row.id === space.id);
2007 if (!s) return;
2008 await this.nameUsers(s.members.filter((m) => m.principal.startsWith("user:")).map((m) => m.principal.slice(5)));
2009 const teams = this.needsTeams([s]) ? await this.teamsOf(workspace) : new Map<string, Set<string>>();
2010 const members = await this.members(workspace);
2011 const who = people.get(author)!;
2012 const href = `${this.ref(workspace.slug, space, page).path}${threadId ? `?thread=${encodeURIComponent(threadId)}` : ""}`;
2013 const notify = notifyClient(this.env.NOTIFY);
2014 await Promise.all(
2015 names.map(async (username) => {
2016 const member = members.get(username);
2017 if (!member) return;
2018 // Their id is not needed: access by username's teams and role is enough to decide.
2019 const person: Person = { user_id: `name:${username}`, owner: member.role === "owner", teams: teams.get(username) ?? new Set() };
2020 const listed = s.members.some((m) => m.principal.startsWith("user:") && this.usernames.get(m.principal.slice(5))?.toLowerCase() === username);
2021 if (!listed && !atLeast(roleOf(rulesOf({ ...s, role: null }), person), "view")) return;
2022 await notify
2023 .notify(
2024 { username },
2025 {
2026 id: `doc-comment:${page.id}:${threadId ?? ""}:${username}:${Date.now()}`,
2027 kind: "mention",
2028 workspace: workspace.slug,
2029 title: `${who.display_name} mentioned you on ${page.title || "Untitled"}`,
2030 body: text.slice(0, 140),
2031 href,
2032 actor: { kind: who.kind, id: who.id, name: who.display_name, avatar: who.avatar, avatar_seed: who.avatar_seed ?? null },
2033 created_at: now(),
2034 },
2035 )
2036 .catch(() => undefined);
2037 }),
2038 );
2039 }
2040
2041 private async resolveThreads(workspace: Workspace, threads: Awaited<ReturnType<PageRoom["threads"]>>): Promise<DocThread[]> {
2042 const people = await this.profiles(
2043 workspace,
2044 threads.flatMap((t) => t.comments.map((c) => c.author)),
2045 );
2046 return threads.map((t) => ({ ...t, comments: t.comments.map((c) => ({ ...c, author: people.get(c.author)! })) }));
2047 }
2048
2049 async threads(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocThread[]>> {
2050 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
2051 if (!found.ok) return found;
2052 return ok(await this.resolveThreads(found.value.workspace, await this.room(a.page_id).threads()));
2053 }
2054
2055 // ── Agents ──────────────────────────────────────────────────────────────
2056
2057 /**
2058 * What an agent may read and do for `viewer`, space by space: the
2059 * viewer's own access, narrowed to what every person in the audience can
2060 * read. Never wider than the viewer.
2061 */
2062 private async agentSpaces(slug: string, agentId: string, viewer: Viewer, audience: DocAudience | null): Promise<Result<{ workspace: Workspace; agent: WorkspaceAgent; spaces: (Space & { can: DocAgentAbilities })[] }>> {
2063 const found = await this.viewerWorkspace(slug, viewer);
2064 if (!found.ok) return found;
2065 const workspace = found.value;
2066 const agent = (await this.agentsById([String(agentId ?? "")])).get(String(agentId ?? ""));
2067 if (!agent || agent.workspace_id !== workspace.id || agent.archived_at) return fail("not_found", "No such agent.");
2068 const spaces = await this.spacesFor(workspace, viewer!);
2069 let readable = (s: Space) => !!s.role;
2070 if (audience?.kind === "workspace") {
2071 readable = (s) => !!s.role && readableByWorkspace(rulesOf(s));
2072 } else if (audience?.kind === "people" && Array.isArray(audience.user_ids) && audience.user_ids.length) {
2073 const ids = [...new Set(audience.user_ids.map(String))].slice(0, 200);
2074 await this.nameUsers(ids);
2075 const [members, teams] = await Promise.all([this.members(workspace), this.needsTeams(spaces) ? this.teamsOf(workspace) : Promise.resolve(new Map<string, Set<string>>())]);
2076 const people: Person[] = ids.map((id) => {
2077 const username = this.usernames.get(id)?.toLowerCase() ?? "";
2078 const member = members.get(username);
2079 // Someone who is not a member reads nothing: a person with no teams who owns nothing.
2080 return { user_id: member ? id : `outside:${id}`, owner: member?.role === "owner", teams: teams.get(username) ?? new Set() };
2081 });
2082 readable = (s) => !!s.role && readableByAll(rulesOf(s), people);
2083 }
2084 return ok({
2085 workspace,
2086 agent,
2087 spaces: spaces.filter(readable).map((s) => ({ ...s, can: agentAbilities(s.role, s.row.agent_mode) })),
2088 });
2089 }
2090
2091 async spacesForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; audience: DocAudience | null }) {
2092 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
2093 if (!found.ok) return found;
2094 return ok(
2095 found.value.spaces.map((s) => ({
2096 id: s.row.id,
2097 slug: s.row.slug,
2098 name: s.row.name,
2099 description: s.row.description,
2100 kind: s.row.kind,
2101 agent_mode: s.row.agent_mode,
2102 projects: s.projects,
2103 can: s.can,
2104 })),
2105 );
2106 }
2107
2108 /** A page an agent may read for the viewer and audience, or not found. */
2109 private async agentPage(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience?: DocAudience | null }) {
2110 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience ?? null);
2111 if (!found.ok) return found;
2112 const page = await this.db
2113 .prepare("SELECT * FROM pages WHERE id = ? AND workspace_id = ? AND archived_at IS NULL")
2114 .bind(String(a.page_id ?? ""), found.value.workspace.id)
2115 .first<PageRow>();
2116 const space = page ? found.value.spaces.find((s) => s.row.id === page.space_id) : null;
2117 if (!page || !space) return fail("not_found", "No such page.");
2118 return ok({ ...found.value, page, space });
2119 }
2120
2121 async pageMarkdown(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience: DocAudience | null }): Promise<Result<DocAgentPage>> {
2122 const found = await this.agentPage(a);
2123 if (!found.ok) return found;
2124 const { workspace, page, space } = found.value;
2125 const room = this.room(page.id);
2126 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2127 const read = await room.read();
2128 return ok({
2129 page: { ...this.ref(workspace.slug, space.row, page), updated_at: page.updated_at },
2130 space: { id: space.row.id, slug: space.row.slug, name: space.row.name, agent_mode: space.row.agent_mode },
2131 markdown: read.markdown,
2132 blocks: read.blocks,
2133 can: space.can,
2134 });
2135 }
2136
2137 async searchForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; query: DocSearchQuery; audience: DocAudience | null }): Promise<Result<DocSearchHit[]>> {
2138 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
2139 if (!found.ok) return found;
2140 return ok(await this.searchIn(found.value.workspace, found.value.spaces, { ...(a.query ?? { query: "" }), limit: Math.min(Number(a.query?.limit) || 10, 20) }));
2141 }
2142
2143 // ── Recall: the semantic index ──────────────────────────────────────────
2144
2145 /** Pages indexed again one after another (moved, restored). */
2146 private async reindexPages(ids: string[]): Promise<void> {
2147 for (const id of ids.slice(0, 500)) await indexPage(this.env, id);
2148 }
2149
2150 /** A query's embedding, kept a minute; null without an embedder or when it fails (then words only). */
2151 private async queryVector(query: string): Promise<number[] | null> {
2152 const { embedder } = adapters(this.env);
2153 const key = queryKey(query);
2154 if (!embedder || !key) return null;
2155 const cached = queryVectors.get(key);
2156 if (cached) return cached;
2157 try {
2158 const [vector] = await embedder.embed([key]);
2159 if (vector) queryVectors.set(key, vector);
2160 return vector ?? null;
2161 } catch (error) {
2162 console.error("docs could not embed a query; matching words instead", String(error));
2163 return null;
2164 }
2165 }
2166
2167 /** The passages nearest a vector, only from `allowed` spaces (by the index's filter, or after). */
2168 private async meaningMatches(workspaceId: string, allowed: string[], vector: number[]): Promise<{ id: string; score: number }[]> {
2169 const { store } = adapters(this.env);
2170 const plan = vectorQueryPlan(workspaceId, allowed);
2171 if (!store || !plan) return [];
2172 try {
2173 return await store.query(vector, { topK: plan.topK, filter: plan.filter });
2174 } catch (error) {
2175 console.error("docs semantic query failed; matching words instead", String(error));
2176 return [];
2177 }
2178 }
2179
2180 /** Passages by their words (any of them), best first, from `allowed` spaces. */
2181 private async wordMatches(workspaceId: string, allowed: string[], fts: string, limit: number): Promise<string[]> {
2182 if (!allowed.length) return [];
2183 const named = allowed.length <= 80;
2184 const rows = await this.db
2185 .prepare(
2186 `SELECT doc_chunks_fts.chunk_id AS id FROM doc_chunks_fts JOIN doc_chunks c ON c.id = doc_chunks_fts.chunk_id
2187 WHERE doc_chunks_fts MATCH ? AND c.workspace_id = ? ${named ? `AND doc_chunks_fts.space_id IN (${allowed.map(() => "?").join(",")})` : ""}
2188 ORDER BY bm25(doc_chunks_fts, 0, 0, 0, 4.0, 1.0) LIMIT ?`,
2189 )
2190 .bind(fts, workspaceId, ...(named ? allowed : []), limit)
2191 .all<{ id: string }>()
2192 .catch((error: unknown) => {
2193 console.error("docs word recall failed", String(error));
2194 return { results: [] as { id: string }[] };
2195 });
2196 return rows.results.map((r) => r.id);
2197 }
2198
2199 /**
2200 * Passages by id as they read now, with their page or file: only those
2201 * whose page is still out of the trash and whose file is still there.
2202 * A page's passages count as in the page's space now, whatever the index says.
2203 */
2204 private async passages(workspaceId: string, ids: string[]): Promise<Map<string, PassageRow>> {
2205 const out = new Map<string, PassageRow>();
2206 const unique = [...new Set(ids)];
2207 for (let i = 0; i < unique.length; i += 90) {
2208 const part = unique.slice(i, i + 90);
2209 const rows = await this.db
2210 .prepare(
2211 `SELECT c.id, c.page_id, c.repo_file_id, c.path, c.heading, c.text, c.updated_at,
2212 CASE WHEN c.page_id IS NOT NULL THEN p.space_id ELSE c.space_id END AS space_id,
2213 COALESCE(p.title, f.title) AS title, p.icon AS icon, p.updated_at AS page_updated_at
2214 FROM doc_chunks c
2215 LEFT JOIN pages p ON p.id = c.page_id
2216 LEFT JOIN repo_files f ON f.space_id = c.space_id AND f.path = c.path
2217 WHERE c.workspace_id = ? AND c.id IN (${part.map(() => "?").join(",")})
2218 AND ((c.page_id IS NOT NULL AND p.id IS NOT NULL AND p.archived_at IS NULL) OR (c.repo_file_id IS NOT NULL AND f.path IS NOT NULL))`,
2219 )
2220 .bind(workspaceId, ...part)
2221 .all<PassageRow>();
2222 for (const r of rows.results) out.set(r.id, r);
2223 }
2224 return out;
2225 }
2226
2227 /**
2228 * Projects' docs an agent may recall from: those the viewer can read
2229 * and, with an audience, everyone in it. A workspace-wide audience, or
2230 * one too large to ask about person by person, gets public repositories
2231 * only. Never wider than the viewer.
2232 */
2233 private async repoSpacesForAudience(workspace: Workspace, viewer: User, audience: DocAudience | null): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
2234 const mine = await this.readableRepoSpaces(workspace, viewer);
2235 if (!mine.length || !audience) return mine;
2236 const publicOnly = () => mine.filter((s) => !s.repo.isPrivate);
2237 if (audience.kind === "workspace") return publicOnly();
2238 if (audience.kind !== "people" || !Array.isArray(audience.user_ids)) return mine;
2239 const others = [...new Set(audience.user_ids.map(String))].filter((id) => id !== viewer.id);
2240 if (!others.length) return mine;
2241 if (others.length > 20 || !this.env.REPOS) return publicOnly();
2242 await this.nameUsers(others);
2243 const members = await this.members(workspace);
2244 let keep = new Set(mine.map((s) => s.row.repo_id));
2245 for (const id of others) {
2246 const username = this.usernames.get(id)?.toLowerCase();
2247 if (!username) {
2248 const open = new Set(publicOnly().map((s) => s.row.repo_id));
2249 keep = new Set([...keep].filter((r) => open.has(r)));
2250 continue;
2251 }
2252 const member = members.get(username);
2253 // As repos sees them: their membership here and no direct grants, so never wider than they are.
2254 const person: User = { id, username, verified: true, workspaces: member ? [{ slug: workspace.slug, role: member.role }] : [] };
2255 const readable = await reposClient(this.env.REPOS)
2256 .readable([...keep], person)
2257 .catch(() => [] as Repo[]);
2258 keep = new Set(readable.map((r) => r.id));
2259 if (!keep.size) break;
2260 }
2261 return mine.filter((s) => keep.has(s.row.repo_id));
2262 }
2263
2264 /**
2265 * What the workspace's Docs say about a query, for an agent about to
2266 * answer (DocsApi.recallForAgent): the closest passages by meaning above
2267 * MEANING_FLOOR, required spaces first, at most two per page, filled with
2268 * passages matching its words when meaning finds too few. Only from what
2269 * the viewer and audience can all read, by the same rules as every other
2270 * agent read (`agentSpaces`).
2271 */
2272 async recallForAgent(a: {
2273 workspace: string;
2274 agent_id: string;
2275 viewer: Viewer;
2276 query: string;
2277 limit?: number | null;
2278 spaces?: string[] | null;
2279 audience: DocAudience | null;
2280 }): Promise<Result<DocPassage[]>> {
2281 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience ?? null);
2282 if (!found.ok) return found;
2283 const { workspace, spaces } = found.value;
2284 this.defer(ensureIndexed(this.env, workspace.id).catch((error: unknown) => console.error("docs could not start indexing", workspace.id, String(error))));
2285 const query = String(a.query ?? "").trim().slice(0, 2000);
2286 if (!query) return ok([]);
2287 const limit = recallLimit(a.limit);
2288 const repoSpaces = await this.repoSpacesForAudience(workspace, a.viewer!, a.audience ?? null).catch((error: unknown) => {
2289 console.error("docs could not check projects' docs for recall", String(error));
2290 return [] as { row: RepoSpaceRow; repo: Repo }[];
2291 });
2292 const allowed = [...spaces.map((s) => s.row.id), ...repoSpaces.map((r) => r.row.id)];
2293 if (!allowed.length) return ok([]);
2294 const required = requiredSpaces(allowed, a.spaces);
2295 const fts = ftsAnyQuery(query);
2296 const vector = await this.queryVector(query);
2297 const [meaning, requiredMeaning, words] = await Promise.all([
2298 vector ? this.meaningMatches(workspace.id, allowed, vector) : Promise.resolve([]),
2299 // Required reading asked on its own too, so the rest of the workspace can't crowd it out.
2300 vector && required.length && required.length < allowed.length ? this.meaningMatches(workspace.id, required, vector) : Promise.resolve([]),
2301 fts ? this.wordMatches(workspace.id, allowed, fts, 30) : Promise.resolve([] as string[]),
2302 ]);
2303 const scores = new Map<string, number>();
2304 for (const m of [...meaning, ...requiredMeaning]) scores.set(m.id, Math.max(scores.get(m.id) ?? 0, m.score));
2305 const rows = await this.passages(workspace.id, [...scores.keys(), ...words]);
2306 const candidates: (Candidate & { row: PassageRow })[] = [];
2307 for (const [id, score] of scores) {
2308 const row = rows.get(id);
2309 if (row) candidates.push({ id, doc_id: row.page_id ?? row.repo_file_id!, space_id: row.space_id, score, by: "meaning", row });
2310 }
2311 for (const id of words) {
2312 const row = rows.get(id);
2313 if (row) candidates.push({ id, doc_id: row.page_id ?? row.repo_file_id!, space_id: row.space_id, score: WORDS_SCORE, by: "words", row });
2314 }
2315 const picked = pickPassages(candidates, { allowed: new Set(allowed), required, limit });
2316 const stale = await this.staleIds([...new Set(picked.map((c) => c.row.page_id).filter((id): id is string => !!id))]);
2317 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
2318 const byRepo = new Map(repoSpaces.map((r) => [r.row.id, r]));
2319 const out: DocPassage[] = [];
2320 for (const c of picked) {
2321 const row = c.row;
2322 const score = Math.round(c.score * 1000) / 1000;
2323 if (row.page_id) {
2324 const space = bySpace.get(row.space_id);
2325 if (!space) continue;
2326 out.push({
2327 page: this.ref(workspace.slug, space, { id: row.page_id, title: row.title ?? "", icon: row.icon }),
2328 repo_file: null,
2329 space_name: space.name,
2330 heading: row.heading,
2331 text: row.text,
2332 score,
2333 updated_at: row.page_updated_at ?? row.updated_at,
2334 stale: stale.has(row.page_id),
2335 });
2336 } else {
2337 const repo = byRepo.get(row.space_id);
2338 if (!repo || !row.path) continue;
2339 const name = `${repo.repo.namespace}/${repo.repo.name}`;
2340 out.push({
2341 page: null,
2342 repo_file: { repo: name, path: row.path, href: `/${workspace.slug}/-/docs/repo/${name}/${row.path.split("/").map(encodeURIComponent).join("/")}` },
2343 space_name: name,
2344 heading: row.heading,
2345 text: row.text,
2346 score,
2347 updated_at: repo.row.indexed_at ?? row.updated_at,
2348 stale: false,
2349 });
2350 }
2351 }
2352 return ok(out);
2353 }
2354
2355 /** Indexes the workspace's pages and projects' docs again, on the queue. Owners only. */
2356 async reindexDocs(a: { workspace: string; viewer: Viewer }): Promise<Result<boolean>> {
2357 const found = await this.viewerWorkspace(a.workspace, a.viewer);
2358 if (!found.ok) return found;
2359 if (!this.viewerOwner(a.viewer!, a.workspace)) return fail("forbidden", "Only an owner can index the workspace's docs again.");
2360 return ok(await startBackfill(this.env, found.value.id, { force: true }));
2361 }
2362
2363 private async fileSuggestion(
2364 workspace: Workspace,
2365 page: PageRow,
2366 space: SpaceRow,
2367 agent: WorkspaceAgent,
2368 viewer: User,
2369 edit: { target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean },
2370 ): Promise<Result<DocSuggestion>> {
2371 const room = this.room(page.id);
2372 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2373 const current = await room.target(edit.target);
2374 if (!current) return fail("not_found", "That part of the page isn't there. Read the page again and target what is there now.");
2375 const id = newId("sug");
2376 const row: SuggestionRow = {
2377 id,
2378 page_id: page.id,
2379 author: principalKey({ kind: "agent", id: agent.id }),
2380 asked_by: this.userKey(viewer),
2381 target: JSON.stringify(edit.target),
2382 before_markdown: current.markdown,
2383 after_markdown: edit.markdown,
2384 note: edit.note,
2385 status: "open",
2386 created_at: now(),
2387 decided_by: null,
2388 decided_at: null,
2389 marks_current: edit.marks_current ? 1 : 0,
2390 };
2391 await this.db
2392 .prepare("INSERT INTO suggestions (id, page_id, author, asked_by, target, before_markdown, after_markdown, note, status, created_at, marks_current) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)")
2393 .bind(row.id, row.page_id, row.author, row.asked_by, row.target, row.before_markdown, row.after_markdown, row.note, row.created_at, row.marks_current)
2394 .run();
2395 const [suggestion] = await this.toSuggestions(workspace, [row], [current.block_ids]);
2396 this.tell(page.id, { type: "suggestion.created", suggestion: suggestion! });
2397 this.defer(room.announce(row.author, agent.display_name).catch(() => undefined));
2398 this.defer(this.notifyOwners(workspace, page, space, suggestion!));
2399 return ok(suggestion!);
2400 }
2401
2402 /** A page's owners (people) hear of a suggestion waiting for them. */
2403 private async notifyOwners(workspace: Workspace, page: PageRow, space: SpaceRow, suggestion: DocSuggestion): Promise<void> {
2404 if (!this.env.NOTIFY) return;
2405 const owners = (await this.db.prepare("SELECT principal FROM page_owners WHERE page_id = ?").bind(page.id).all<{ principal: string }>()).results
2406 .map((o) => o.principal)
2407 .filter((k) => k.startsWith("user:"))
2408 .map((k) => k.slice(5));
2409 if (!owners.length) return;
2410 const notify = notifyClient(this.env.NOTIFY);
2411 const href = this.ref(workspace.slug, space, page).path;
2412 await Promise.all(
2413 owners.map((id) =>
2414 notify
2415 .notify(
2416 { user_id: id },
2417 {
2418 id: `doc-suggestion:${suggestion.id}:${id}`,
2419 kind: "inbox",
2420 workspace: workspace.slug,
2421 title: `${suggestion.author.display_name} suggested a change to ${page.title || "Untitled"}`,
2422 body: suggestion.note ?? excerpt(suggestion.after_markdown, 140),
2423 href,
2424 actor: { kind: "agent", id: suggestion.author.id, name: suggestion.author.display_name, avatar: suggestion.author.avatar, avatar_seed: suggestion.author.avatar_seed ?? null },
2425 created_at: suggestion.created_at,
2426 },
2427 )
2428 .catch(() => undefined),
2429 ),
2430 );
2431 }
2432
2433 private cleanEdit(edit: unknown): Result<{ target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean }> {
2434 const e = (edit ?? {}) as { target?: unknown; markdown?: unknown; note?: unknown; marks_current?: unknown };
2435 const target = cleanTarget(e.target);
2436 if (!target) return fail("invalid", "Say what to change: append, document, a section by its heading, or blocks by id.");
2437 const markdown = String(e.markdown ?? "");
2438 if (markdown.length > MAX_MARKDOWN) return fail("invalid", "That edit is too long.");
2439 if (target.kind === "append" && !markdown.trim()) return fail("invalid", "Nothing to add.");
2440 return ok({ target, markdown, note: e.note ? String(e.note).trim().slice(0, MAX_NOTE) || null : null, marks_current: e.marks_current === true });
2441 }
2442
2443 async suggestEdit(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; edit: unknown }): Promise<Result<DocSuggestion>> {
2444 const edit = this.cleanEdit(a.edit);
2445 if (!edit.ok) return edit;
2446 const found = await this.agentPage(a);
2447 if (!found.ok) return found;
2448 const { workspace, agent, page, space } = found.value;
2449 if (!space.can.suggest) return fail("forbidden", `${a.viewer!.username} can only read this page, so no change can be suggested for them.`);
2450 return this.fileSuggestion(workspace, page, space.row, agent, a.viewer!, edit.value);
2451 }
2452
2453 async applyEdit(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; edit: unknown }): Promise<Result<DocAgentEditResult>> {
2454 const edit = this.cleanEdit(a.edit);
2455 if (!edit.ok) return edit;
2456 const found = await this.agentPage(a);
2457 if (!found.ok) return found;
2458 const { workspace, agent, page, space } = found.value;
2459 const ref = this.ref(workspace.slug, space.row, page);
2460 if (!space.can.edit) {
2461 if (!space.can.suggest) return fail("forbidden", `${a.viewer!.username} can only read this page, so it can't be changed for them.`);
2462 const suggestion = await this.fileSuggestion(workspace, page, space.row, agent, a.viewer!, edit.value);
2463 return suggestion.ok ? ok({ mode: "suggested", suggestion: suggestion.value, page: ref }) : suggestion;
2464 }
2465 const room = this.room(page.id);
2466 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2467 const result = await room.edit(edit.value.target, edit.value.markdown, {
2468 key: principalKey({ kind: "agent", id: agent.id }),
2469 kind: "agent",
2470 note: edit.value.note ? `@${agent.handle} for @${a.viewer!.username}: ${edit.value.note}` : `@${agent.handle} for @${a.viewer!.username}`,
2471 authors: [principalKey({ kind: "agent", id: agent.id })],
2472 });
2473 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.");
2474 if (edit.value.marks_current) await this.clearStale(page.id, principalKey({ kind: "agent", id: agent.id }));
2475 this.defer(room.announce(principalKey({ kind: "agent", id: agent.id }), agent.display_name).catch(() => undefined));
2476 return ok({ mode: "applied", version_id: result.version_id, page: ref });
2477 }
2478
2479 async createPageAsAgent(a: {
2480 workspace: string;
2481 agent_id: string;
2482 viewer: Viewer;
2483 input: { space_id?: string | null; parent_id?: string | null; title: string; icon?: string | null; markdown: string; source?: { title: string; href: string } | null };
2484 }): Promise<Result<DocPageRef>> {
2485 const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, null);
2486 if (!found.ok) return found;
2487 const { workspace, agent, spaces } = found.value;
2488 const input = a.input ?? ({} as typeof a.input);
2489 const space = input.space_id ? spaces.find((s) => s.row.id === input.space_id) : spaces.find((s) => s.row.is_default);
2490 if (!space) return fail("not_found", "No such space.");
2491 if (!atLeast(space.role, "edit")) return fail("forbidden", `${a.viewer!.username} can't add pages to ${space.row.name}.`);
2492 const title = cleanTitle(input.title);
2493 if (!title) return fail("invalid", "Give the page a title.");
2494 let markdown = String(input.markdown ?? "").slice(0, MAX_MARKDOWN);
2495 const source = input.source && typeof input.source.href === "string" && input.source.href.startsWith("/") ? input.source : null;
2496 if (source) markdown = `> [!NOTE]\n> Written up from [${String(source.title || "a conversation").replace(/[[\]]/g, "")}](${source.href}).\n\n${markdown}`;
2497 const parent = input.parent_id
2498 ? 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 }>()
2499 : null;
2500 const agentKey = principalKey({ kind: "agent", id: agent.id });
2501 const row = await this.insertPage(workspace, space.row, agentKey, {
2502 parent_id: parent?.id ?? null,
2503 title,
2504 icon: cleanIcon(input.icon),
2505 markdown,
2506 owners: [this.userKey(a.viewer!)],
2507 });
2508 return ok(this.ref(workspace.slug, space.row, row));
2509 }
2510
2511 async threadsForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience: DocAudience | null }): Promise<Result<DocThread[]>> {
2512 const found = await this.agentPage(a);
2513 if (!found.ok) return found;
2514 return ok(await this.resolveThreads(found.value.workspace, await this.room(found.value.page.id).threads()));
2515 }
2516
2517 // ── Sockets and files ───────────────────────────────────────────────────
2518
2519 private viewerFrom(request: Request): Viewer {
2520 try {
2521 return JSON.parse(request.headers.get(DOCS_VIEWER_HEADER) ?? "null") as Viewer;
2522 } catch {
2523 return null;
2524 }
2525 }
2526
2527 /**
2528 * `GET /live?workspace=<slug>&page=<id>`, upgraded to a WebSocket. The
2529 * viewer comes in DOCS_VIEWER_HEADER, set by the site after checking the
2530 * session; trusted only because this Worker is reachable through service
2531 * bindings alone. Checked like any read, then handed to the page's room
2532 * with the viewer's role, which the room enforces.
2533 */
2534 async live(request: Request): Promise<Response> {
2535 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("Expected a WebSocket upgrade\n", { status: 426 });
2536 const viewer = this.viewerFrom(request);
2537 if (!viewer?.id) return new Response("Sign in to use Docs\n", { status: 401 });
2538 const url = new URL(request.url);
2539 const slug = (url.searchParams.get("workspace") ?? "").toLowerCase();
2540 const found = await this.pageFor(slug, url.searchParams.get("page") ?? "", viewer, "view");
2541 if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 });
2542 const { workspace, page, space } = found.value;
2543 if (page.archived_at) return new Response("That page is in the trash\n", { status: 410 });
2544 const room = this.room(page.id);
2545 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2546 const key = this.userKey(viewer);
2547 const who: RoomMember = { page_id: page.id, workspace_slug: workspace.slug, key, member: (await this.profiles(workspace, [key])).get(key)!, role: space.role! };
2548 const headers = new Headers(request.headers);
2549 headers.delete(DOCS_VIEWER_HEADER);
2550 headers.set(ROOM_MEMBER_HEADER, JSON.stringify(who));
2551 return room.fetch(new Request(request.url, { method: "GET", headers }));
2552 }
2553
2554 /** `PUT /files?workspace=&page=&name=`: a file for a page, from someone who can edit it. */
2555 async upload(request: Request): Promise<Response> {
2556 const viewer = this.viewerFrom(request);
2557 const url = new URL(request.url);
2558 const found = await this.pageFor((url.searchParams.get("workspace") ?? "").toLowerCase(), url.searchParams.get("page") ?? "", viewer, "edit");
2559 if (!found.ok) return Response.json(found);
2560 const bytes = Number(request.headers.get("content-length") ?? "0");
2561 if (!bytes || bytes > DOC_MAX_FILE_BYTES) return Response.json(fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`));
2562 const name = safeName(url.searchParams.get("name") ?? "file");
2563 const contentType = servedType(request.headers.get("content-type") ?? "");
2564 const random = crypto.getRandomValues(new Uint8Array(32));
2565 const key = [...random].map((b) => b.toString(16).padStart(2, "0")).join("");
2566 const id = newId("fil");
2567 await fileStore(this.env).put(`docs/${key}`, request.body ?? new Uint8Array(), contentType);
2568 await this.db
2569 .prepare("INSERT INTO files (id, workspace_id, page_id, key, name, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
2570 .bind(id, found.value.workspace.id, found.value.page.id, key, name, contentType, bytes, this.userKey(viewer!), now())
2571 .run();
2572 const file: DocFile = { id, url: `/docs-files/${key}`, name, content_type: contentType, bytes };
2573 return Response.json(ok(file));
2574 }
2575
2576 /**
2577 * `GET /files/<key>`: a page's file, for the usercontent origin. The key
2578 * is 256 random bits, so knowing it is the permission, as with any
2579 * shared link; it is never on the site's own origin.
2580 */
2581 async file(key: string): Promise<Response> {
2582 const row = await this.db.prepare("SELECT name, content_type FROM files WHERE key = ?").bind(key).first<{ name: string; content_type: string }>();
2583 if (!row) return new Response("Not found\n", { status: 404 });
2584 const stored = await fileStore(this.env).get(`docs/${key}`);
2585 if (!stored) return new Response("Not found\n", { status: 404 });
2586 const inline = row.content_type !== "application/octet-stream";
2587 return new Response(stored.body, {
2588 headers: {
2589 "content-type": row.content_type,
2590 "content-length": String(stored.bytes),
2591 etag: stored.etag,
2592 "content-disposition": `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(row.name)}`,
2593 "cache-control": "private, max-age=31536000, immutable",
2594 },
2595 });
2596 }
2597}
2598
2599/** One RPC method's answer. */
2600async function answer(service: Docs, method: string, args: any): Promise<Response> {
2601 switch (method) {
2602 case "sidebar":
2603 return Response.json(await service.sidebar(args));
2604 case "home":
2605 return Response.json(await service.home(args));
2606 case "space":
2607 return Response.json(await service.space(args));
2608 case "create_space":
2609 return Response.json(await service.createSpace(args));
2610 case "update_space":
2611 return Response.json(await service.updateSpace(args));
2612 case "set_space_member":
2613 return Response.json(await service.setSpaceMember(args));
2614 case "page":
2615 return Response.json(await service.page(args));
2616 case "create_page":
2617 return Response.json(await service.createPage(args));
2618 case "update_page":
2619 return Response.json(await service.updatePage(args));
2620 case "move_page":
2621 return Response.json(await service.movePage(args));
2622 case "duplicate_page":
2623 return Response.json(await service.duplicatePage(args));
2624 case "archive_page":
2625 return Response.json(await service.archivePage(args));
2626 case "restore_page":
2627 return Response.json(await service.restorePage(args));
2628 case "delete_page":
2629 return Response.json(await service.deletePage(args));
2630 case "trash":
2631 return Response.json(await service.trash(args));
2632 case "favorite":
2633 return Response.json(await service.favorite(args));
2634 case "search":
2635 return Response.json(await service.search(args));
2636 case "versions":
2637 return Response.json(await service.versions(args));
2638 case "version":
2639 return Response.json(await service.version(args));
2640 case "restore_version":
2641 return Response.json(await service.restoreVersion(args));
2642 case "templates":
2643 return Response.json(await service.templates(args));
2644 case "save_template":
2645 return Response.json(await service.saveTemplate(args));
2646 case "delete_template":
2647 return Response.json(await service.deleteTemplate(args));
2648 case "export_page":
2649 return Response.json(await service.exportPage(args));
2650 case "export_space":
2651 return Response.json(await service.exportSpace(args));
2652 case "suggestions":
2653 return Response.json(await service.suggestions(args));
2654 case "decide_suggestion":
2655 return Response.json(await service.decideSuggestion(args));
2656 case "accept_all":
2657 return Response.json(await service.acceptAll(args));
2658 case "thread":
2659 return Response.json(await service.thread(args));
2660 case "threads":
2661 return Response.json(await service.threads(args));
2662 case "spaces_for_agent":
2663 return Response.json(await service.spacesForAgent(args));
2664 case "page_markdown":
2665 return Response.json(await service.pageMarkdown(args));
2666 case "search_for_agent":
2667 return Response.json(await service.searchForAgent(args));
2668 case "suggest_edit":
2669 return Response.json(await service.suggestEdit(args));
2670 case "apply_edit":
2671 return Response.json(await service.applyEdit(args));
2672 case "create_page_as_agent":
2673 return Response.json(await service.createPageAsAgent(args));
2674 case "threads_for_agent":
2675 return Response.json(await service.threadsForAgent(args));
2676 case "stale_pages_for_agent":
2677 return Response.json(await service.stalePagesForAgent(args));
2678 case "mark_current":
2679 return Response.json(await service.markCurrent(args));
2680 case "stale_pages":
2681 return Response.json(await service.stalePages(args));
2682 case "add_repo_space":
2683 return Response.json(await service.addRepoSpace(args));
2684 case "remove_repo_space":
2685 return Response.json(await service.removeRepoSpace(args));
2686 case "repo_page":
2687 return Response.json(await service.repoPage(args));
2688 case "recall_for_agent":
2689 return Response.json(await service.recallForAgent(args));
2690 case "reindex_docs":
2691 return Response.json(await service.reindexDocs(args));
2692 default:
2693 return new Response("Unknown method\n", { status: 404 });
2694 }
2695}
2696
2697export default {
2698 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
2699 const url = new URL(request.url);
2700 const defer = (work: Promise<unknown>) => ctx.waitUntil(work);
2701 if (request.method === "GET" && url.pathname === "/live") return new Docs(env, defer).live(request);
2702 if (request.method === "PUT" && url.pathname === "/files") return new Docs(env, defer).upload(request);
2703 const file = /^\/files\/([0-9a-f]{64})$/.exec(url.pathname);
2704 if ((request.method === "GET" || request.method === "HEAD") && file) return new Docs(env, defer).file(file[1]!);
2705 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
2706 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
2707 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2708 const opened = openD1(env.DB, request);
2709 const service = new Docs(Object.create(env, { DB: { value: opened.db } }) as Env, defer);
2710 const args = (await request.json().catch(() => ({}))) as any;
2711 try {
2712 return opened.finish(await answer(service, match[1]!, args));
2713 } catch (error) {
2714 console.error("docs:", match[1], error);
2715 return opened.finish(Response.json(fail("conflict", "Docs couldn't do that just now. Try again.")));
2716 }
2717 },
2718
2719 /**
2720 * Events from the events service (SUBSCRIBER_DOCS): pages whose cited
2721 * code changed become possibly out of date, and projects' docs are read
2722 * again after a push (src/staleness.ts) and their passages indexed
2723 * (src/indexer.ts). The same queue carries this service's own backfill
2724 * jobs (`docs.index`). One failing message is retried on its own.
2725 */
2726 async queue(batch: MessageBatch<G1tEvent | DocsJob>, env: Env): Promise<void> {
2727 const reindex = async (repoId: string) => {
2728 if (env.REPOS) await reindexRepo({ DB: env.DB, REPOS: env.REPOS }, repoId, (spaceId, changed, gone) => indexRepoFiles(env, spaceId, changed, gone));
2729 };
2730 for (const message of batch.messages) {
2731 try {
2732 const body = message.body;
2733 if (body.type === "docs.index") {
2734 await runBackfill(env, (body as DocsJob).workspace_id);
2735 message.ack();
2736 continue;
2737 }
2738 if (body.type === "repo.purged") {
2739 // Its docs leave Docs (src/staleness.ts); their passages leave the index first.
2740 const gone = await env.DB.prepare("SELECT id FROM repo_spaces WHERE repo_id = ?").bind((body as G1tEvent<"repo.purged">).data.repoId).all<{ id: string }>();
2741 for (const space of gone.results) await forgetDocs(env, { space_id: space.id });
2742 }
2743 await onEvent(env, body as G1tEvent, reindex);
2744 message.ack();
2745 } catch (error) {
2746 console.error("docs could not handle", message.body?.type, String(error));
2747 message.retry();
2748 }
2749 }
2750 },
2751} satisfies ExportedHandler<Env, G1tEvent | DocsJob>;