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