Skip to content

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

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