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