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