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