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