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