Skip to content
2,303 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 return { key: m.principal, kind: p.kind, name: p.name, display_name: p.display_name, avatar: p.avatar, avatar_seed: p.avatar_seed ?? null, role: m.role };
789 })
790 .sort((x, y) => RANK[y.role] - RANK[x.role] || x.display_name.localeCompare(y.display_name));
791 }
792
793 async space(a: { workspace: string; space: string; viewer: Viewer }): Promise<Result<{ space: DocSpace; members: DocSpaceMember[]; pages: DocPage[] }>> {
794 const found = await this.viewerWorkspace(a.workspace, a.viewer);
795 if (!found.ok) return found;
796 const workspace = found.value;
797 const spaces = await this.spacesFor(workspace, a.viewer!, true);
798 const space = spaces.find((s) => s.row.slug === String(a.space ?? "").toLowerCase() || s.row.id === a.space);
799 if (!space?.role) return fail("not_found", "No such space.");
800 const rows = (
801 await this.db
802 .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")
803 .bind(space.row.id)
804 .all<PageRow>()
805 ).results;
806 const [members, pages] = await Promise.all([this.spaceMembers(workspace, space), this.toPages(workspace, new Map([[space.row.id, space.row]]), rows)]);
807 return ok({ space: this.toSpace(space, rows.length), members, pages });
808 }
809
810 /** Every space the viewer can open, archived ones included (the Spaces page lists those apart), General first. */
811 async spaces(a: { workspace: string; viewer: Viewer }): Promise<Result<DocSpace[]>> {
812 const found = await this.viewerWorkspace(a.workspace, a.viewer);
813 if (!found.ok) return found;
814 const spaces = await this.spacesFor(found.value, a.viewer!, true);
815 return ok(spaces.filter((s) => s.role).map((s) => this.toSpace(s)));
816 }
817
818 async createSpace(a: { workspace: string; viewer: Viewer; input: NewDocSpace }): Promise<Result<DocSpace>> {
819 const found = await this.viewerWorkspace(a.workspace, a.viewer);
820 if (!found.ok) return found;
821 const workspace = found.value;
822 const viewer = a.viewer!;
823 const input = a.input ?? ({} as NewDocSpace);
824 const name = cleanTitle(input.name).slice(0, 80);
825 if (!name) return fail("invalid", "Name the space.");
826 const kind: DocSpaceKind = input.kind === "team" || input.kind === "private" ? input.kind : "workspace";
827 const team = kind === "team" ? String(input.team ?? "").trim().toLowerCase() : null;
828 if (kind === "team") {
829 if (!team) return fail("invalid", "Choose the team the space is for.");
830 const teams = await this.teamsOf(workspace);
831 const known = [...teams.values()].some((set) => set.has(team));
832 if (!known) return fail("invalid", "No such team in this workspace.");
833 }
834 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));
835 let slug: string;
836 if (input.slug) {
837 const wanted = validSpaceSlug(input.slug);
838 if (!wanted) return fail("invalid", "A space's address is lowercase letters, numbers and hyphens.");
839 if (taken.has(wanted)) return fail("conflict", "Another space has that address.");
840 slug = wanted;
841 } else slug = freeSlug(name, taken);
842 const defaultRole: DocRole | null = kind === "private" ? null : isRole(input.default_role) ? input.default_role : "edit";
843 const id = newId("spc");
844 const at = now();
845 const me = this.userKey(viewer);
846 const projects = cleanProjects(input.projects);
847 await this.db.batch([
848 this.db
849 .prepare(
850 "INSERT INTO spaces (id, workspace_id, slug, name, description, icon, kind, team, default_role, agent_mode, is_default, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)",
851 )
852 .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),
853 // Whoever makes a space manages it.
854 this.db.prepare("INSERT INTO space_members (space_id, principal, role, added_by, added_at) VALUES (?, ?, 'manage', ?, ?)").bind(id, me, me, at),
855 ...projects.map((repo) => this.db.prepare("INSERT INTO space_projects (space_id, repo) VALUES (?, ?)").bind(id, repo)),
856 ]);
857 const spaces = await this.spacesFor(workspace, viewer);
858 const space = spaces.find((s) => s.row.id === id)!;
859 return ok(this.toSpace(space));
860 }
861
862 async updateSpace(a: { workspace: string; space_id: string; viewer: Viewer; change: DocSpaceChange }): Promise<Result<DocSpace>> {
863 const found = await this.viewerWorkspace(a.workspace, a.viewer);
864 if (!found.ok) return found;
865 const workspace = found.value;
866 const spaces = await this.spacesFor(workspace, a.viewer!, true);
867 const space = spaces.find((s) => s.row.id === a.space_id);
868 if (!space?.role) return fail("not_found", "No such space.");
869 if (!atLeast(space.role, "manage")) return fail("forbidden", "Only people with full access can change a space.");
870 const c = a.change ?? {};
871 const sets: string[] = [];
872 const values: unknown[] = [];
873 const set = (column: string, value: unknown) => {
874 sets.push(`${column} = ?`);
875 values.push(value);
876 };
877 if (c.name !== undefined) {
878 const name = cleanTitle(c.name).slice(0, 80);
879 if (!name) return fail("invalid", "Name the space.");
880 set("name", name);
881 }
882 if (c.description !== undefined) set("description", String(c.description ?? "").trim().slice(0, 300) || null);
883 if (c.icon !== undefined) set("icon", cleanIcon(c.icon));
884 if (c.slug !== undefined && c.slug !== space.row.slug) {
885 const wanted = validSpaceSlug(String(c.slug ?? ""));
886 if (!wanted) return fail("invalid", "A space's address is lowercase letters, numbers and hyphens.");
887 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();
888 if (clash) return fail("conflict", "Another space has that address.");
889 set("slug", wanted);
890 }
891 if (c.kind !== undefined && c.kind !== space.row.kind) {
892 if (space.row.is_default) return fail("invalid", "The General space is always the whole workspace's.");
893 if (c.kind !== "workspace" && c.kind !== "team" && c.kind !== "private") return fail("invalid", "Choose who the space is for.");
894 set("kind", c.kind);
895 if (c.kind === "private") set("default_role", null);
896 else if (!space.row.default_role) set("default_role", "edit");
897 if (c.kind === "private" && !space.members.some((m) => m.role === "manage")) {
898 // Someone must still manage it: whoever made it private.
899 await this.db
900 .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'")
901 .bind(space.row.id, this.userKey(a.viewer!), this.userKey(a.viewer!), now())
902 .run();
903 }
904 }
905 if (c.team !== undefined) set("team", c.team ? String(c.team).trim().toLowerCase() : null);
906 if (c.default_role !== undefined && (c.kind ?? space.row.kind) !== "private") set("default_role", isRole(c.default_role) ? c.default_role : null);
907 if (c.agent_mode !== undefined) set("agent_mode", c.agent_mode === "edit" ? "edit" : "suggest");
908 if (c.editors_can_share !== undefined) set("editors_can_share", c.editors_can_share ? 1 : 0);
909 // Any space archives, General too: a space is not required to exist.
910 if (c.archived !== undefined) set("archived_at", c.archived ? now() : null);
911 const statements: D1PreparedStatement[] = [];
912 if (sets.length) statements.push(this.db.prepare(`UPDATE spaces SET ${sets.join(", ")} WHERE id = ?`).bind(...values, space.row.id));
913 if (c.projects !== undefined) {
914 statements.push(this.db.prepare("DELETE FROM space_projects WHERE space_id = ?").bind(space.row.id));
915 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));
916 }
917 if (statements.length) await this.db.batch(statements);
918 this.workspaces.clear();
919 const after = (await this.spacesFor(workspace, a.viewer!, true)).find((s) => s.row.id === space.row.id)!;
920 return ok(this.toSpace(after));
921 }
922
923 async setSpaceMember(a: { workspace: string; space_id: string; viewer: Viewer; member: string; role: DocRole | null }): Promise<Result<DocSpaceMember[]>> {
924 const found = await this.viewerWorkspace(a.workspace, a.viewer);
925 if (!found.ok) return found;
926 const workspace = found.value;
927 const spaces = await this.spacesFor(workspace, a.viewer!, true);
928 const space = spaces.find((s) => s.row.id === a.space_id);
929 if (!space?.role) return fail("not_found", "No such space.");
930 if (!atLeast(space.role, "manage")) return fail("forbidden", "Only people with full access can change who is in a space.");
931 const key = memberKey(String(a.member ?? ""));
932 if (!key) return fail("invalid", "Choose a person or team.");
933 const role = a.role === null ? null : isRole(a.role) ? a.role : null;
934 if (a.role !== null && !role) return fail("invalid", "Choose a role.");
935 // They must belong to the workspace.
936 if (key.kind === "user") {
937 await this.nameUsers([key.id]);
938 const username = this.usernames.get(key.id);
939 if (!username || !(await this.members(workspace)).has(username.toLowerCase())) return fail("invalid", "Only members of the workspace can be added.");
940 } else if (key.kind === "agent") {
941 // An agent left in a space from before can be taken out, never added.
942 if (role) return fail("invalid", "Choose a person or team.");
943 } else {
944 const teams = await this.teamsOf(workspace);
945 if (![...teams.values()].some((set) => set.has(key.id.toLowerCase()))) return fail("invalid", "No such team in this workspace.");
946 }
947 const principal = key.kind === "team" ? `team:${key.id.toLowerCase()}` : `${key.kind}:${key.id}`;
948 if (leavesNoManager(space.row.kind, space.members, principal, role)) return fail("invalid", "Someone must keep full access to a private space.");
949 if (role) {
950 await this.db
951 .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")
952 .bind(space.row.id, principal, role, this.userKey(a.viewer!), now())
953 .run();
954 } else {
955 await this.db.prepare("DELETE FROM space_members WHERE space_id = ? AND principal = ?").bind(space.row.id, principal).run();
956 }
957 const after = (await this.spacesFor(workspace, a.viewer!, true)).find((s) => s.row.id === space.row.id)!;
958 return ok(await this.spaceMembers(workspace, after));
959 }
960
961 // ── Pages ───────────────────────────────────────────────────────────────
962
963 async page(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPageDetail>> {
964 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
965 if (!found.ok) return found;
966 const { workspace, page, space, spaces } = found.value;
967 const viewer = a.viewer!;
968 const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
969 const readable = new Set(spaces.filter((s) => s.role).map((s) => s.row.id));
970 const [tree, backlinks, children, favorite, viewed, suggestions, cited, staleness] = await Promise.all([
971 this.db.prepare("SELECT id, space_id, parent_id, position, title, icon FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>(),
972 this.db
973 .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")
974 .bind(page.id)
975 .all<PageRow>(),
976 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>(),
977 this.db.prepare("SELECT 1 AS yes FROM favorites WHERE user_id = ? AND page_id = ?").bind(viewer.id, page.id).first<{ yes: number }>(),
978 this.db.prepare("SELECT viewed_at FROM page_views WHERE user_id = ? AND page_id = ?").bind(viewer.id, page.id).first<{ viewed_at: string }>(),
979 this.openSuggestions(workspace, page.id),
980 this.citationsOf([page.id]),
981 this.stalenessFor(page.id, viewer),
982 ]);
983 this.defer(
984 this.db
985 .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")
986 .bind(page.id, viewer.id, now())
987 .run(),
988 );
989 const [detail] = await this.toPages(workspace, bySpace, [page]);
990 return ok({
991 page: detail!,
992 space: this.toSpace(space),
993 breadcrumbs: ancestors(tree.results, page.id).map((r) => this.ref(workspace.slug, space.row, r)),
994 markdown: page.markdown,
995 role: space.role!,
996 backlinks: backlinks.results.filter((r) => readable.has(r.space_id)).map((r) => this.ref(workspace.slug, bySpace.get(r.space_id)!, r)),
997 children: children.results.map((r) => this.ref(workspace.slug, space.row, r)),
998 favorite: !!favorite,
999 last_viewed_at: viewed?.viewed_at ?? null,
1000 suggestions,
1001 citations: cited.get(page.id) ?? [],
1002 describes: (cited.get(page.id) ?? []).filter((c) => c.source === "header").map((c) => ({ repo: c.repo, path: c.path })),
1003 staleness,
1004 });
1005 }
1006
1007 // ── Citations and staleness ─────────────────────────────────────────────
1008
1009 /** Each page's citations, by page. */
1010 private async citationsOf(pageIds: string[]): Promise<Map<string, DocCitation[]>> {
1011 const out = new Map<string, DocCitation[]>();
1012 if (!pageIds.length) return out;
1013 const rows = await this.db
1014 .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`)
1015 .bind(...pageIds)
1016 .all<Omit<DocCitation, "label"> & { page_id: string; label: string }>();
1017 for (const r of rows.results) {
1018 const list = out.get(r.page_id) ?? [];
1019 list.push({ repo: r.repo, path: r.path, kind: r.kind, label: r.label || null, ref: r.ref, source: r.source });
1020 out.set(r.page_id, list);
1021 }
1022 return out;
1023 }
1024
1025 /** Of these repositories (`owner/name`), those the viewer can read. */
1026 private async readableRepos(viewer: User, repos: string[]): Promise<Set<string>> {
1027 const out = new Set<string>();
1028 if (!this.env.REPOS) return out;
1029 const client = reposClient(this.env.REPOS);
1030 await Promise.all(
1031 [...new Set(repos)].slice(0, 25).map(async (repo) => {
1032 const [namespace, name] = repo.split("/") as [string, string];
1033 const found = await client.get({ namespace, name }, viewer).catch(() => null);
1034 if (found?.ok) out.add(repo);
1035 }),
1036 );
1037 return out;
1038 }
1039
1040 /** Open changes on these pages, newest first. */
1041 private async openChanges(pageIds: string[]): Promise<ChangeRow[]> {
1042 if (!pageIds.length) return [];
1043 const out: ChangeRow[] = [];
1044 for (let i = 0; i < pageIds.length; i += 90) {
1045 const part = pageIds.slice(i, i + 90);
1046 const rows = await this.db
1047 .prepare(`SELECT * FROM page_changes WHERE cleared_at IS NULL AND page_id IN (${part.map(() => "?").join(",")}) ORDER BY detected_at DESC`)
1048 .bind(...part)
1049 .all<ChangeRow>();
1050 out.push(...rows.results);
1051 }
1052 return out.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
1053 }
1054
1055 /** A change as a reader sees it: named only when they can read its repository. */
1056 private toChange(row: ChangeRow, readable: Set<string>): DocStaleChange {
1057 if (!readable.has(row.repo)) return { visible: false, repo: null, commit: null, pull: null, paths: [], at: row.detected_at };
1058 let paths: string[] = [];
1059 try {
1060 paths = JSON.parse(row.paths) as string[];
1061 } catch {
1062 paths = [];
1063 }
1064 return {
1065 visible: true,
1066 repo: row.repo,
1067 commit: row.commit_sha,
1068 pull: row.pull_number ? { number: row.pull_number, title: row.pull_title } : null,
1069 paths,
1070 at: row.detected_at,
1071 };
1072 }
1073
1074 /** Why a page is possibly out of date, as this viewer may see it; null when it isn't. */
1075 private async stalenessFor(pageId: string, viewer: User): Promise<DocStaleness | null> {
1076 const rows = await this.openChanges([pageId]);
1077 if (!rows.length) return null;
1078 const readable = await this.readableRepos(
1079 viewer,
1080 rows.map((r) => r.repo),
1081 );
1082 const changes = rows.slice(0, 20).map((r) => this.toChange(r, readable));
1083 return { since: rows[rows.length - 1]!.detected_at, changes };
1084 }
1085
1086 /** Live pages in these spaces that are possibly out of date, most recently flagged first; `repo` narrows to changes there. */
1087 private async staleRows(spaceIds: string[], repo: string | null, limit: number): Promise<PageRow[]> {
1088 if (!spaceIds.length) return [];
1089 const marks = spaceIds.map(() => "?").join(",");
1090 return (
1091 await this.db
1092 .prepare(
1093 `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
1094 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
1095 WHERE p.space_id IN (${marks}) AND p.archived_at IS NULL ORDER BY c.flagged DESC LIMIT ?`,
1096 )
1097 .bind(...(repo ? [repo] : []), ...spaceIds, limit)
1098 .all<PageRow>()
1099 ).results;
1100 }
1101
1102 async stalePages(a: { workspace: string; viewer: Viewer; repo?: string | null }): Promise<Result<DocPage[]>> {
1103 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1104 if (!found.ok) return found;
1105 const workspace = found.value;
1106 const spaces = (await this.spacesFor(workspace, a.viewer!)).filter((s) => s.role);
1107 const rows = await this.staleRows(
1108 spaces.map((s) => s.row.id),
1109 a.repo ? projectRef(a.repo) : null,
1110 200,
1111 );
1112 return ok(await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), rows));
1113 }
1114
1115 /** Clears every open change on a page. */
1116 private async clearStale(pageId: string, by: string): Promise<boolean> {
1117 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();
1118 const cleared = (done.meta?.changes ?? 0) > 0;
1119 if (cleared) this.tell(pageId, { type: "page.staleness" });
1120 return cleared;
1121 }
1122
1123 async markCurrent(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1124 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1125 if (!found.ok) return found;
1126 await this.clearStale(found.value.page.id, this.userKey(a.viewer!));
1127 return ok(true);
1128 }
1129
1130 /** Where a new page in `space` from `input` starts: its Markdown and title. */
1131 private async startingPoint(workspace: Workspace, input: NewDocPage): Promise<{ markdown: string; title: string; icon: string | null }> {
1132 let markdown = String(input.markdown ?? "").slice(0, MAX_MARKDOWN);
1133 let title = cleanTitle(input.title);
1134 let icon = cleanIcon(input.icon);
1135 if (input.template_id) {
1136 const template = builtinTemplate(input.template_id) ?? (await this.savedTemplate(workspace, input.template_id));
1137 if (template) {
1138 markdown = template.markdown;
1139 if (!title) title = template.name;
1140 if (!icon) icon = template.icon;
1141 }
1142 }
1143 return { markdown, title, icon };
1144 }
1145
1146 private async savedTemplate(workspace: Workspace, id: string): Promise<DocTemplate | null> {
1147 const row = await this.db
1148 .prepare("SELECT * FROM templates WHERE id = ? AND workspace_id = ?")
1149 .bind(id, workspace.id)
1150 .first<{ id: string; name: string; description: string; icon: string; markdown: string; created_by: string }>();
1151 if (!row) return null;
1152 return { id: row.id, name: row.name, description: row.description, icon: row.icon, markdown: row.markdown, builtin: false, created_by: parsePrincipalKey(row.created_by) };
1153 }
1154
1155 /** Inserts a page row and fills its room. */
1156 private async insertPage(
1157 workspace: Workspace,
1158 space: SpaceRow,
1159 author: string,
1160 input: { parent_id: string | null; title: string; icon: string | null; markdown: string; state?: Uint8Array | null; projects?: string[]; owners?: string[]; position?: number },
1161 ): Promise<PageRow> {
1162 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;
1163 const id = newId("pag");
1164 const at = now();
1165 const position = input.position ?? lastPosition(rows, input.parent_id);
1166 const row: PageRow = {
1167 id,
1168 workspace_id: workspace.id,
1169 space_id: space.id,
1170 parent_id: input.parent_id,
1171 position,
1172 title: input.title,
1173 icon: input.icon,
1174 cover: null,
1175 markdown: input.markdown,
1176 created_by: author,
1177 created_at: at,
1178 updated_by: author,
1179 updated_at: at,
1180 archived_at: null,
1181 archived_by: null,
1182 };
1183 await this.db.batch([
1184 this.db
1185 .prepare(
1186 "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, ?, ?, ?, ?, ?)",
1187 )
1188 .bind(id, workspace.id, space.id, input.parent_id, position, input.title, input.icon, input.markdown, author, at, author, at),
1189 this.db.prepare("INSERT INTO pages_fts (page_id, title, body) VALUES (?, ?, ?)").bind(id, input.title, searchText(input.markdown)),
1190 this.db.prepare("INSERT INTO page_owners (page_id, principal) VALUES (?, ?)").bind(id, author),
1191 ...(input.owners ?? []).filter((o) => o !== author).map((o) => this.db.prepare("INSERT OR IGNORE INTO page_owners (page_id, principal) VALUES (?, ?)").bind(id, o)),
1192 ...(input.projects ?? []).map((repo) => this.db.prepare("INSERT OR IGNORE INTO page_projects (page_id, repo) VALUES (?, ?)").bind(id, repo)),
1193 this.db
1194 .prepare("INSERT INTO page_versions (id, page_id, created_at, kind, authors, note, markdown, state) VALUES (?, ?, ?, 'created', ?, NULL, ?, NULL)")
1195 .bind(newId("ver"), id, at, JSON.stringify([author]), input.markdown),
1196 ]);
1197 await this.room(id).ensure({ page_id: id, workspace_slug: workspace.slug, markdown: input.markdown, state: input.state ?? null });
1198 this.defer(publishDocEvent(this.env.EVENTS, "doc.page.created", this.eventData(workspace, space, row), author));
1199 // Its passages, for search by meaning (src/indexer.ts); later edits are indexed by its room.
1200 if (input.markdown.trim()) this.defer(indexPage(this.env, id));
1201 return row;
1202 }
1203
1204 /** What every `doc.page.*` event says of a page. */
1205 private eventData(workspace: Workspace, space: Pick<SpaceRow, "id" | "slug">, row: Pick<PageRow, "id" | "title" | "icon">) {
1206 return { workspace: workspace.slug, workspaceId: workspace.id, pageId: row.id, spaceId: space.id, title: row.title, path: this.ref(workspace.slug, space, row).path };
1207 }
1208
1209 async createPage(a: { workspace: string; viewer: Viewer; input: NewDocPage }): Promise<Result<DocPage>> {
1210 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1211 if (!found.ok) return found;
1212 const workspace = found.value;
1213 const input = a.input ?? ({} as NewDocPage);
1214 const spaces = await this.spacesFor(workspace, a.viewer!);
1215 const space = spaces.find((s) => s.row.id === input.space_id) ?? (input.space_id ? null : spaces.find((s) => s.row.is_default));
1216 if (!space?.role) return fail("not_found", "No such space.");
1217 if (!atLeast(space.role, "edit")) return fail("forbidden", "You can read this space but not add pages to it.");
1218 const parent = input.parent_id
1219 ? 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 }>()
1220 : null;
1221 if (input.parent_id && !parent) return fail("not_found", "No such parent page.");
1222 const start = await this.startingPoint(workspace, input);
1223 const row = await this.insertPage(workspace, space.row, this.userKey(a.viewer!), {
1224 parent_id: parent?.id ?? null,
1225 title: start.title,
1226 icon: start.icon,
1227 markdown: start.markdown,
1228 projects: cleanProjects(input.projects),
1229 });
1230 const [page] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [row]);
1231 return ok(page!);
1232 }
1233
1234 async updatePage(a: { workspace: string; page_id: string; viewer: Viewer; change: DocPageChange }): Promise<Result<DocPage>> {
1235 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1236 if (!found.ok) return found;
1237 const { workspace, page, space } = found.value;
1238 const c = a.change ?? {};
1239 const statements: D1PreparedStatement[] = [];
1240 const sets: string[] = [];
1241 const values: unknown[] = [];
1242 if (c.title !== undefined) {
1243 sets.push("title = ?");
1244 values.push(cleanTitle(c.title));
1245 statements.push(this.db.prepare("UPDATE pages_fts SET title = ? WHERE page_id = ?").bind(cleanTitle(c.title), page.id));
1246 }
1247 if (c.icon !== undefined) {
1248 sets.push("icon = ?");
1249 values.push(cleanIcon(c.icon));
1250 }
1251 if (c.cover !== undefined) {
1252 sets.push("cover = ?");
1253 values.push(cleanCover(c.cover));
1254 }
1255 if (sets.length) {
1256 sets.push("updated_at = ?", "updated_by = ?");
1257 values.push(now(), this.userKey(a.viewer!));
1258 statements.unshift(this.db.prepare(`UPDATE pages SET ${sets.join(", ")} WHERE id = ?`).bind(...values, page.id));
1259 }
1260 if (c.projects !== undefined) {
1261 statements.push(this.db.prepare("DELETE FROM page_projects WHERE page_id = ?").bind(page.id));
1262 for (const repo of cleanProjects(c.projects)) statements.push(this.db.prepare("INSERT INTO page_projects (page_id, repo) VALUES (?, ?)").bind(page.id, repo));
1263 }
1264 if (c.describes !== undefined) {
1265 statements.push(this.db.prepare("DELETE FROM citations WHERE page_id = ? AND source = 'header'").bind(page.id));
1266 for (const d of cleanDescribes(c.describes)) {
1267 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));
1268 }
1269 }
1270 if (c.owners !== undefined) {
1271 const owners = [...new Set((Array.isArray(c.owners) ? c.owners : []).map(String).filter((k) => memberKey(k)?.kind === "user"))].slice(0, 20);
1272 statements.push(this.db.prepare("DELETE FROM page_owners WHERE page_id = ?").bind(page.id));
1273 for (const o of owners) statements.push(this.db.prepare("INSERT INTO page_owners (page_id, principal) VALUES (?, ?)").bind(page.id, o));
1274 }
1275 if (statements.length) await this.db.batch(statements);
1276 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1277 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1278 this.tell(page.id, { type: "page.updated", page: detail! });
1279 // The title is part of what each passage is embedded with.
1280 if (c.title !== undefined && cleanTitle(c.title) !== page.title) this.defer(indexPage(this.env, page.id));
1281 return ok(detail!);
1282 }
1283
1284 async movePage(a: { workspace: string; page_id: string; viewer: Viewer; move: DocMove }): Promise<Result<DocPage>> {
1285 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1286 if (!found.ok) return found;
1287 const { workspace, page, spaces } = found.value;
1288 const move = a.move ?? ({ parent_id: null } as DocMove);
1289 const target = move.space_id ? spaces.find((s) => s.row.id === move.space_id) : spaces.find((s) => s.row.id === page.space_id);
1290 if (!target?.role || target.row.archived_at) return fail("not_found", "No such space.");
1291 if (!atLeast(target.role, "edit")) return fail("forbidden", "You can't add pages to that space.");
1292 const [sourceRows, targetRows] = await Promise.all([
1293 this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(page.space_id).all<PageRow>(),
1294 this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ? AND archived_at IS NULL").bind(target.row.id).all<PageRow>(),
1295 ]);
1296 const parent = move.parent_id ?? null;
1297 if (parent && !targetRows.results.some((r) => r.id === parent)) return fail("not_found", "No such parent page in that space.");
1298 if (wouldCycle(sourceRows.results, page.id, parent)) return fail("invalid", "A page can't go inside itself.");
1299 const placed = placeBefore(targetRows.results, page.id, parent, move.before_id ?? null);
1300 const statements: D1PreparedStatement[] = [this.db.prepare("UPDATE pages SET parent_id = ?, position = ? WHERE id = ?").bind(parent, placed.position, page.id)];
1301 for (const [id, position] of placed.renumber) statements.push(this.db.prepare("UPDATE pages SET position = ? WHERE id = ?").bind(position, id));
1302 let moved: string[] = [];
1303 if (target.row.id !== page.space_id) {
1304 // The page and everything under it move to the other space.
1305 const all = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>()).results;
1306 moved = descendants(all, page.id);
1307 for (const id of moved) statements.push(this.db.prepare("UPDATE pages SET space_id = ? WHERE id = ?").bind(target.row.id, id));
1308 }
1309 await this.db.batch(statements);
1310 // Their passages are filed under the new space (no new embeddings: they only moved).
1311 if (moved.length) this.defer(this.reindexPages(moved));
1312 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1313 const [detail] = await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), [after!]);
1314 this.tell(page.id, { type: "page.updated", page: detail! });
1315 return ok(detail!);
1316 }
1317
1318 async duplicatePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
1319 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1320 if (!found.ok) return found;
1321 const { workspace, page, space } = found.value;
1322 const room = this.room(page.id);
1323 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1324 const [state, read] = await Promise.all([room.state(), room.read()]);
1325 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;
1326 const next = rows.filter((r) => r.parent_id === page.parent_id).sort((x, y) => x.position - y.position).find((r) => r.position > page.position);
1327 const row = await this.insertPage(workspace, space.row, this.userKey(a.viewer!), {
1328 parent_id: page.parent_id,
1329 title: `${page.title || "Untitled"} (copy)`.slice(0, MAX_TITLE),
1330 icon: page.icon,
1331 markdown: read.markdown,
1332 state,
1333 position: next ? (page.position + next.position) / 2 : page.position + 1024,
1334 });
1335 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [row]);
1336 return ok(detail!);
1337 }
1338
1339 async archivePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
1340 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1341 if (!found.ok) return found;
1342 const { workspace, page, space } = found.value;
1343 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;
1344 const ids = descendants(all, page.id);
1345 const at = now();
1346 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)));
1347 for (const id of ids) this.defer(this.room(id).closeAll("Moved to the trash").catch(() => undefined));
1348 // Out of search while in the trash; restoring indexes them again.
1349 this.defer(forgetDocs(this.env, { page_ids: ids }));
1350 this.defer(publishDocEvent(this.env.EVENTS, "doc.page.archived", this.eventData(workspace, space.row, page), this.userKey(a.viewer!)));
1351 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1352 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1353 return ok(detail!);
1354 }
1355
1356 async restorePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocPage>> {
1357 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1358 if (!found.ok) return found;
1359 const { workspace, page, space } = found.value;
1360 if (!page.archived_at) return fail("invalid", "That page isn't in the trash.");
1361 // It comes back with what was trashed with it; under its parent if that is still there.
1362 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;
1363 const parent = page.parent_id ? all.find((r) => r.id === page.parent_id) : null;
1364 const parentGone = !!page.parent_id && (!parent || !!parent.archived_at);
1365 const ids = descendants(all, page.id).filter((id) => all.find((r) => r.id === id)?.archived_at === page.archived_at);
1366 const statements = ids.map((id) => this.db.prepare("UPDATE pages SET archived_at = NULL, archived_by = NULL WHERE id = ?").bind(id));
1367 if (parentGone) statements.push(this.db.prepare("UPDATE pages SET parent_id = NULL WHERE id = ?").bind(page.id));
1368 await this.db.batch(statements);
1369 this.defer(this.reindexPages(ids));
1370 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
1371 const [detail] = await this.toPages(workspace, new Map([[space.row.id, space.row]]), [after!]);
1372 return ok(detail!);
1373 }
1374
1375 async deletePage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1376 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "manage");
1377 if (!found.ok) return found;
1378 const { page } = found.value;
1379 if (!page.archived_at) return fail("invalid", "Move the page to the trash first.");
1380 const all = (await this.db.prepare("SELECT id, parent_id, position FROM pages WHERE space_id = ?").bind(page.space_id).all<PageRow>()).results;
1381 const ids = descendants(all, page.id);
1382 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)]));
1383 this.defer(forgetDocs(this.env, { page_ids: ids }));
1384 return ok(true);
1385 }
1386
1387 async trash(a: { workspace: string; viewer: Viewer }): Promise<Result<DocPage[]>> {
1388 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1389 if (!found.ok) return found;
1390 const workspace = found.value;
1391 const spaces = (await this.spacesFor(workspace, a.viewer!)).filter((s) => atLeast(s.role, "edit"));
1392 if (!spaces.length) return ok([]);
1393 const marks = spaces.map(() => "?").join(",");
1394 const rows = (
1395 await this.db
1396 .prepare(`SELECT ${PAGE_COLUMNS} FROM pages WHERE space_id IN (${marks}) AND archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT 200`)
1397 .bind(...spaces.map((s) => s.row.id))
1398 .all<PageRow>()
1399 ).results;
1400 return ok(await this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), rows));
1401 }
1402
1403 async favorite(a: { workspace: string; page_id: string; viewer: Viewer; on: boolean }): Promise<Result<boolean>> {
1404 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1405 if (!found.ok) return found;
1406 if (a.on) {
1407 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();
1408 } else {
1409 await this.db.prepare("DELETE FROM favorites WHERE user_id = ? AND page_id = ?").bind(a.viewer!.id, a.page_id).run();
1410 }
1411 return ok(!!a.on);
1412 }
1413
1414 // ── Search ──────────────────────────────────────────────────────────────
1415
1416 /** Full text over `spaceIds`, best first. */
1417 private async searchIn(workspace: Workspace, spaces: Space[], query: DocSearchQuery): Promise<DocSearchHit[]> {
1418 const q = ftsQuery(query.query);
1419 const ids = searchSpaces(
1420 spaces.map((s) => s.row.id),
1421 query.space_id ?? null,
1422 );
1423 if (!ids.length) return [];
1424 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1425 const marks = ids.map(() => "?").join(",");
1426 const project = query.project ? projectRef(query.project) : null;
1427 type Hit = { id: string; space_id: string; title: string; icon: string | null; updated_at: string; snippet: string };
1428 let rows: Hit[];
1429 if (q) {
1430 rows = (
1431 await this.db
1432 .prepare(
1433 `SELECT p.id, p.space_id, p.title, p.icon, p.updated_at, snippet(pages_fts, 2, '[[', ']]', '…', 16) AS snippet
1434 FROM pages_fts JOIN pages p ON p.id = pages_fts.page_id
1435 WHERE pages_fts MATCH ? AND p.space_id IN (${marks}) AND p.archived_at IS NULL
1436 ORDER BY bm25(pages_fts, 0, 8.0, 1.0) LIMIT ?`,
1437 )
1438 .bind(q, ...ids, limit * 3)
1439 .all<Hit>()
1440 ).results;
1441 } else {
1442 rows = (
1443 await this.db
1444 .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 ?`)
1445 .bind(...ids, limit * 3)
1446 .all<Hit>()
1447 ).results;
1448 }
1449 const pageIds = rows.map((r) => r.id);
1450 const projects = pageIds.length
1451 ? (
1452 await this.db
1453 .prepare(`SELECT page_id, repo FROM page_projects WHERE page_id IN (${pageIds.map(() => "?").join(",")})`)
1454 .bind(...pageIds)
1455 .all<{ page_id: string; repo: string }>()
1456 ).results
1457 : [];
1458 const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
1459 return rows
1460 .map((r) => {
1461 const space = bySpace.get(r.space_id)!;
1462 const own = projects.filter((p) => p.page_id === r.id).map((p) => p.repo);
1463 return { r, space, own };
1464 })
1465 .filter(({ space, own }) => inProject(project, own, space.projects))
1466 .slice(0, limit)
1467 .map(({ r, space, own }) => ({
1468 ...this.ref(workspace.slug, space.row, r),
1469 space_name: space.row.name,
1470 snippet: q ? r.snippet : excerpt(r.snippet, 140),
1471 updated_at: r.updated_at,
1472 projects: [...new Set([...own, ...space.projects])],
1473 }));
1474 }
1475
1476 async search(a: { workspace: string; viewer: Viewer; query: DocSearchQuery }): Promise<Result<DocSearchHit[]>> {
1477 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1478 if (!found.ok) return found;
1479 const workspace = found.value;
1480 const query = a.query ?? { query: "" };
1481 const spaces = (await this.spacesFor(workspace, a.viewer!)).filter((s) => s.role);
1482 const hybrid = query.mode === "hybrid" && !!ftsQuery(query.query);
1483 // A project's docs, when the search isn't narrowed to one of the workspace's spaces.
1484 const repoSpaces = query.space_id || !ftsQuery(query.query)
1485 ? []
1486 : await this.repoSpacesMatching(workspace, a.viewer!, query).catch((error: unknown) => {
1487 console.error("docs could not list projects' docs for search", String(error));
1488 return [] as { row: RepoSpaceRow; repo: Repo }[];
1489 });
1490 const [pages, files, meaning] = await Promise.all([
1491 this.searchIn(workspace, spaces, query),
1492 this.searchRepoFiles(workspace, repoSpaces, query).catch((error: unknown) => {
1493 console.error("docs could not search projects' docs", String(error));
1494 return [] as DocSearchHit[];
1495 }),
1496 hybrid
1497 ? this.meaningHits(workspace, spaces, repoSpaces, query).catch((error: unknown) => {
1498 console.error("docs could not search by meaning", String(error));
1499 return null;
1500 })
1501 : Promise.resolve(null),
1502 ]);
1503 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1504 // Words only: pages first, then files, as many as asked for.
1505 if (!hybrid) return ok([...pages, ...files].slice(0, limit));
1506 return ok(await this.fuseHits(workspace, spaces, repoSpaces, [...pages, ...files], meaning ?? [], query, limit));
1507 }
1508
1509 /** The projects' docs the viewer can read, narrowed to the search's project. */
1510 private async repoSpacesMatching(workspace: Workspace, viewer: User, query: DocSearchQuery): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
1511 const spaces = await this.readableRepoSpaces(workspace, viewer);
1512 const project = query.project ? projectRef(query.project) : null;
1513 return project ? spaces.filter((s) => `${s.repo.namespace}/${s.repo.name}`.toLowerCase() === project) : spaces;
1514 }
1515
1516 /** A search hit's key: a page's id, or `repo:<space>:<path>` for a project's docs file. */
1517 private hitKey(row: Pick<PassageRow, "page_id" | "space_id" | "path">): string {
1518 return row.page_id ?? `repo:${row.space_id}:${row.path}`;
1519 }
1520
1521 /** By meaning: each page's or file's closest passage above the floor, closest first. */
1522 private async meaningHits(workspace: Workspace, spaces: Space[], repoSpaces: { row: RepoSpaceRow }[], query: DocSearchQuery): Promise<{ key: string; row: PassageRow; score: number }[]> {
1523 const allowed = [...searchSpaces(spaces.map((s) => s.row.id), query.space_id ?? null), ...repoSpaces.map((r) => r.row.id)];
1524 if (!allowed.length) return [];
1525 const vector = await this.queryVector(query.query);
1526 if (!vector) return [];
1527 const matches = (await this.meaningMatches(workspace.id, allowed, vector)).filter((m) => m.score >= MEANING_FLOOR);
1528 const rows = await this.passages(workspace.id, matches.map((m) => m.id));
1529 const may = new Set(allowed);
1530 const best = new Map<string, { key: string; row: PassageRow; score: number }>();
1531 for (const m of matches) {
1532 const row = rows.get(m.id);
1533 if (!row || !may.has(row.space_id)) continue;
1534 const key = this.hitKey(row);
1535 if ((best.get(key)?.score ?? -1) < m.score) best.set(key, { key, row, score: m.score });
1536 }
1537 return [...best.values()].sort((a, b) => b.score - a.score);
1538 }
1539
1540 /**
1541 * Hybrid search's answer: word hits and meaning hits fused by rank, each
1542 * with the passage that matched and its heading. Word hits get theirs
1543 * from the passages' full text; meaning-only hits show their passage.
1544 */
1545 private async fuseHits(
1546 workspace: Workspace,
1547 spaces: Space[],
1548 repoSpaces: { row: RepoSpaceRow; repo: Repo }[],
1549 words: DocSearchHit[],
1550 meaning: { key: string; row: PassageRow; score: number }[],
1551 query: DocSearchQuery,
1552 limit: number,
1553 ): Promise<DocSearchHit[]> {
1554 const order = fuseRanks(
1555 words.map((h) => h.id),
1556 meaning.map((m) => m.key),
1557 );
1558 const byWords = new Map(words.map((h) => [h.id, h]));
1559 const byMeaning = new Map(meaning.map((m) => [m.key, m]));
1560 // The passage each word hit matched in, for its heading and a closer snippet.
1561 const docIds = new Map(words.map((h) => [h.repo_file ? repoFileId(h.space_id, h.repo_file.path) : h.id, h.id]));
1562 const passageOf = new Map<string, { heading: string; snippet: string }>();
1563 const q = ftsQuery(query.query);
1564 if (q && docIds.size) {
1565 // The best-ranked 90, within D1's bound parameters.
1566 const ids = [...docIds.keys()].slice(0, 90);
1567 const found = await this.db
1568 .prepare(
1569 `SELECT doc_id, heading, snippet(doc_chunks_fts, 4, '[[', ']]', '…', 16) AS snippet FROM doc_chunks_fts
1570 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`,
1571 )
1572 .bind(q, ...ids)
1573 .all<{ doc_id: string; heading: string; snippet: string }>()
1574 .catch(() => ({ results: [] as { doc_id: string; heading: string; snippet: string }[] }));
1575 for (const r of found.results) {
1576 const key = docIds.get(r.doc_id);
1577 if (key && !passageOf.has(key)) passageOf.set(key, { heading: r.heading, snippet: r.snippet });
1578 }
1579 }
1580 // Meaning-only pages: their projects, for the project filter and the hit.
1581 const onlyMeaning = meaning.filter((m) => !byWords.has(m.key) && m.row.page_id);
1582 const pageIds = onlyMeaning.map((m) => m.row.page_id!);
1583 const projects = pageIds.length
1584 ? (
1585 await this.db
1586 .prepare(`SELECT page_id, repo FROM page_projects WHERE page_id IN (${pageIds.map(() => "?").join(",")})`)
1587 .bind(...pageIds)
1588 .all<{ page_id: string; repo: string }>()
1589 ).results
1590 : [];
1591 const project = query.project ? projectRef(query.project) : null;
1592 const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
1593 const byRepo = new Map(repoSpaces.map((r) => [r.row.id, r]));
1594 const out: DocSearchHit[] = [];
1595 for (const key of order) {
1596 if (out.length >= limit) break;
1597 const w = byWords.get(key);
1598 const m = byMeaning.get(key);
1599 if (w) {
1600 const passage = passageOf.get(key);
1601 out.push({
1602 ...w,
1603 snippet: passage?.snippet || w.snippet,
1604 heading: (passage ? passage.heading || null : null) ?? m?.row.heading ?? null,
1605 matched: m ? "both" : "words",
1606 });
1607 continue;
1608 }
1609 if (!m) continue;
1610 const row = m.row;
1611 const snippet = excerpt(row.text, 200);
1612 if (row.page_id) {
1613 const space = bySpace.get(row.space_id);
1614 if (!space) continue;
1615 const own = projects.filter((p) => p.page_id === row.page_id).map((p) => p.repo);
1616 if (!inProject(project, own, space.projects)) continue;
1617 out.push({
1618 ...this.ref(workspace.slug, space.row, { id: row.page_id, title: row.title ?? "", icon: row.icon }),
1619 space_name: space.row.name,
1620 snippet,
1621 updated_at: row.page_updated_at ?? row.updated_at,
1622 projects: [...new Set([...own, ...space.projects])],
1623 heading: row.heading,
1624 matched: "meaning",
1625 });
1626 } else {
1627 const r = byRepo.get(row.space_id);
1628 if (!r || !row.path) continue;
1629 const repo = `${r.repo.namespace}/${r.repo.name}`;
1630 out.push({
1631 id: key,
1632 space_id: row.space_id,
1633 space_slug: "repo",
1634 title: row.title ?? row.path,
1635 icon: null,
1636 slug: row.path,
1637 path: `/${workspace.slug}/-/docs/repo/${repo}/${row.path.split("/").map(encodeURIComponent).join("/")}`,
1638 space_name: repo,
1639 snippet,
1640 updated_at: r.row.indexed_at ?? r.row.added_at,
1641 projects: [repo.toLowerCase()],
1642 repo_file: { repo, path: row.path },
1643 heading: row.heading,
1644 matched: "meaning",
1645 });
1646 }
1647 }
1648 return out;
1649 }
1650
1651 /** Full text over these projects' docs (the viewer's to read). */
1652 private async searchRepoFiles(workspace: Workspace, spaces: { row: RepoSpaceRow; repo: Repo }[], query: DocSearchQuery): Promise<DocSearchHit[]> {
1653 const q = ftsQuery(query.query);
1654 if (!q) return [];
1655 if (!spaces.length) return [];
1656 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1657 const rows = (
1658 await this.db
1659 .prepare(
1660 `SELECT space_id, path, title, snippet(repo_files_fts, 3, '[[', ']]', '…', 16) AS snippet FROM repo_files_fts
1661 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 ?`,
1662 )
1663 .bind(q, ...spaces.map((s) => s.row.id), limit)
1664 .all<{ space_id: string; path: string; title: string; snippet: string }>()
1665 ).results;
1666 const byId = new Map(spaces.map((s) => [s.row.id, s]));
1667 return rows.map((r) => {
1668 const s = byId.get(r.space_id)!;
1669 const repo = `${s.repo.namespace}/${s.repo.name}`;
1670 return {
1671 id: `repo:${r.space_id}:${r.path}`,
1672 space_id: r.space_id,
1673 space_slug: "repo",
1674 title: r.title,
1675 icon: null,
1676 slug: r.path,
1677 path: `/${workspace.slug}/-/docs/repo/${repo}/${r.path.split("/").map(encodeURIComponent).join("/")}`,
1678 space_name: repo,
1679 snippet: r.snippet,
1680 updated_at: s.row.indexed_at ?? s.row.added_at,
1681 projects: [repo.toLowerCase()],
1682 repo_file: { repo, path: r.path },
1683 };
1684 });
1685 }
1686
1687 // ── History ─────────────────────────────────────────────────────────────
1688
1689 private async toVersions(workspace: Workspace, rows: Omit<VersionRow, "markdown" | "state">[]): Promise<DocVersion[]> {
1690 const authors = rows.map((r) => {
1691 try {
1692 return JSON.parse(r.authors) as string[];
1693 } catch {
1694 return [];
1695 }
1696 });
1697 const people = await this.profiles(workspace, authors.flat());
1698 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) }));
1699 }
1700
1701 async versions(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocVersion[]>> {
1702 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1703 if (!found.ok) return found;
1704 // Whatever is unsaved goes in first, so the newest version is now.
1705 await this.room(a.page_id)
1706 .flush()
1707 .catch(() => undefined);
1708 const rows = (
1709 await this.db
1710 .prepare("SELECT id, page_id, created_at, kind, authors, note FROM page_versions WHERE page_id = ? ORDER BY created_at DESC LIMIT 200")
1711 .bind(a.page_id)
1712 .all<Omit<VersionRow, "markdown" | "state">>()
1713 ).results;
1714 return ok(await this.toVersions(found.value.workspace, rows));
1715 }
1716
1717 async version(a: { workspace: string; page_id: string; version_id: string; viewer: Viewer }): Promise<Result<DocVersionDetail>> {
1718 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1719 if (!found.ok) return found;
1720 const row = await this.db
1721 .prepare("SELECT id, page_id, created_at, kind, authors, note, markdown FROM page_versions WHERE id = ? AND page_id = ?")
1722 .bind(a.version_id, a.page_id)
1723 .first<Omit<VersionRow, "state">>();
1724 if (!row) return fail("not_found", "No such version.");
1725 const before = await this.db
1726 .prepare("SELECT markdown FROM page_versions WHERE page_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT 1")
1727 .bind(a.page_id, row.created_at)
1728 .first<{ markdown: string }>();
1729 const [version] = await this.toVersions(found.value.workspace, [row]);
1730 return ok({ ...version!, markdown: row.markdown, diff: diffLines(before?.markdown ?? "", row.markdown) });
1731 }
1732
1733 async restoreVersion(a: { workspace: string; page_id: string; version_id: string; viewer: Viewer }): Promise<Result<DocVersion>> {
1734 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1735 if (!found.ok) return found;
1736 const { workspace, page } = found.value;
1737 const row = await this.db.prepare("SELECT * FROM page_versions WHERE id = ? AND page_id = ?").bind(a.version_id, page.id).first<VersionRow>();
1738 if (!row) return fail("not_found", "No such version.");
1739 const room = this.room(page.id);
1740 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1741 const when = new Date(row.created_at).toISOString().slice(0, 16).replace("T", " ");
1742 const origin: Origin = { key: this.userKey(a.viewer!), kind: "restore", note: `Restored the version of ${when} UTC` };
1743 const versionId = await room.restore({ state: row.state ? new Uint8Array(row.state) : null, markdown: row.markdown }, origin);
1744 const created = versionId
1745 ? 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">>()
1746 : null;
1747 if (!created) return fail("conflict", "The page could not be restored. Try again.");
1748 const [version] = await this.toVersions(workspace, [created]);
1749 this.tell(page.id, { type: "version.created", version: version! });
1750 return ok(version!);
1751 }
1752
1753 // ── Templates and export ────────────────────────────────────────────────
1754
1755 async templates(a: { workspace: string; viewer: Viewer }): Promise<Result<DocTemplate[]>> {
1756 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1757 if (!found.ok) return found;
1758 const rows = (
1759 await this.db
1760 .prepare("SELECT * FROM templates WHERE workspace_id = ? ORDER BY name COLLATE NOCASE")
1761 .bind(found.value.id)
1762 .all<{ id: string; name: string; description: string; icon: string; markdown: string; created_by: string }>()
1763 ).results;
1764 return ok([
1765 ...BUILTIN_TEMPLATES,
1766 ...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) })),
1767 ]);
1768 }
1769
1770 async saveTemplate(a: { workspace: string; viewer: Viewer; input: { page_id: string; name: string; description?: string | null } }): Promise<Result<DocTemplate>> {
1771 const found = await this.pageFor(a.workspace, a.input?.page_id, a.viewer, "view");
1772 if (!found.ok) return found;
1773 const { workspace, page } = found.value;
1774 const name = cleanTitle(a.input.name || page.title).slice(0, 80);
1775 if (!name) return fail("invalid", "Name the template.");
1776 const room = this.room(page.id);
1777 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1778 const { markdown } = await room.read();
1779 const id = newId("tpl");
1780 const description = String(a.input.description ?? "").trim().slice(0, 200);
1781 await this.db
1782 .prepare("INSERT INTO templates (id, workspace_id, name, description, icon, markdown, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
1783 .bind(id, workspace.id, name, description, page.icon ?? "📄", markdown, this.userKey(a.viewer!), now())
1784 .run();
1785 return ok({ id, name, description, icon: page.icon ?? "📄", markdown, builtin: false, created_by: { kind: "user", id: a.viewer!.id } });
1786 }
1787
1788 async deleteTemplate(a: { workspace: string; template_id: string; viewer: Viewer }): Promise<Result<boolean>> {
1789 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1790 if (!found.ok) return found;
1791 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 }>();
1792 if (!row) return fail("not_found", "No such template.");
1793 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.");
1794 await this.db.prepare("DELETE FROM templates WHERE id = ?").bind(a.template_id).run();
1795 return ok(true);
1796 }
1797
1798 async exportPage(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<{ filename: string; markdown: string }>> {
1799 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1800 if (!found.ok) return found;
1801 const { workspace, page } = found.value;
1802 const room = this.room(page.id);
1803 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1804 const { markdown } = await room.read();
1805 const title = page.title || "Untitled";
1806 return ok({ filename: `${title.replace(/[\\/:*?"<>|]+/g, " ").trim() || "page"}.md`, markdown: `# ${title}\n\n${markdown}` });
1807 }
1808
1809 async exportSpace(a: { workspace: string; space_id: string; viewer: Viewer }): Promise<Result<{ name: string; files: { path: string; markdown: string }[] }>> {
1810 const found = await this.viewerWorkspace(a.workspace, a.viewer);
1811 if (!found.ok) return found;
1812 const spaces = await this.spacesFor(found.value, a.viewer!);
1813 const space = spaces.find((s) => s.row.id === a.space_id);
1814 if (!space?.role) return fail("not_found", "No such space.");
1815 const rows = (
1816 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>()
1817 ).results;
1818 const paths = exportPaths(rows.map((r) => ({ ...r, title: r.title || "Untitled" })));
1819 return ok({
1820 name: space.row.slug,
1821 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)),
1822 });
1823 }
1824
1825 // ── Suggestions ─────────────────────────────────────────────────────────
1826
1827 private async toSuggestions(workspace: Workspace, rows: SuggestionRow[], blocks: (string[] | null)[] = []): Promise<DocSuggestion[]> {
1828 const people = await this.profiles(
1829 workspace,
1830 rows.flatMap((r) => [r.author, r.asked_by, r.decided_by].filter((k): k is string => !!k)),
1831 );
1832 return rows.map((r, i) => ({
1833 id: r.id,
1834 page_id: r.page_id,
1835 author: people.get(r.author)!,
1836 asked_by: r.asked_by ? (people.get(r.asked_by) ?? null) : null,
1837 target: JSON.parse(r.target) as DocEditTarget,
1838 before_markdown: r.before_markdown,
1839 after_markdown: r.after_markdown,
1840 note: r.note,
1841 status: r.status,
1842 created_at: r.created_at,
1843 decided_by: r.decided_by ? (people.get(r.decided_by) ?? null) : null,
1844 decided_at: r.decided_at,
1845 block_ids: blocks[i] ?? [],
1846 }));
1847 }
1848
1849 /** A page's open suggestions, with the blocks each covers now; ones whose target is gone become stale. */
1850 private async openSuggestions(workspace: Workspace, pageId: string): Promise<DocSuggestion[]> {
1851 const rows = (await this.db.prepare("SELECT * FROM suggestions WHERE page_id = ? AND status = 'open' ORDER BY created_at").bind(pageId).all<SuggestionRow>()).results;
1852 if (!rows.length) return [];
1853 let blocks: (string[] | null)[] = rows.map(() => []);
1854 try {
1855 blocks = await this.room(pageId).targets(rows.map((r) => JSON.parse(r.target) as DocEditTarget));
1856 } catch (error) {
1857 console.error("docs could not place suggestions", error);
1858 }
1859 const gone = rows.filter((_, i) => blocks[i] === null);
1860 if (gone.length) {
1861 await this.db.batch(gone.map((r) => this.db.prepare("UPDATE suggestions SET status = 'stale' WHERE id = ?").bind(r.id)));
1862 }
1863 const live = rows.map((r, i) => ({ r, b: blocks[i] })).filter((x) => x.b !== null);
1864 return this.toSuggestions(
1865 workspace,
1866 live.map((x) => x.r),
1867 live.map((x) => x.b!),
1868 );
1869 }
1870
1871 async suggestions(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocSuggestion[]>> {
1872 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1873 if (!found.ok) return found;
1874 return ok(await this.openSuggestions(found.value.workspace, a.page_id));
1875 }
1876
1877 async decideSuggestion(a: { workspace: string; suggestion_id: string; viewer: Viewer; decision: "accept" | "reject" }): Promise<Result<DocSuggestion>> {
1878 const row = await this.db.prepare("SELECT * FROM suggestions WHERE id = ?").bind(String(a.suggestion_id ?? "")).first<SuggestionRow>();
1879 if (!row) return fail("not_found", "No such suggestion.");
1880 const found = await this.pageFor(a.workspace, row.page_id, a.viewer, "edit");
1881 if (!found.ok) return found.error.code === "forbidden" ? fail("forbidden", "Only people who can edit the page can accept or reject a suggestion.") : found;
1882 const { workspace, page } = found.value;
1883 if (row.status !== "open") return fail("conflict", "That suggestion was already decided.");
1884 const me = this.userKey(a.viewer!);
1885 let status: DocSuggestion["status"] = a.decision === "accept" ? "accepted" : "rejected";
1886 if (a.decision === "accept") {
1887 const people = await this.profiles(workspace, [row.author, me]);
1888 const room = this.room(page.id);
1889 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1890 const result = await room.edit(JSON.parse(row.target) as DocEditTarget, row.after_markdown, {
1891 key: me,
1892 kind: "suggestion",
1893 note: `Suggested by @${people.get(row.author)!.name}, accepted by @${people.get(me)!.name}`,
1894 authors: [row.author, me],
1895 });
1896 if (!result.applied) status = "stale";
1897 else if (row.marks_current) await this.clearStale(page.id, row.author);
1898 }
1899 await this.db.prepare("UPDATE suggestions SET status = ?, decided_by = ?, decided_at = ? WHERE id = ?").bind(status, me, now(), row.id).run();
1900 const [after] = await this.toSuggestions(workspace, [{ ...row, status, decided_by: me, decided_at: now() }]);
1901 this.tell(page.id, { type: "suggestion.updated", suggestion: after! });
1902 if (status === "stale") return fail("conflict", "The part of the page this suggestion changes is gone, so it can't be applied.");
1903 return ok(after!);
1904 }
1905
1906 async acceptAll(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocSuggestion[]>> {
1907 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "edit");
1908 if (!found.ok) return found;
1909 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;
1910 const out: DocSuggestion[] = [];
1911 for (const r of rows) {
1912 const decided = await this.decideSuggestion({ workspace: a.workspace, suggestion_id: r.id, viewer: a.viewer, decision: "accept" });
1913 if (decided.ok) out.push(decided.value);
1914 }
1915 return ok(out);
1916 }
1917
1918 // ── Comments ────────────────────────────────────────────────────────────
1919
1920 async thread(a: { workspace: string; page_id: string; viewer: Viewer; action: DocThreadAction }): Promise<Result<unknown>> {
1921 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "comment");
1922 if (!found.ok) return found;
1923 const { workspace, page, space } = found.value;
1924 const me = this.userKey(a.viewer!);
1925 const room = this.room(page.id);
1926 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
1927 const result = (await room.thread(me, space.role!, a.action)) as ThreadResult;
1928 if (!result.ok) return fail(result.code, result.message);
1929 if (result.mentions?.length) this.defer(this.notifyMentioned(workspace, page, space.row, me, result.mentions, result.text ?? "", result.thread_id ?? null));
1930 return ok(result.value);
1931 }
1932
1933 /** People mentioned in a comment hear of it, if they can read the page. */
1934 private async notifyMentioned(workspace: Workspace, page: PageRow, space: SpaceRow, author: string, mentions: string[], text: string, threadId: string | null): Promise<void> {
1935 if (!this.env.NOTIFY) return;
1936 // Comment mentions name people by username (`user:<username>`).
1937 const me = author.startsWith("user:") ? (this.usernames.get(author.slice(5)) ?? "").toLowerCase() : "";
1938 const names = [...new Set(mentions.filter((k) => k.startsWith("user:")).map((k) => k.slice(5).toLowerCase()))].filter((n) => n && n !== me);
1939 if (!names.length) return;
1940 const [spaces, people] = await Promise.all([this.allSpaces(workspace), this.profiles(workspace, [author])]);
1941 const s = spaces.find((x) => x.row.id === space.id);
1942 if (!s) return;
1943 await this.nameUsers(s.members.filter((m) => m.principal.startsWith("user:")).map((m) => m.principal.slice(5)));
1944 const teams = this.needsTeams([s]) ? await this.teamsOf(workspace) : new Map<string, Set<string>>();
1945 const members = await this.members(workspace);
1946 const who = people.get(author)!;
1947 const href = `${this.ref(workspace.slug, space, page).path}${threadId ? `?thread=${encodeURIComponent(threadId)}` : ""}`;
1948 const notify = notifyClient(this.env.NOTIFY);
1949 await Promise.all(
1950 names.map(async (username) => {
1951 const member = members.get(username);
1952 if (!member) return;
1953 // Their id is not needed: access by username's teams and role is enough to decide.
1954 const person: Person = { user_id: `name:${username}`, owner: member.role === "owner", teams: teams.get(username) ?? new Set() };
1955 const listed = s.members.some((m) => m.principal.startsWith("user:") && this.usernames.get(m.principal.slice(5))?.toLowerCase() === username);
1956 if (!listed && !atLeast(roleOf(rulesOf({ ...s, role: null }), person), "view")) return;
1957 await notify
1958 .notify(
1959 { username },
1960 {
1961 id: `doc-comment:${page.id}:${threadId ?? ""}:${username}:${Date.now()}`,
1962 kind: "mention",
1963 workspace: workspace.slug,
1964 title: `${who.display_name} mentioned you on ${page.title || "Untitled"}`,
1965 body: text.slice(0, 140),
1966 href,
1967 actor: { kind: who.kind, id: who.id, name: who.display_name, avatar: who.avatar, avatar_seed: who.avatar_seed ?? null },
1968 created_at: now(),
1969 },
1970 )
1971 .catch(() => undefined);
1972 }),
1973 );
1974 }
1975
1976 private async resolveThreads(workspace: Workspace, threads: Awaited<ReturnType<PageRoom["threads"]>>): Promise<DocThread[]> {
1977 const people = await this.profiles(
1978 workspace,
1979 threads.flatMap((t) => t.comments.map((c) => c.author)),
1980 );
1981 return threads.map((t) => ({ ...t, comments: t.comments.map((c) => ({ ...c, author: people.get(c.author)! })) }));
1982 }
1983
1984 async threads(a: { workspace: string; page_id: string; viewer: Viewer }): Promise<Result<DocThread[]>> {
1985 const found = await this.pageFor(a.workspace, a.page_id, a.viewer, "view");
1986 if (!found.ok) return found;
1987 return ok(await this.resolveThreads(found.value.workspace, await this.room(a.page_id).threads()));
1988 }
1989
1990 // ── Recall: the semantic index ──────────────────────────────────────────
1991
1992 /** Pages indexed again one after another (moved, restored). */
1993 private async reindexPages(ids: string[]): Promise<void> {
1994 for (const id of ids.slice(0, 500)) await indexPage(this.env, id);
1995 }
1996
1997 /** A query's embedding, kept a minute; null without an embedder or when it fails (then words only). */
1998 private async queryVector(query: string): Promise<number[] | null> {
1999 const { embedder } = adapters(this.env);
2000 const key = queryKey(query);
2001 if (!embedder || !key) return null;
2002 const cached = queryVectors.get(key);
2003 if (cached) return cached;
2004 try {
2005 const [vector] = await embedder.embed([key]);
2006 if (vector) queryVectors.set(key, vector);
2007 return vector ?? null;
2008 } catch (error) {
2009 console.error("docs could not embed a query; matching words instead", String(error));
2010 return null;
2011 }
2012 }
2013
2014 /** The passages nearest a vector, only from `allowed` spaces (by the index's filter, or after). */
2015 private async meaningMatches(workspaceId: string, allowed: string[], vector: number[]): Promise<{ id: string; score: number }[]> {
2016 const { store } = adapters(this.env);
2017 const plan = vectorQueryPlan(workspaceId, allowed);
2018 if (!store || !plan) return [];
2019 try {
2020 return await store.query(vector, { topK: plan.topK, filter: plan.filter });
2021 } catch (error) {
2022 console.error("docs semantic query failed; matching words instead", String(error));
2023 return [];
2024 }
2025 }
2026
2027 /**
2028 * Passages by id as they read now, with their page or file: only those
2029 * whose page is still out of the trash and whose file is still there.
2030 * A page's passages count as in the page's space now, whatever the index says.
2031 */
2032 private async passages(workspaceId: string, ids: string[]): Promise<Map<string, PassageRow>> {
2033 const out = new Map<string, PassageRow>();
2034 const unique = [...new Set(ids)];
2035 for (let i = 0; i < unique.length; i += 90) {
2036 const part = unique.slice(i, i + 90);
2037 const rows = await this.db
2038 .prepare(
2039 `SELECT c.id, c.page_id, c.repo_file_id, c.path, c.heading, c.text, c.updated_at,
2040 CASE WHEN c.page_id IS NOT NULL THEN p.space_id ELSE c.space_id END AS space_id,
2041 COALESCE(p.title, f.title) AS title, p.icon AS icon, p.updated_at AS page_updated_at
2042 FROM doc_chunks c
2043 LEFT JOIN pages p ON p.id = c.page_id
2044 LEFT JOIN repo_files f ON f.space_id = c.space_id AND f.path = c.path
2045 WHERE c.workspace_id = ? AND c.id IN (${part.map(() => "?").join(",")})
2046 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))`,
2047 )
2048 .bind(workspaceId, ...part)
2049 .all<PassageRow>();
2050 for (const r of rows.results) out.set(r.id, r);
2051 }
2052 return out;
2053 }
2054
2055 /** Indexes the workspace's pages and projects' docs again, on the queue. Owners only. */
2056 async reindexDocs(a: { workspace: string; viewer: Viewer }): Promise<Result<boolean>> {
2057 const found = await this.viewerWorkspace(a.workspace, a.viewer);
2058 if (!found.ok) return found;
2059 if (!this.viewerOwner(a.viewer!, a.workspace)) return fail("forbidden", "Only an owner can index the workspace's docs again.");
2060 return ok(await startBackfill(this.env, found.value.id, { force: true }));
2061 }
2062
2063 // ── Sockets and files ───────────────────────────────────────────────────
2064
2065 private viewerFrom(request: Request): Viewer {
2066 try {
2067 return JSON.parse(request.headers.get(DOCS_VIEWER_HEADER) ?? "null") as Viewer;
2068 } catch {
2069 return null;
2070 }
2071 }
2072
2073 /**
2074 * `GET /live?workspace=<slug>&page=<id>`, upgraded to a WebSocket. The
2075 * viewer comes in DOCS_VIEWER_HEADER, set by the site after checking the
2076 * session; trusted only because this Worker is reachable through service
2077 * bindings alone. Checked like any read, then handed to the page's room
2078 * with the viewer's role, which the room enforces.
2079 */
2080 async live(request: Request): Promise<Response> {
2081 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("Expected a WebSocket upgrade\n", { status: 426 });
2082 const viewer = this.viewerFrom(request);
2083 if (!viewer?.id) return new Response("Sign in to use Docs\n", { status: 401 });
2084 const url = new URL(request.url);
2085 const slug = (url.searchParams.get("workspace") ?? "").toLowerCase();
2086 const found = await this.pageFor(slug, url.searchParams.get("page") ?? "", viewer, "view");
2087 if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 });
2088 const { workspace, page, space } = found.value;
2089 if (page.archived_at) return new Response("That page is in the trash\n", { status: 410 });
2090 const room = this.room(page.id);
2091 await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2092 const key = this.userKey(viewer);
2093 const who: RoomMember = { page_id: page.id, workspace_slug: workspace.slug, key, member: (await this.profiles(workspace, [key])).get(key)!, role: space.role! };
2094 const headers = new Headers(request.headers);
2095 headers.delete(DOCS_VIEWER_HEADER);
2096 headers.set(ROOM_MEMBER_HEADER, JSON.stringify(who));
2097 return room.fetch(new Request(request.url, { method: "GET", headers }));
2098 }
2099
2100 /** `PUT /files?workspace=&page=&name=`: a file for a page, from someone who can edit it. */
2101 async upload(request: Request): Promise<Response> {
2102 const viewer = this.viewerFrom(request);
2103 const url = new URL(request.url);
2104 const found = await this.pageFor((url.searchParams.get("workspace") ?? "").toLowerCase(), url.searchParams.get("page") ?? "", viewer, "edit");
2105 if (!found.ok) return Response.json(found);
2106 const bytes = Number(request.headers.get("content-length") ?? "0");
2107 if (!bytes || bytes > DOC_MAX_FILE_BYTES) return Response.json(fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`));
2108 const refused = await uploadRefused(this.db, this.env, found.value.workspace, bytes);
2109 if (refused) return Response.json(fail("limit", refused));
2110 const name = safeName(url.searchParams.get("name") ?? "file");
2111 const contentType = servedType(request.headers.get("content-type") ?? "");
2112 const random = crypto.getRandomValues(new Uint8Array(32));
2113 const key = [...random].map((b) => b.toString(16).padStart(2, "0")).join("");
2114 const id = newId("fil");
2115 await fileStore(this.env).put(`docs/${key}`, request.body ?? new Uint8Array(), contentType);
2116 await this.db
2117 .prepare("INSERT INTO files (id, workspace_id, page_id, key, name, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
2118 .bind(id, found.value.workspace.id, found.value.page.id, key, name, contentType, bytes, this.userKey(viewer!), now())
2119 .run();
2120 const file: DocFile = { id, url: `/docs-files/${key}`, name, content_type: contentType, bytes };
2121 return Response.json(ok(file));
2122 }
2123
2124 /**
2125 * `GET /files/<key>`: a page's file, for the usercontent origin. The key
2126 * is 256 random bits, so knowing it is the permission, as with any
2127 * shared link; it is never on the site's own origin.
2128 */
2129 async file(key: string): Promise<Response> {
2130 const row = await this.db.prepare("SELECT name, content_type FROM files WHERE key = ?").bind(key).first<{ name: string; content_type: string }>();
2131 if (!row) return new Response("Not found\n", { status: 404 });
2132 const stored = await fileStore(this.env).get(`docs/${key}`);
2133 if (!stored) return new Response("Not found\n", { status: 404 });
2134 const inline = row.content_type !== "application/octet-stream";
2135 return new Response(stored.body, {
2136 headers: {
2137 "content-type": row.content_type,
2138 "content-length": String(stored.bytes),
2139 etag: stored.etag,
2140 "content-disposition": `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(row.name)}`,
2141 "cache-control": "private, max-age=31536000, immutable",
2142 },
2143 });
2144 }
2145}
2146
2147/** One RPC method's answer. */
2148async function answer(service: Docs, method: string, args: any): Promise<Response> {
2149 switch (method) {
2150 case "sidebar":
2151 return Response.json(await service.sidebar(args));
2152 case "home":
2153 return Response.json(await service.home(args));
2154 case "space":
2155 return Response.json(await service.space(args));
2156 case "spaces":
2157 return Response.json(await service.spaces(args));
2158 case "create_space":
2159 return Response.json(await service.createSpace(args));
2160 case "update_space":
2161 return Response.json(await service.updateSpace(args));
2162 case "set_space_member":
2163 return Response.json(await service.setSpaceMember(args));
2164 case "page":
2165 return Response.json(await service.page(args));
2166 case "create_page":
2167 return Response.json(await service.createPage(args));
2168 case "update_page":
2169 return Response.json(await service.updatePage(args));
2170 case "move_page":
2171 return Response.json(await service.movePage(args));
2172 case "duplicate_page":
2173 return Response.json(await service.duplicatePage(args));
2174 case "archive_page":
2175 return Response.json(await service.archivePage(args));
2176 case "restore_page":
2177 return Response.json(await service.restorePage(args));
2178 case "delete_page":
2179 return Response.json(await service.deletePage(args));
2180 case "trash":
2181 return Response.json(await service.trash(args));
2182 case "favorite":
2183 return Response.json(await service.favorite(args));
2184 case "search":
2185 return Response.json(await service.search(args));
2186 case "versions":
2187 return Response.json(await service.versions(args));
2188 case "version":
2189 return Response.json(await service.version(args));
2190 case "restore_version":
2191 return Response.json(await service.restoreVersion(args));
2192 case "templates":
2193 return Response.json(await service.templates(args));
2194 case "save_template":
2195 return Response.json(await service.saveTemplate(args));
2196 case "delete_template":
2197 return Response.json(await service.deleteTemplate(args));
2198 case "export_page":
2199 return Response.json(await service.exportPage(args));
2200 case "export_space":
2201 return Response.json(await service.exportSpace(args));
2202 case "suggestions":
2203 return Response.json(await service.suggestions(args));
2204 case "decide_suggestion":
2205 return Response.json(await service.decideSuggestion(args));
2206 case "accept_all":
2207 return Response.json(await service.acceptAll(args));
2208 case "thread":
2209 return Response.json(await service.thread(args));
2210 case "threads":
2211 return Response.json(await service.threads(args));
2212 case "mark_current":
2213 return Response.json(await service.markCurrent(args));
2214 case "stale_pages":
2215 return Response.json(await service.stalePages(args));
2216 case "add_repo_space":
2217 return Response.json(await service.addRepoSpace(args));
2218 case "remove_repo_space":
2219 return Response.json(await service.removeRepoSpace(args));
2220 case "repo_page":
2221 return Response.json(await service.repoPage(args));
2222 case "reindex_docs":
2223 return Response.json(await service.reindexDocs(args));
2224 default:
2225 return new Response("Unknown method\n", { status: 404 });
2226 }
2227}
2228
2229export default {
2230 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
2231 const url = new URL(request.url);
2232 const defer = (work: Promise<unknown>) => ctx.waitUntil(work);
2233 if (request.method === "GET" && url.pathname === "/live") return url.searchParams.has("folio") ? new Folios(env, defer).live(request) : new Docs(env, defer).live(request);
2234 if (request.method === "PUT" && url.pathname === "/files") return url.searchParams.has("folio") ? new Folios(env, defer).upload(request) : new Docs(env, defer).upload(request);
2235 const file = /^\/files\/([0-9a-f]{64})$/.exec(url.pathname);
2236 if ((request.method === "GET" || request.method === "HEAD") && file) return (await new Folios(env, defer).file(file[1]!)) ?? new Docs(env, defer).file(file[1]!);
2237 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
2238 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
2239 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2240 const opened = openD1(env.DB, request);
2241 const scoped = Object.create(env, { DB: { value: opened.db } }) as Env;
2242 const args = (await request.json().catch(() => ({}))) as any;
2243 try {
2244 // Folios first (src/folios/rpc.ts), then Docs' pages.
2245 const folio = folioHandler(match[1]!);
2246 if (folio) return opened.finish(Response.json(await folio(new Folios(scoped, defer), args)));
2247 const service = new Docs(scoped, defer);
2248 return opened.finish(await answer(service, match[1]!, args));
2249 } catch (error) {
2250 console.error("docs:", match[1], error);
2251 return opened.finish(Response.json(fail("conflict", "Docs couldn't do that just now. Try again.")));
2252 }
2253 },
2254
2255 /**
2256 * Events from the events service (SUBSCRIBER_DOCS): pages whose cited
2257 * code changed become possibly out of date, and projects' docs are read
2258 * again after a push (src/staleness.ts) and their passages indexed
2259 * (src/indexer.ts). The same queue carries this service's own backfill
2260 * jobs (`docs.index`). One failing message is retried on its own.
2261 */
2262 async queue(batch: MessageBatch<G1tEvent | DocsJob>, env: Env): Promise<void> {
2263 const reindex = async (repoId: string) => {
2264 if (env.REPOS) await reindexRepo({ DB: env.DB, REPOS: env.REPOS }, repoId, (spaceId, changed, gone) => indexRepoFiles(env, spaceId, changed, gone));
2265 };
2266 for (const message of batch.messages) {
2267 try {
2268 const body = message.body;
2269 if (body.type === "docs.index") {
2270 await runBackfill(env, (body as Extract<DocsJob, { type: "docs.index" }>).workspace_id);
2271 message.ack();
2272 continue;
2273 }
2274 if (body.type === "folios.reacl") {
2275 await runReacl(env, (body as Extract<DocsJob, { type: "folios.reacl" }>).folio_id);
2276 message.ack();
2277 continue;
2278 }
2279 if (body.type === "repo.purged") {
2280 // Its docs leave Docs (src/staleness.ts); their passages leave the index first.
2281 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 }>();
2282 for (const space of gone.results) await forgetDocs(env, { space_id: space.id });
2283 }
2284 await onEvent(env, body as G1tEvent, reindex);
2285 message.ack();
2286 } catch (error) {
2287 console.error("docs could not handle", message.body?.type, String(error));
2288 message.retry();
2289 }
2290 }
2291 },
2292
2293 /** Daily (wrangler.jsonc `triggers`): folios in the trash for over 30 days are deleted for good. */
2294 async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
2295 ctx.waitUntil(
2296 purgeTrash(env)
2297 .then((n) => {
2298 if (n) console.log("folios purged from the trash", n);
2299 })
2300 .catch((error: unknown) => console.error("folios could not purge the trash", String(error))),
2301 );
2302 },
2303} satisfies ExportedHandler<Env, G1tEvent | DocsJob>;