Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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