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