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.

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