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 | * Folios (Artifacts mode): the artifacts service's answers to every method in | |
| 3 | * FOLIO_RPC_METHODS (packages/contracts folios.ts, `foliosClient`), its | |
| 4 | * live socket (`GET /live?folio=`) and uploads (`PUT /files?folio=`). | |
| 5 | * | |
| 6 | * Every read goes through one rule (src/access.ts `effectiveRole`) over | |
| 7 | * the folio's chain, after the list SQL's coarse filter (`folio_access`, | |
| 8 | * readable spaces, general access, link visits). Everything that changes | |
| 9 | * a folio's content goes through its room (src/folios/room.ts); this | |
| 10 | * class decides who may ask. Agents act for a person and never reach | |
| 11 | * more than that person can, narrowed to their audience | |
| 12 | * (src/folios/agents.ts). | |
| 13 | */ | |
| 14 | import { | |
| 15 | DOCS_VIEWER_HEADER, | |
| 16 | DOC_MAX_FILE_BYTES, | |
| 17 | FOLIO_INLINE_REACL, | |
| 18 | FOLIO_KIND_LABELS, | |
| 19 | FOLIO_MAX_SHARE, | |
| 20 | fail, | |
| 21 | folioAccessChangeError, | |
| 22 | folioAgentEditError, | |
| 23 | folioListQueryError, | |
| 24 | identityClient, | |
| 25 | isFolioKind, | |
| 26 | isFolioPrincipal, | |
| 27 | newFolioError, | |
| 28 | newId, | |
| 29 | notifyClient, | |
| 30 | ok, | |
| 31 | parsePrincipalKey, | |
| 32 | principalKey, | |
| 33 | reposClient, | |
| 34 | type DocAgentMode, | |
| 35 | type DocAudience, | |
| 36 | type DocCitation, | |
| 37 | type DocEditTarget, | |
| 38 | type DocRepoSpace, | |
| 39 | type DocRole, | |
| 40 | type DocSuggestion, | |
| 41 | type DocThread, | |
| 42 | type DocThreadAction, | |
| 43 | type Folio, | |
| 44 | type FolioAccessChange, | |
| 45 | type FolioAccessList, | |
| 46 | type FolioAccessRow, | |
| 47 | type FolioAgentEdit, | |
| 48 | type FolioAgentEditResult, | |
| 49 | type FolioAgentRead, | |
| 50 | type FolioChange, | |
| 51 | type FolioContentInput, | |
| 52 | type FolioKind, | |
| 53 | type FolioList, | |
| 54 | type FolioListQuery, | |
| 55 | type FolioMove, | |
| 56 | type FolioPage, | |
| 57 | type FolioPassage, | |
| 58 | type FolioProposal, | |
| 59 | type FolioRef, | |
| 60 | type FolioSearchHit, | |
| 61 | type FolioSuggestion, | |
| 62 | type FolioTemplate, | |
| 63 | type FolioTreeNode, | |
| 64 | type FolioVersion, | |
| 65 | type FolioVersionDetail, | |
| 66 | type FoliosLiveEvent, | |
| 67 | type FoliosSidebar, | |
| 68 | type FoliosSidebarSpace, | |
| 69 | type MemberProfile, | |
| 70 | type Repo, | |
| 71 | type Result, | |
| 72 | type ServiceBinding, | |
| 73 | type User, | |
| 74 | type Viewer, | |
| 75 | type Workspace, | |
| 76 | type WorkspaceAgent, | |
| 77 | } from "@g1t/contracts"; | |
| 78 | ||
| 79 | import { RANK, aclChain, atLeast, canShare, explicitAccess, inheritsSpace, isPrivateFolio, isRole, personKeys, type Person, type SpaceRules } from "../access.ts"; | |
| 80 | import { diffLines } from "../diff.ts"; | |
| 81 | import { fileStore, safeName, servedType } from "../files.ts"; | |
| 82 | import { adapters, folioAdapters, forgetFolios, indexFolio, startBackfill, ensureIndexed, type DocsJob } from "../indexer.ts"; | |
| 83 | import { kindModel } from "../kinds/index.ts"; | |
| 84 | import type { FolioOrigin } from "../kinds/types.ts"; | |
| 85 | import { excerpt, searchText } from "../markdown.ts"; | |
| 86 | import { QueryCache, fuseRanks, pickPassages, queryKey, recallLimit, vectorQueryPlan, MEANING_FLOOR, WORDS_SCORE, type Candidate } from "../recall.ts"; | |
| 87 | import type { RepoSpaceRow } from "../repo-spaces.ts"; | |
| 88 | import { ROOM_MEMBER_HEADER } from "../room.ts"; | |
| 89 | import { ftsAnyQuery, ftsQuery, projectRef } from "../search.ts"; | |
| 90 | import type { ThreadResult } from "../threads.ts"; | |
| 91 | import { placeBefore } from "../tree.ts"; | |
| 92 | import { Who, now, rulesOf, userKey, type Space, type WhoEnv } from "../who.ts"; | |
| 93 | import { | |
| 94 | FOLIO_COLUMNS, | |
| 95 | aclNode, | |
| 96 | ancestry, | |
| 97 | folioColumns, | |
| 98 | foliosById, | |
| 99 | json, | |
| 100 | readableWhere, | |
| 101 | rebuildSubtree, | |
| 102 | rolesFrom, | |
| 103 | runBatches, | |
| 104 | subtree, | |
| 105 | visitsOf, | |
| 106 | workspaceReadable, | |
| 107 | type Ancestry, | |
| 108 | type FolioRow, | |
| 109 | type ReaderContext, | |
| 110 | } from "./access-store.ts"; | |
| 111 | import { agentMayFind, agentReach, audienceRule, type AgentReach, type AudienceRule } from "./agents.ts"; | |
| 112 | import { publishFolioEvent } from "./events.ts"; | |
| 113 | import { MAX_DEPTH, cleanCover, cleanIcon, cleanNote, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, sharedTops, slugOf, subtreeHeight, treeNodes } from "./list.ts"; | |
| 114 | import { REQUEST_RECIPIENTS, claimAccessRequest } from "./requests.ts"; | |
| 115 | import type { FolioRoom } from "./room.ts"; | |
| 116 | import { builtinFolioTemplate, builtinFolioTemplates } from "./templates.ts"; | |
| 117 | ||
| 118 | export type FoliosEnv = WhoEnv & { | |
| 119 | FOLIOS: DurableObjectNamespace<FolioRoom>; | |
| 120 | NOTIFY?: ServiceBinding; | |
| 121 | EVENTS?: ServiceBinding; | |
| 122 | REPOS?: ServiceBinding; | |
| 123 | AI?: Ai; | |
| 124 | VECTORS?: Vectorize; | |
| 125 | FOLIO_VECTORS?: Vectorize; | |
| 126 | JOBS?: Queue<DocsJob>; | |
| 127 | FILES?: R2Bucket; | |
| 128 | DOCS_FILES?: string; | |
| 129 | DOCS_S3_ENDPOINT?: string; | |
| 130 | DOCS_S3_BUCKET?: string; | |
| 131 | DOCS_S3_REGION?: string; | |
| 132 | DOCS_S3_ACCESS_KEY_ID?: string; | |
| 133 | DOCS_S3_SECRET_ACCESS_KEY?: string; | |
| 134 | DOCS_S3_VIRTUAL_HOSTED?: string; | |
| 135 | }; | |
| 136 | ||
| 137 | type Args = { workspace: string; viewer: Viewer }; | |
| 138 | type AgentArgs = Args & { agent_id: string; audience?: DocAudience | null }; | |
| 139 | ||
| 140 | /** The viewer in their workspace, with their spaces. */ | |
| 141 | type Ctx = { workspace: Workspace; viewer: User; key: string; person: Person; spaces: Space[]; spaceById: Map<string, Space>; owner: boolean }; | |
| 142 | ||
| 143 | /** An agent's turn: its asker's context, the agent, and who will see the answer. */ | |
| 144 | type AgentCtx = Ctx & { agent: WorkspaceAgent; agentKey: string; rule: AudienceRule; people: Person[]; audienceIds: string[] }; | |
| 145 | ||
| 146 | type SuggestionRow = { | |
| 147 | id: string; | |
| 148 | folio_id: string; | |
| 149 | author: string; | |
| 150 | asked_by: string | null; | |
| 151 | target: string; | |
| 152 | before_markdown: string; | |
| 153 | after_markdown: string; | |
| 154 | note: string | null; | |
| 155 | status: DocSuggestion["status"]; | |
| 156 | created_at: string; | |
| 157 | decided_by: string | null; | |
| 158 | decided_at: string | null; | |
| 159 | marks_current: number; | |
| 160 | }; | |
| 161 | ||
| 162 | type VersionRow = { id: string; folio_id: string; created_at: string; kind: FolioVersion["kind"]; authors: string; note: string | null; text: string; state: ArrayBuffer | null; state_key: string | null }; | |
| 163 | ||
| 164 | /** Queries' embeddings, a minute per isolate. */ | |
| 165 | const queryVectors = new QueryCache(); | |
| 166 | ||
| 167 | /** Open rooms told of an access change inline; a larger subtree's go with the queue job. */ | |
| 168 | const INLINE_ROOMS = 200; | |
| 169 | const MAX_TEXT = 512 * 1024; | |
| 170 | ||
| 171 | const parseJson = <T>(value: string | null | undefined, fallback: T): T => { | |
| 172 | if (!value) return fallback; | |
| 173 | try { | |
| 174 | return JSON.parse(value) as T; | |
| 175 | } catch { | |
| 176 | return fallback; | |
| 177 | } | |
| 178 | }; | |
| 179 | ||
| 180 | const kindLabel = (kind: FolioKind) => FOLIO_KIND_LABELS[kind] ?? kind; | |
| 181 | ||
| 182 | export class Folios { | |
| 183 | readonly who: Who; | |
| 184 | ||
| 185 | constructor( | |
| 186 | private readonly env: FoliosEnv, | |
| 187 | private readonly defer: (work: Promise<unknown>) => void = () => {}, | |
| 188 | ) { | |
| 189 | this.who = new Who(env); | |
| 190 | } | |
| 191 | ||
| 192 | private get db() { | |
| 193 | return this.env.DB; | |
| 194 | } | |
| 195 | ||
| 196 | room(folioId: string) { | |
| 197 | return this.env.FOLIOS.get(this.env.FOLIOS.idFromName(folioId)); | |
| 198 | } | |
| 199 | ||
| 200 | private tell(folioId: string, event: FoliosLiveEvent): void { | |
| 201 | this.defer( | |
| 202 | this.room(folioId) | |
| 203 | .notice(event) | |
| 204 | .catch((error: unknown) => console.error("folios could not tell a room", folioId, String(error))), | |
| 205 | ); | |
| 206 | } | |
| 207 | ||
| 208 | /** The room, named and given its kind and (when empty) its saved text. */ | |
| 209 | private async ready(workspace: Workspace, row: FolioRow) { | |
| 210 | const room = this.room(row.id); | |
| 211 | let text = row.text; | |
| 212 | if (!text) text = (await this.db.prepare("SELECT text FROM folios WHERE id = ?").bind(row.id).first<{ text: string }>())?.text ?? ""; | |
| 213 | await room.ensure({ folio_id: row.id, kind: row.kind, workspace_slug: workspace.slug, text }); | |
| 214 | return room; | |
| 215 | } | |
| 216 | ||
| 217 | // ── Who, where, and what they may do ──────────────────────────────────── | |
| 218 | ||
| 219 | private async ctx(slug: string, viewer: Viewer): Promise<Result<Ctx>> { | |
| 220 | const found = await this.who.viewerWorkspace(slug, viewer); | |
| 221 | if (!found.ok) return found; | |
| 222 | const workspace = found.value; | |
| 223 | const user = viewer!; | |
| 224 | await this.who.ensureDefault(workspace, user); | |
| 225 | const person = await this.who.viewerPerson(workspace, user); | |
| 226 | const spaces = await this.who.spacesFor(workspace, person); | |
| 227 | return ok({ workspace, viewer: user, key: userKey(user), person, spaces, spaceById: new Map(spaces.map((s) => [s.row.id, s])), owner: this.who.viewerOwner(user, workspace.slug) }); | |
| 228 | } | |
| 229 | ||
| 230 | private reader(ctx: Ctx, visits: ReadonlySet<string>): ReaderContext { | |
| 231 | return { person: ctx.person, spaceRole: (id) => ctx.spaceById.get(id)?.role ?? null, visits }; | |
| 232 | } | |
| 233 | ||
| 234 | /** The viewer's role on each row, from each one's whole chain. */ | |
| 235 | private async roles(ctx: Ctx, rows: FolioRow[], extraVisits: string[] = []): Promise<{ roles: Map<string, DocRole | null>; found: Ancestry }> { | |
| 236 | const [found, visits] = await Promise.all([ancestry(this.db, rows), visitsOf(this.db, ctx.viewer.id, rows)]); | |
| 237 | for (const id of extraVisits) visits.add(id); | |
| 238 | return { roles: rolesFrom(found, rows, this.reader(ctx, visits)), found }; | |
| 239 | } | |
| 240 | ||
| 241 | /** | |
| 242 | * A folio the viewer may `need`-access, or not found when they can't | |
| 243 | * read it at all. `opening` counts as opening its link (the `folio` | |
| 244 | * read and the live socket), which is what makes a link folio readable. | |
| 245 | */ | |
| 246 | private async open(ctx: Ctx, folioId: unknown, need: DocRole, options: { trashed?: boolean; opening?: boolean; text?: boolean } = {}): Promise<Result<{ row: FolioRow; role: DocRole; found: Ancestry }>> { | |
| 247 | const columns = options.text ? FOLIO_COLUMNS.replace("'' AS text", "text") : FOLIO_COLUMNS; | |
| 248 | const row = await this.db.prepare(`SELECT ${columns} FROM folios WHERE id = ? AND workspace_id = ?`).bind(String(folioId ?? ""), ctx.workspace.id).first<FolioRow>(); | |
| 249 | if (!row) return fail("not_found", "No such artifact."); | |
| 250 | if (row.trashed_at && !options.trashed) return fail("not_found", "That artifact is in the trash."); | |
| 251 | const { roles, found } = await this.roles(ctx, [row], options.opening ? [row.id] : []); | |
| 252 | const role = roles.get(row.id) ?? null; | |
| 253 | if (!role) return fail("not_found", "No such artifact."); | |
| 254 | if (!atLeast(role, need)) { | |
| 255 | const message = need === "comment" ? "You can read this but not comment on it." : need === "manage" ? "Only people with full access can do that." : "You can read this but not change it."; | |
| 256 | return fail("forbidden", message); | |
| 257 | } | |
| 258 | return ok({ row, role, found }); | |
| 259 | } | |
| 260 | ||
| 261 | private agentMode(row: Pick<FolioRow, "agent_mode" | "space_id">, ctx: Ctx): DocAgentMode { | |
| 262 | return row.agent_mode ?? (row.space_id ? ctx.spaceById.get(row.space_id)?.row.agent_mode : null) ?? "suggest"; | |
| 263 | } | |
| 264 | ||
| 265 | ref(slug: string, row: Pick<FolioRow, "id" | "kind" | "title" | "icon">): FolioRef { | |
| 266 | const s = slugOf(row.title, row.id); | |
| 267 | return { id: row.id, kind: row.kind, title: row.title, icon: row.icon, slug: s, path: `/${slug}/-/artifacts/${s}` }; | |
| 268 | } | |
| 269 | ||
| 270 | /** Folios as lists and pages show them, for the viewer. Rows without a role are left out. */ | |
| 271 | private async toFolios(ctx: Ctx, rows: FolioRow[], known?: { roles: Map<string, DocRole | null>; found: Ancestry }): Promise<Folio[]> { | |
| 272 | if (!rows.length) return []; | |
| 273 | const { roles, found } = known ?? (await this.roles(ctx, rows)); | |
| 274 | const readable = rows.filter((r) => roles.get(r.id)); | |
| 275 | if (!readable.length) return []; | |
| 276 | const ids = readable.map((r) => r.id); | |
| 277 | const [favorites, counts, kids, stale] = await Promise.all([ | |
| 278 | this.db.prepare("SELECT folio_id FROM folio_favorites WHERE user_id = ? AND folio_id IN (SELECT value FROM json_each(?))").bind(ctx.viewer.id, json(ids)).all<{ folio_id: string }>(), | |
| 279 | this.db.prepare("SELECT folio_id, COUNT(*) AS n FROM folio_grants WHERE folio_id IN (SELECT value FROM json_each(?)) GROUP BY folio_id").bind(json(ids)).all<{ folio_id: string; n: number }>(), | |
| 280 | this.db.prepare("SELECT DISTINCT parent_id FROM folios WHERE parent_id IN (SELECT value FROM json_each(?)) AND trashed_at IS NULL").bind(json(ids)).all<{ parent_id: string }>(), | |
| 281 | this.staleIds(ids), | |
| 282 | ]); | |
| 283 | const people = await this.who.profiles( | |
| 284 | ctx.workspace, | |
| 285 | readable.flatMap((r) => [r.owner, r.created_by, ...(r.edited_by ? [r.edited_by] : [])]), | |
| 286 | ); | |
| 287 | const fav = new Set(favorites.results.map((f) => f.folio_id)); | |
| 288 | const shared = new Map(counts.results.map((c) => [c.folio_id, c.n])); | |
| 289 | const parents = new Set(kids.results.map((k) => k.parent_id)); | |
| 290 | return readable.map((row) => { | |
| 291 | const chain = aclChain(row.id, found.nodes); | |
| 292 | const root = chain[chain.length - 1] ?? aclNode(row); | |
| 293 | const space = row.space_id ? ctx.spaceById.get(row.space_id) : undefined; | |
| 294 | const parent = row.parent_id ? found.rows.get(row.parent_id) : undefined; | |
| 295 | let inherited: Folio["inherited_from"] = null; | |
| 296 | if (row.inherit && parent) inherited = { kind: "folio", id: parent.id, name: parent.title || "Untitled" }; | |
| 297 | else if (row.inherit && !row.parent_id && space) inherited = { kind: "space", id: space.row.id, name: space.row.name }; | |
| 298 | const preview = parseJson<Folio["preview"]>(row.preview, null); | |
| 299 | return { | |
| 300 | ...this.ref(ctx.workspace.slug, row), | |
| 301 | workspace_id: row.workspace_id, | |
| 302 | space: space ? { id: space.row.id, slug: space.row.slug, name: space.row.name, kind: space.row.kind } : null, | |
| 303 | parent_id: row.parent_id, | |
| 304 | position: row.position, | |
| 305 | owner: people.get(row.owner)!, | |
| 306 | created_by: people.get(row.created_by)!, | |
| 307 | created_at: row.created_at, | |
| 308 | updated_at: row.updated_at, | |
| 309 | edited_by: row.edited_by ? (people.get(row.edited_by) ?? null) : null, | |
| 310 | edited_at: row.edited_at, | |
| 311 | trashed_at: row.trashed_at, | |
| 312 | viewer_role: roles.get(row.id)!, | |
| 313 | favorite: fav.has(row.id), | |
| 314 | private: isPrivateFolio(chain, found.grants), | |
| 315 | shared_count: shared.get(row.id) ?? 0, | |
| 316 | general_access: root.general_access, | |
| 317 | general_role: root.general_access === "none" ? null : ((root.general_role as Folio["general_role"]) ?? "view"), | |
| 318 | inherit: !!row.inherit, | |
| 319 | inherited_from: inherited, | |
| 320 | agent_mode: this.agentMode(row, ctx), | |
| 321 | excerpt: row.excerpt, | |
| 322 | preview, | |
| 323 | source: parseJson<Folio["source"]>(row.source, null), | |
| 324 | stale: stale.has(row.id), | |
| 325 | has_children: parents.has(row.id), | |
| 326 | }; | |
| 327 | }); | |
| 328 | } | |
| 329 | ||
| 330 | private async staleIds(ids: string[]): Promise<Set<string>> { | |
| 331 | if (!ids.length) return new Set(); | |
| 332 | const rows = await this.db | |
| 333 | .prepare("SELECT DISTINCT folio_id FROM folio_changes WHERE cleared_at IS NULL AND folio_id IN (SELECT value FROM json_each(?))") | |
| 334 | .bind(json(ids)) | |
| 335 | .all<{ folio_id: string }>(); | |
| 336 | return new Set(rows.results.map((r) => r.folio_id)); | |
| 337 | } | |
| 338 | ||
| 339 | private async folioOf(ctx: Ctx, row: FolioRow): Promise<Folio> { | |
| 340 | const fresh = (await foliosById(this.db, [row.id])).get(row.id) ?? row; | |
| 341 | const [folio] = await this.toFolios(ctx, [fresh]); | |
| 342 | return folio!; | |
| 343 | } | |
| 344 | ||
| 345 | /** The keys and spaces the list filter reads. */ | |
| 346 | private filterOf(ctx: Ctx) { | |
| 347 | return readableWhere( | |
| 348 | personKeys(ctx.person), | |
| 349 | ctx.spaces.filter((s) => s.role).map((s) => s.row.id), | |
| 350 | ctx.viewer.id, | |
| 351 | ); | |
| 352 | } | |
| 353 | ||
| 354 | // ── Lists ─────────────────────────────────────────────────────────────── | |
| 355 | ||
| 356 | async list(a: Args & { query: FolioListQuery }): Promise<Result<FolioList>> { | |
| 357 | const query = a.query ?? ({ tab: "all" } as FolioListQuery); | |
| 358 | const invalid = folioListQueryError({ ...query, tab: query.tab ?? "all" }); | |
| 359 | if (invalid) return fail("invalid", invalid); | |
| 360 | const found = await this.ctx(a.workspace, a.viewer); | |
| 361 | if (!found.ok) return found; | |
| 362 | return ok(await this.listFor(found.value, { ...query, tab: query.tab ?? "all" })); | |
| 363 | } | |
| 364 | ||
| 365 | private async listFor(ctx: Ctx, query: FolioListQuery): Promise<FolioList> { | |
| 366 | const limit = listLimit(query.limit); | |
| 367 | if (query.q && ftsQuery(query.q)) { | |
| 368 | // Words or meaning: the search's order, the list's filters. | |
| 369 | const hits = await this.searchFor(ctx, { q: query.q, kinds: query.kinds, space_id: query.space_id, project: query.project, owner: query.owner, mode: "hybrid", limit }); | |
| 370 | const rows = await foliosById( | |
| 371 | this.db, | |
| 372 | hits.map((h) => h.id), | |
| 373 | ); | |
| 374 | const ordered = hits.map((h) => rows.get(h.id)).filter((r): r is FolioRow => !!r && (query.tab !== "yours" || r.owner === ctx.key) && (query.tab !== "shared" || r.owner !== ctx.key)); | |
| 375 | return { items: await this.toFolios(ctx, ordered), next_cursor: null }; | |
| 376 | } | |
| 377 | const keys = personKeys(ctx.person); | |
| 378 | const where: string[] = ["f.workspace_id = ?", "f.trashed_at IS NULL"]; | |
| 379 | const binds: unknown[] = [ctx.workspace.id]; | |
| 380 | let sortKey = "f.edited_at"; | |
| 381 | const sortBinds: unknown[] = []; | |
| 382 | if (query.tab === "yours") { | |
| 383 | where.push("f.owner = ?"); | |
| 384 | binds.push(ctx.key); | |
| 385 | } else if (query.tab === "shared") { | |
| 386 | where.push( | |
| 387 | "f.owner <> ?", | |
| 388 | `(f.id IN (SELECT folio_id FROM folio_access WHERE principal IN (SELECT value FROM json_each(?)) AND via <> 'owner') OR (r.general_access = 'link' AND EXISTS (SELECT 1 FROM folio_visits v WHERE v.user_id = ? AND (v.folio_id = f.id OR v.folio_id = f.acl_root))))`, | |
| 389 | ); | |
| 390 | binds.push(ctx.key, json(keys), ctx.viewer.id); | |
| 391 | sortKey = "MAX(f.edited_at, COALESCE((SELECT MAX(a.since) FROM folio_access a WHERE a.folio_id = f.id AND a.principal IN (SELECT value FROM json_each(?))), ''))"; | |
| 392 | sortBinds.push(json(keys)); | |
| 393 | } else { | |
| 394 | const filter = this.filterOf(ctx); | |
| 395 | where.push(filter.sql); | |
| 396 | binds.push(...filter.binds); | |
| 397 | } | |
| 398 | if (query.kinds?.length) { | |
| 399 | where.push("f.kind IN (SELECT value FROM json_each(?))"); | |
| 400 | binds.push(json(query.kinds)); | |
| 401 | } | |
| 402 | if (query.space_id === "private") where.push("f.space_id IS NULL"); | |
| 403 | else if (query.space_id) { | |
| 404 | where.push("f.space_id = ?"); | |
| 405 | binds.push(query.space_id); | |
| 406 | } | |
| 407 | if (query.owner) { | |
| 408 | where.push("f.owner = ?"); | |
| 409 | binds.push(query.owner); | |
| 410 | } | |
| 411 | const project = query.project ? projectRef(query.project) : null; | |
| 412 | if (query.project && !project) return { items: [], next_cursor: null }; | |
| 413 | if (project) { | |
| 414 | where.push("(f.id IN (SELECT folio_id FROM folio_projects WHERE repo = ?) OR f.space_id IN (SELECT space_id FROM space_projects WHERE repo = ?))"); | |
| 415 | binds.push(project, project); | |
| 416 | } | |
| 417 | const cursor = decodeCursor(query.cursor); | |
| 418 | if (cursor) { | |
| 419 | where.push(`(${sortKey} < ? OR (${sortKey} = ? AND f.id < ?))`); | |
| 420 | binds.push(...sortBinds, cursor.k, ...sortBinds, cursor.k, cursor.id); | |
| 421 | } | |
| 422 | const rows = ( | |
| 423 | await this.db | |
| 424 | .prepare(`SELECT ${folioColumns("f")}, ${sortKey} AS sort_key FROM folios f JOIN folios r ON r.id = f.acl_root WHERE ${where.join(" AND ")} ORDER BY sort_key DESC, f.id DESC LIMIT ?`) | |
| 425 | .bind(...sortBinds, ...binds, limit + 1) | |
| 426 | .all<FolioRow & { sort_key: string }>() | |
| 427 | ).results; | |
| 428 | const page = rows.slice(0, limit); | |
| 429 | const last = page[page.length - 1]; | |
| 430 | return { items: await this.toFolios(ctx, page), next_cursor: rows.length > limit && last ? encodeCursor({ k: last.sort_key, id: last.id }) : null }; | |
| 431 | } | |
| 432 | ||
| 433 | async sidebar(a: Args): Promise<Result<FoliosSidebar>> { | |
| 434 | const found = await this.ctx(a.workspace, a.viewer); | |
| 435 | if (!found.ok) return found; | |
| 436 | const ctx = found.value; | |
| 437 | const joins = new Set( | |
| 438 | (await this.db.prepare("SELECT space_id FROM space_joins WHERE user_id = ?").bind(ctx.viewer.id).all<{ space_id: string }>()).results.map((r) => r.space_id), | |
| 439 | ); | |
| 440 | // Joined open spaces (General always), team spaces of theirs, Members-only spaces they're in. | |
| 441 | const shown = ctx.spaces.filter((s) => s.role && !s.row.archived_at && (s.row.kind !== "workspace" || s.row.is_default || joins.has(s.row.id))); | |
| 442 | const keys = personKeys(ctx.person); | |
| 443 | const [spaceRows, privateRows, sharedRows, favoriteRows, repos, trashed] = await Promise.all([ | |
| 444 | shown.length | |
| 445 | ? this.db | |
| 446 | .prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE workspace_id = ? AND trashed_at IS NULL AND space_id IN (SELECT value FROM json_each(?)) ORDER BY position LIMIT 5000`) | |
| 447 | .bind( | |
| 448 | ctx.workspace.id, | |
| 449 | json(shown.map((s) => s.row.id)), | |
| 450 | ) | |
| 451 | .all<FolioRow>() | |
| 452 | : Promise.resolve({ results: [] as FolioRow[] }), | |
| 453 | // Their Private: everything under a top-level Private folio of theirs. | |
| 454 | this.db | |
| 455 | .prepare( | |
| 456 | `SELECT ${folioColumns("f")} FROM folios f JOIN folios t ON t.id = substr(f.path, 2, instr(substr(f.path, 2), '/') - 1) | |
| 457 | WHERE f.workspace_id = ? AND f.space_id IS NULL AND f.trashed_at IS NULL AND t.owner = ? ORDER BY f.position LIMIT 2000`, | |
| 458 | ) | |
| 459 | .bind(ctx.workspace.id, ctx.key) | |
| 460 | .all<FolioRow>(), | |
| 461 | this.db | |
| 462 | .prepare( | |
| 463 | `SELECT ${folioColumns("f")} FROM folios f JOIN folios r ON r.id = f.acl_root | |
| 464 | WHERE f.workspace_id = ? AND f.trashed_at IS NULL AND f.owner <> ? | |
| 465 | AND (f.id IN (SELECT folio_id FROM folio_access WHERE principal IN (SELECT value FROM json_each(?))) | |
| 466 | OR (r.general_access = 'link' AND EXISTS (SELECT 1 FROM folio_visits v WHERE v.user_id = ? AND (v.folio_id = f.id OR v.folio_id = f.acl_root)))) | |
| 467 | ORDER BY f.edited_at DESC LIMIT 300`, | |
| 468 | ) | |
| 469 | .bind(ctx.workspace.id, ctx.key, json(keys), ctx.viewer.id) | |
| 470 | .all<FolioRow>(), | |
| 471 | this.db | |
| 472 | .prepare(`SELECT ${folioColumns("f")} FROM folio_favorites v JOIN folios f ON f.id = v.folio_id WHERE v.user_id = ? AND f.workspace_id = ? AND f.trashed_at IS NULL ORDER BY v.position`) | |
| 473 | .bind(ctx.viewer.id, ctx.workspace.id) | |
| 474 | .all<FolioRow>(), | |
| 475 | this.repoSpacesFor(ctx).catch((error: unknown) => { | |
| 476 | console.error("folios could not list projects' docs", String(error)); | |
| 477 | return [] as DocRepoSpace[]; | |
| 478 | }), | |
| 479 | this.trashedFor(ctx, 200), | |
| 480 | ]); | |
| 481 | const all = [...spaceRows.results, ...privateRows.results, ...sharedRows.results, ...favoriteRows.results]; | |
| 482 | const unique = [...new Map(all.map((r) => [r.id, r])).values()]; | |
| 483 | const { roles } = await this.roles(ctx, unique); | |
| 484 | const can = (r: FolioRow) => !!roles.get(r.id); | |
| 485 | const inSpaces = spaceRows.results.filter(can); | |
| 486 | const mine = privateRows.results.filter(can); | |
| 487 | const stale = await this.staleIds([...inSpaces, ...mine].map((r) => r.id)); | |
| 488 | const elsewhere = new Set([...inSpaces, ...mine].map((r) => r.id)); | |
| 489 | const spaceCounts = new Map<string, number>(); | |
| 490 | for (const r of inSpaces) spaceCounts.set(r.space_id!, (spaceCounts.get(r.space_id!) ?? 0) + 1); | |
| 491 | const spaces: FoliosSidebarSpace[] = shown.map((s) => ({ | |
| 492 | ...this.who.toSpace(s, spaceCounts.get(s.row.id) ?? 0), | |
| 493 | joined: s.row.kind !== "workspace" || !!s.row.is_default || joins.has(s.row.id), | |
| 494 | tree: treeNodes( | |
| 495 | inSpaces.filter((r) => r.space_id === s.row.id), | |
| 496 | stale, | |
| 497 | ), | |
| 498 | })); | |
| 499 | const ref = (r: FolioRow) => this.ref(ctx.workspace.slug, r); | |
| 500 | return ok({ | |
| 501 | favorites: favoriteRows.results.filter(can).map(ref), | |
| 502 | spaces, | |
| 503 | private_tree: treeNodes(mine, stale), | |
| 504 | shared: sharedTops(sharedRows.results.filter(can), elsewhere).slice(0, 100).map(ref), | |
| 505 | repos, | |
| 506 | can_create_space: true, | |
| 507 | trash_count: trashed.length, | |
| 508 | stale_count: stale.size, | |
| 509 | }); | |
| 510 | } | |
| 511 | ||
| 512 | /** | |
| 513 | * A folio for the viewer. Reading it opens it, which records the visit | |
| 514 | * that makes a link folio readable; a `peek` (chat's link card) does | |
| 515 | * neither, so a link folio they never opened is not found. | |
| 516 | */ | |
| 517 | async folio(a: Args & { folio_id: string; peek?: boolean | null }): Promise<Result<Folio>> { | |
| 518 | const found = await this.ctx(a.workspace, a.viewer); | |
| 519 | if (!found.ok) return found; | |
| 520 | const ctx = found.value; | |
| 521 | const peek = a.peek === true; | |
| 522 | const opened = await this.open(ctx, a.folio_id, "view", { trashed: !peek, opening: !peek }); | |
| 523 | if (!opened.ok) return opened; | |
| 524 | if (peek) { | |
| 525 | const [folio] = await this.toFolios(ctx, [opened.value.row], { roles: new Map([[opened.value.row.id, opened.value.role]]), found: opened.value.found }); | |
| 526 | return ok(folio!); | |
| 527 | } | |
| 528 | const at = now(); | |
| 529 | this.defer( | |
| 530 | this.db | |
| 531 | .prepare("INSERT INTO folio_visits (folio_id, user_id, first_at, last_at) VALUES (?, ?, ?, ?) ON CONFLICT (folio_id, user_id) DO UPDATE SET last_at = excluded.last_at") | |
| 532 | .bind(opened.value.row.id, ctx.viewer.id, at, at) | |
| 533 | .run(), | |
| 534 | ); | |
| 535 | const [folio] = await this.toFolios(ctx, [opened.value.row], { roles: new Map([[opened.value.row.id, opened.value.role]]), found: opened.value.found }); | |
| 536 | return ok(folio!); | |
| 537 | } | |
| 538 | ||
| 539 | /** The folio, and what its page shows around it: the docs above it, what is under it, what links to it, open suggestions. */ | |
| 540 | async page(a: Args & { folio_id: string }): Promise<Result<FolioPage>> { | |
| 541 | const found = await this.ctx(a.workspace, a.viewer); | |
| 542 | if (!found.ok) return found; | |
| 543 | const ctx = found.value; | |
| 544 | const opened = await this.open(ctx, a.folio_id, "view", { trashed: true, opening: true, text: true }); | |
| 545 | if (!opened.ok) return opened; | |
| 546 | const { row, role } = opened.value; | |
| 547 | const at = now(); | |
| 548 | this.defer( | |
| 549 | this.db | |
| 550 | .prepare("INSERT INTO folio_visits (folio_id, user_id, first_at, last_at) VALUES (?, ?, ?, ?) ON CONFLICT (folio_id, user_id) DO UPDATE SET last_at = excluded.last_at") | |
| 551 | .bind(row.id, ctx.viewer.id, at, at) | |
| 552 | .run(), | |
| 553 | ); | |
| 554 | const above = row.path.split("/").filter((id) => id && id !== row.id); | |
| 555 | const [aboveRows, childRows, linkRows] = await Promise.all([ | |
| 556 | foliosById(this.db, above), | |
| 557 | this.db.prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE parent_id = ? AND trashed_at IS NULL ORDER BY position LIMIT 200`).bind(row.id).all<FolioRow>(), | |
| 558 | this.db | |
| 559 | .prepare(`SELECT ${folioColumns("f")} FROM folio_links l JOIN folios f ON f.id = l.from_folio WHERE l.to_folio = ? AND f.workspace_id = ? AND f.trashed_at IS NULL LIMIT 200`) | |
| 560 | .bind(row.id, ctx.workspace.id) | |
| 561 | .all<FolioRow>(), | |
| 562 | ]); | |
| 563 | const parents = above.map((id) => aboveRows.get(id)).filter((r): r is FolioRow => !!r); | |
| 564 | const others = [...parents, ...childRows.results, ...linkRows.results.filter((r) => r.id !== row.id)]; | |
| 565 | const { roles } = await this.roles(ctx, others); | |
| 566 | const readable = (list: FolioRow[]) => list.filter((r) => roles.get(r.id)).map((r) => this.ref(ctx.workspace.slug, r)); | |
| 567 | const [folio] = await this.toFolios(ctx, [row], { roles: new Map([[row.id, role]]), found: opened.value.found }); | |
| 568 | return ok({ | |
| 569 | folio: folio!, | |
| 570 | text: row.text, | |
| 571 | breadcrumbs: readable(parents), | |
| 572 | children: readable(childRows.results), | |
| 573 | backlinks: readable(linkRows.results.filter((r) => r.id !== row.id)), | |
| 574 | suggestions: row.kind === "doc" ? await this.openSuggestions(ctx, row) : [], | |
| 575 | }); | |
| 576 | } | |
| 577 | ||
| 578 | // ── Making and changing ───────────────────────────────────────────────── | |
| 579 | ||
| 580 | /** Where a new folio may go for this person: a parent doc they can edit, a space they can edit, or their Private. */ | |
| 581 | private async placeFor(ctx: Ctx, input: { space_id?: string | null; parent_id?: string | null }): Promise<Result<{ space_id: string | null; parent: FolioRow | null }>> { | |
| 582 | if (input.parent_id) { | |
| 583 | const parent = await this.open(ctx, input.parent_id, "edit"); | |
| 584 | if (!parent.ok) return parent.error.code === "forbidden" ? fail("forbidden", "You can read that doc but not add to it.") : fail("not_found", "No such doc to put it under."); | |
| 585 | if (parent.value.row.kind !== "doc") return fail("invalid", "Only a doc can hold other artifacts."); | |
| 586 | if (depthOf(parent.value.row.path) >= MAX_DEPTH) return fail("invalid", `Artifacts go at most ${MAX_DEPTH} deep.`); | |
| 587 | return ok({ space_id: parent.value.row.space_id, parent: parent.value.row }); | |
| 588 | } | |
| 589 | if (input.space_id) { | |
| 590 | const space = ctx.spaceById.get(input.space_id); | |
| 591 | if (!space?.role || space.row.archived_at) return fail("not_found", "No such space."); | |
| 592 | if (!atLeast(space.role, "edit")) return fail("forbidden", `You can read ${space.row.name} but not add to it.`); | |
| 593 | return ok({ space_id: space.row.id, parent: null }); | |
| 594 | } | |
| 595 | return ok({ space_id: null, parent: null }); | |
| 596 | } | |
| 597 | ||
| 598 | /** Where a new folio starts: a template's or the given content, and its title and icon. */ | |
| 599 | private async startingPoint(ctx: Ctx, kind: FolioKind, input: { title?: string | null; icon?: string | null; template_id?: string | null; content?: FolioContentInput | null }): Promise<Result<{ text: string; spec: unknown; title: string; icon: string | null }>> { | |
| 600 | let text = ""; | |
| 601 | let spec: unknown = undefined; | |
| 602 | let title = cleanTitle(input.title); | |
| 603 | let icon = cleanIcon(input.icon); | |
| 604 | if (input.template_id) { | |
| 605 | const template = builtinFolioTemplate(input.template_id) ?? (await this.savedTemplate(ctx.workspace, input.template_id)); | |
| 606 | if (!template) return fail("not_found", "No such template."); | |
| 607 | if (template.kind !== kind) return fail("invalid", `That template is for ${kindLabel(template.kind)}, not ${kindLabel(kind)}.`); | |
| 608 | if (kind === "doc" || kind === "slides") text = template.body; | |
| 609 | else spec = parseJson(template.body, null); | |
| 610 | if (!title) title = template.name; | |
| 611 | if (!icon) icon = template.icon; | |
| 612 | } else if (input.content) { | |
| 613 | if ("markdown" in input.content) text = String(input.content.markdown ?? "").slice(0, MAX_TEXT); | |
| 614 | else spec = input.content.spec; | |
| 615 | } | |
| 616 | return ok({ text, spec, title, icon }); | |
| 617 | } | |
| 618 | ||
| 619 | /** Whether a member key may be shared with: a member, an agent or a team of this workspace. */ | |
| 620 | private async principalExists(ctx: Ctx, principal: string): Promise<boolean> { | |
| 621 | const p = parsePrincipalKey(principal); | |
| 622 | if (principal.startsWith("team:")) { | |
| 623 | const slug = principal.slice(5).toLowerCase(); | |
| 624 | return [...(await this.who.teamsOf(ctx.workspace)).values()].some((set) => set.has(slug)); | |
| 625 | } | |
| 626 | if (!p) return false; | |
| 627 | if (p.kind === "agent") { | |
| 628 | const agent = (await this.who.agentsById([p.id])).get(p.id); | |
| 629 | return !!agent && agent.workspace_id === ctx.workspace.id && !agent.archived_at; | |
| 630 | } | |
| 631 | await this.who.nameUsers([p.id]); | |
| 632 | const username = this.who.usernames.get(p.id); | |
| 633 | return !!username && (await this.who.members(ctx.workspace)).has(username.toLowerCase()); | |
| 634 | } | |
| 635 | ||
| 636 | /** Inserts a folio and fills its room. */ | |
| 637 | private async insertFolio( | |
| 638 | ctx: Ctx, | |
| 639 | input: { | |
| 640 | kind: FolioKind; | |
| 641 | owner: string; | |
| 642 | created_by: string; | |
| 643 | space_id: string | null; | |
| 644 | parent: FolioRow | null; | |
| 645 | title: string; | |
| 646 | icon: string | null; | |
| 647 | text: string; | |
| 648 | spec?: unknown; | |
| 649 | state?: Uint8Array | null; | |
| 650 | inherit?: boolean; | |
| 651 | source?: { title: string; href: string } | null; | |
| 652 | grants?: { principal: string; role: DocRole }[]; | |
| 653 | position?: number; | |
| 654 | }, | |
| 655 | ): Promise<FolioRow> { | |
| 656 | const id = newId("fol"); | |
| 657 | const at = now(); | |
| 658 | const siblings = input.parent | |
| 659 | ? await this.db.prepare("SELECT MAX(position) AS p FROM folios WHERE parent_id = ?").bind(input.parent.id).first<{ p: number | null }>() | |
| 660 | : input.space_id | |
| 661 | ? await this.db.prepare("SELECT MAX(position) AS p FROM folios WHERE space_id = ? AND parent_id IS NULL").bind(input.space_id).first<{ p: number | null }>() | |
| 662 | : await this.db.prepare("SELECT MAX(position) AS p FROM folios WHERE workspace_id = ? AND space_id IS NULL AND parent_id IS NULL AND owner = ?").bind(ctx.workspace.id, input.owner).first<{ p: number | null }>(); | |
| 663 | const position = input.position ?? (siblings?.p ?? 0) + 1024; | |
| 664 | const inherit = input.inherit ?? true; | |
| 665 | const aclRoot = !inherit || !input.parent ? id : input.parent.acl_root; | |
| 666 | const path = input.parent ? `${input.parent.path}${id}/` : `/${id}/`; | |
| 667 | const row: FolioRow = { | |
| 668 | id, | |
| 669 | workspace_id: ctx.workspace.id, | |
| 670 | kind: input.kind, | |
| 671 | title: input.title, | |
| 672 | icon: input.icon, | |
| 673 | cover: null, | |
| 674 | owner: input.owner, | |
| 675 | space_id: input.space_id, | |
| 676 | parent_id: input.parent?.id ?? null, | |
| 677 | position, | |
| 678 | inherit: inherit ? 1 : 0, | |
| 679 | acl_root: aclRoot, | |
| 680 | path, | |
| 681 | general_access: "none", | |
| 682 | general_role: null, | |
| 683 | agent_mode: null, | |
| 684 | text: input.text, | |
| 685 | excerpt: excerpt(input.text), | |
| 686 | preview: null, | |
| 687 | source: input.source ? JSON.stringify(input.source) : null, | |
| 688 | mentioned: "[]", | |
| 689 | created_by: input.created_by, | |
| 690 | created_at: at, | |
| 691 | updated_by: input.created_by, | |
| 692 | updated_at: at, | |
| 693 | edited_by: input.created_by, | |
| 694 | edited_at: at, | |
| 695 | trashed_at: null, | |
| 696 | trashed_by: null, | |
| 697 | }; | |
| 698 | const grants = (input.grants ?? []).filter((g) => g.principal !== input.owner); | |
| 699 | await this.db.batch([ | |
| 700 | this.db | |
| 701 | .prepare( | |
| 702 | `INSERT INTO folios (id, workspace_id, kind, title, icon, owner, space_id, parent_id, position, inherit, acl_root, path, text, excerpt, source, created_by, created_at, updated_by, updated_at, edited_by, edited_at) | |
| 703 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 704 | ) | |
| 705 | .bind(id, row.workspace_id, row.kind, row.title, row.icon, row.owner, row.space_id, row.parent_id, position, row.inherit, aclRoot, path, row.text, row.excerpt, row.source, row.created_by, at, row.created_by, at, row.created_by, at), | |
| 706 | this.db.prepare("INSERT INTO folios_fts (folio_id, kind, title, body) VALUES (?, ?, ?, ?)").bind(id, row.kind, row.title, searchText(row.text)), | |
| 707 | this.db.prepare("INSERT INTO folio_versions (id, folio_id, created_at, kind, authors, note, text, state) VALUES (?, ?, ?, 'created', ?, NULL, ?, NULL)").bind(newId("ver"), id, at, JSON.stringify([input.created_by]), row.text), | |
| 708 | ...grants.map((g) => this.db.prepare("INSERT OR REPLACE INTO folio_grants (folio_id, principal, role, granted_by, granted_at) VALUES (?, ?, ?, ?, ?)").bind(id, g.principal, g.role, input.created_by, at)), | |
| 709 | ]); | |
| 710 | await rebuildSubtree(this.db, id); | |
| 711 | const room = this.room(id); | |
| 712 | await room.ensure({ folio_id: id, kind: row.kind, workspace_slug: ctx.workspace.slug, text: input.text, spec: input.spec, state: input.state ?? null }); | |
| 713 | // The rendition the room makes of it, its card, links and citations, now. | |
| 714 | await room.flush(); | |
| 715 | const open = await workspaceReadable(this.db, id).catch(() => false); | |
| 716 | this.defer( | |
| 717 | publishFolioEvent(this.env.EVENTS, "folio.created", { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, folioId: id, kind: row.kind, spaceId: row.space_id, title: open ? row.title : null }, input.created_by), | |
| 718 | ); | |
| 719 | this.defer(indexFolio(this.env, id)); | |
| 720 | return (await foliosById(this.db, [id])).get(id) ?? row; | |
| 721 | } | |
| 722 | ||
| 723 | /** Grants asked for at creation: people, agents and teams of this workspace, never above `edit` for teams' sake of sense. */ | |
| 724 | private async cleanShares(ctx: Ctx, share: { principal: string; role: DocRole }[] | null | undefined): Promise<Result<{ principal: string; role: DocRole }[]>> { | |
| 725 | const out: { principal: string; role: DocRole }[] = []; | |
| 726 | for (const s of (share ?? []).slice(0, FOLIO_MAX_SHARE)) { | |
| 727 | const principal = s.principal.startsWith("team:") ? `team:${s.principal.slice(5).toLowerCase()}` : s.principal; | |
| 728 | if (!(await this.principalExists(ctx, principal))) return fail("invalid", `${s.principal} isn't a member, agent or team of this workspace.`); | |
| 729 | out.push({ principal, role: s.role }); | |
| 730 | } | |
| 731 | return ok(out); | |
| 732 | } | |
| 733 | ||
| 734 | async create(a: Args & { input: Parameters<typeof newFolioError>[0] }): Promise<Result<Folio>> { | |
| 735 | const input = a.input ?? ({ kind: "doc" } as Parameters<typeof newFolioError>[0]); | |
| 736 | const invalid = newFolioError(input); | |
| 737 | if (invalid) return fail("invalid", invalid); | |
| 738 | if (!kindModel(input.kind)) return fail("invalid", `${kindLabel(input.kind)} aren't here yet.`); | |
| 739 | const found = await this.ctx(a.workspace, a.viewer); | |
| 740 | if (!found.ok) return found; | |
| 741 | const ctx = found.value; | |
| 742 | const place = await this.placeFor(ctx, input); | |
| 743 | if (!place.ok) return place; | |
| 744 | const start = await this.startingPoint(ctx, input.kind, input); | |
| 745 | if (!start.ok) return start; | |
| 746 | const shares = await this.cleanShares(ctx, input.share_with); | |
| 747 | if (!shares.ok) return shares; | |
| 748 | const row = await this.insertFolio(ctx, { | |
| 749 | kind: input.kind, | |
| 750 | owner: ctx.key, | |
| 751 | created_by: ctx.key, | |
| 752 | space_id: place.value.space_id, | |
| 753 | parent: place.value.parent, | |
| 754 | title: start.value.title, | |
| 755 | icon: start.value.icon, | |
| 756 | text: start.value.text, | |
| 757 | spec: start.value.spec, | |
| 758 | source: cleanSource(input.source), | |
| 759 | grants: shares.value, | |
| 760 | }); | |
| 761 | return ok(await this.folioOf(ctx, row)); | |
| 762 | } | |
| 763 | ||
| 764 | async update(a: Args & { folio_id: string; change: FolioChange }): Promise<Result<Folio>> { | |
| 765 | const found = await this.ctx(a.workspace, a.viewer); | |
| 766 | if (!found.ok) return found; | |
| 767 | const ctx = found.value; | |
| 768 | const opened = await this.open(ctx, a.folio_id, "edit"); | |
| 769 | if (!opened.ok) return opened; | |
| 770 | const { row } = opened.value; | |
| 771 | const c = a.change ?? {}; | |
| 772 | const sets: string[] = []; | |
| 773 | const values: unknown[] = []; | |
| 774 | const statements: D1PreparedStatement[] = []; | |
| 775 | if (c.title !== undefined) { | |
| 776 | sets.push("title = ?"); | |
| 777 | values.push(cleanTitle(c.title)); | |
| 778 | statements.push(this.db.prepare("UPDATE folios_fts SET title = ? WHERE folio_id = ?").bind(cleanTitle(c.title), row.id)); | |
| 779 | } | |
| 780 | if (c.icon !== undefined) { | |
| 781 | sets.push("icon = ?"); | |
| 782 | values.push(cleanIcon(c.icon)); | |
| 783 | } | |
| 784 | if (c.cover !== undefined) { | |
| 785 | sets.push("cover = ?"); | |
| 786 | values.push(cleanCover(c.cover)); | |
| 787 | } | |
| 788 | if (sets.length) { | |
| 789 | sets.push("updated_at = ?", "updated_by = ?"); | |
| 790 | values.push(now(), ctx.key); | |
| 791 | statements.unshift(this.db.prepare(`UPDATE folios SET ${sets.join(", ")} WHERE id = ?`).bind(...values, row.id)); | |
| 792 | } | |
| 793 | if (c.projects !== undefined) { | |
| 794 | statements.push(this.db.prepare("DELETE FROM folio_projects WHERE folio_id = ?").bind(row.id)); | |
| 795 | const projects = [...new Set((Array.isArray(c.projects) ? c.projects : []).map((p) => projectRef(String(p))).filter((p): p is string => !!p))].slice(0, 20); | |
| 796 | for (const repo of projects) statements.push(this.db.prepare("INSERT INTO folio_projects (folio_id, repo) VALUES (?, ?)").bind(row.id, repo)); | |
| 797 | } | |
| 798 | if (statements.length) await this.db.batch(statements); | |
| 799 | const folio = await this.folioOf(ctx, row); | |
| 800 | this.tell(row.id, { type: "folio.updated", folio }); | |
| 801 | if (c.title !== undefined && cleanTitle(c.title) !== row.title) this.defer(indexFolio(this.env, row.id)); | |
| 802 | return ok(folio); | |
| 803 | } | |
| 804 | ||
| 805 | async move(a: Args & { folio_id: string; move: FolioMove }): Promise<Result<Folio>> { | |
| 806 | const found = await this.ctx(a.workspace, a.viewer); | |
| 807 | if (!found.ok) return found; | |
| 808 | const ctx = found.value; | |
| 809 | const opened = await this.open(ctx, a.folio_id, "edit"); | |
| 810 | if (!opened.ok) return opened; | |
| 811 | const { row } = opened.value; | |
| 812 | const move = a.move ?? ({ space_id: null, parent_id: null } as FolioMove); | |
| 813 | let spaceId: string | null; | |
| 814 | let parent: FolioRow | null = null; | |
| 815 | if (move.parent_id) { | |
| 816 | const target = await this.open(ctx, move.parent_id, "edit"); | |
| 817 | if (!target.ok) return target.error.code === "forbidden" ? fail("forbidden", "You can read that doc but not add to it.") : fail("not_found", "No such doc to put it under."); | |
| 818 | parent = target.value.row; | |
| 819 | if (parent.kind !== "doc") return fail("invalid", "Only a doc can hold other artifacts."); | |
| 820 | if (parent.path.startsWith(row.path)) return fail("invalid", "An artifact can't go inside itself."); | |
| 821 | spaceId = parent.space_id; | |
| 822 | } else if (move.space_id) { | |
| 823 | const space = ctx.spaceById.get(move.space_id); | |
| 824 | if (!space?.role || space.row.archived_at) return fail("not_found", "No such space."); | |
| 825 | if (!atLeast(space.role, "edit")) return fail("forbidden", `You can't add to ${space.row.name}.`); | |
| 826 | spaceId = space.row.id; | |
| 827 | } else { | |
| 828 | // Private is its owner's: only they put something at its top. | |
| 829 | if (row.owner !== ctx.key) return fail("forbidden", "Only its owner can move it to their Private section."); | |
| 830 | spaceId = null; | |
| 831 | } | |
| 832 | const below = await subtree(this.db, row); | |
| 833 | if (depthOf(parent?.path ?? "") + 1 + subtreeHeight(row, below) > MAX_DEPTH) return fail("invalid", `Artifacts go at most ${MAX_DEPTH} deep.`); | |
| 834 | const siblings = ( | |
| 835 | parent | |
| 836 | ? await this.db.prepare("SELECT id, parent_id, position FROM folios WHERE parent_id = ? AND trashed_at IS NULL").bind(parent.id).all<{ id: string; parent_id: string | null; position: number }>() | |
| 837 | : spaceId | |
| 838 | ? await this.db.prepare("SELECT id, parent_id, position FROM folios WHERE space_id = ? AND parent_id IS NULL AND trashed_at IS NULL").bind(spaceId).all<{ id: string; parent_id: string | null; position: number }>() | |
| 839 | : await this.db | |
| 840 | .prepare("SELECT id, parent_id, position FROM folios WHERE workspace_id = ? AND space_id IS NULL AND parent_id IS NULL AND owner = ? AND trashed_at IS NULL") | |
| 841 | .bind(ctx.workspace.id, row.owner) | |
| 842 | .all<{ id: string; parent_id: string | null; position: number }>() | |
| 843 | ).results; | |
| 844 | const placed = placeBefore(siblings, row.id, parent?.id ?? null, move.before_id ?? null); | |
| 845 | const statements: D1PreparedStatement[] = [ | |
| 846 | this.db.prepare("UPDATE folios SET parent_id = ?, space_id = ?, position = ?, updated_at = ?, updated_by = ? WHERE id = ?").bind(parent?.id ?? null, spaceId, placed.position, now(), ctx.key, row.id), | |
| 847 | ]; | |
| 848 | for (const [id, position] of placed.renumber) statements.push(this.db.prepare("UPDATE folios SET position = ? WHERE id = ?").bind(position, id)); | |
| 849 | await this.db.batch(statements); | |
| 850 | await this.afterAccessChange(ctx, row.id, below.length); | |
| 851 | const folio = await this.folioOf(ctx, row); | |
| 852 | this.tell(row.id, { type: "folio.updated", folio }); | |
| 853 | return ok(folio); | |
| 854 | } | |
| 855 | ||
| 856 | /** | |
| 857 | * After a move or a sharing change: the subtree's places and | |
| 858 | * `folio_access` rebuilt, open rooms told of their people's new roles, | |
| 859 | * and passages filed under their new scope. A subtree past | |
| 860 | * FOLIO_INLINE_REACL goes to the queue (`folios.reacl`). | |
| 861 | */ | |
| 862 | private async afterAccessChange(ctx: Ctx | null, rootId: string, size: number): Promise<void> { | |
| 863 | if (size > FOLIO_INLINE_REACL && this.env.JOBS) { | |
| 864 | await this.env.JOBS.send({ type: "folios.reacl", folio_id: rootId }); | |
| 865 | return; | |
| 866 | } | |
| 867 | const ids = await rebuildSubtree(this.db, rootId); | |
| 868 | this.defer(this.followAccess(ctx?.workspace ?? null, ids)); | |
| 869 | } | |
| 870 | ||
| 871 | /** Open rooms in these folios re-check each socket's person; the index files their passages under their scope now. */ | |
| 872 | async followAccess(workspace: Workspace | null, ids: string[]): Promise<void> { | |
| 873 | try { | |
| 874 | const rows = [...(await foliosById(this.db, ids)).values()]; | |
| 875 | if (!rows.length) return; | |
| 876 | const ws = workspace ?? (await this.workspaceById(rows[0]!.workspace_id)); | |
| 877 | if (ws) { | |
| 878 | for (const row of rows.slice(0, INLINE_ROOMS)) { | |
| 879 | const room = this.room(row.id); | |
| 880 | const members = await room.members().catch(() => [] as { key: string; name: string }[]); | |
| 881 | if (members.length) { | |
| 882 | for (const m of members) { | |
| 883 | const role = await this.roleOfPerson(ws, row, m.key, m.name); | |
| 884 | await room.setRole(m.key, role).catch(() => undefined); | |
| 885 | } | |
| 886 | await room.notice({ type: "folio.access" }).catch(() => undefined); | |
| 887 | } | |
| 888 | } | |
| 889 | } | |
| 890 | for (const row of rows.slice(0, 2000)) await indexFolio(this.env, row.id); | |
| 891 | } catch (error) { | |
| 892 | console.error("folios could not follow an access change", String(error)); | |
| 893 | } | |
| 894 | } | |
| 895 | ||
| 896 | private async workspaceById(id: string): Promise<Workspace | null> { | |
| 897 | const names = await identityClient(this.env.IDENTITY) | |
| 898 | .usernames([id]) | |
| 899 | .catch(() => ({}) as Record<string, string>); | |
| 900 | return names[id] ? this.who.workspace(names[id]!) : null; | |
| 901 | } | |
| 902 | ||
| 903 | /** Someone's role on a folio, by their member key and username (for open sockets and mentions). */ | |
| 904 | private async roleOfPerson(workspace: Workspace, row: FolioRow, key: string, username: string): Promise<DocRole | null> { | |
| 905 | if (!key.startsWith("user:")) return null; | |
| 906 | const userId = key.slice(5); | |
| 907 | const member = (await this.who.members(workspace)).get(username.toLowerCase()); | |
| 908 | if (!member) return null; | |
| 909 | const person = await this.who.personOf(workspace, { id: userId, username }, member.role === "owner"); | |
| 910 | const spaces = await this.who.spacesFor(workspace, person); | |
| 911 | const byId = new Map(spaces.map((s) => [s.row.id, s])); | |
| 912 | const [found, visits] = await Promise.all([ancestry(this.db, [row]), visitsOf(this.db, userId, [row])]); | |
| 913 | return rolesFrom(found, [row], { person, spaceRole: (id) => byId.get(id)?.role ?? null, visits }).get(row.id) ?? null; | |
| 914 | } | |
| 915 | ||
| 916 | async duplicate(a: Args & { folio_id: string }): Promise<Result<Folio>> { | |
| 917 | const found = await this.ctx(a.workspace, a.viewer); | |
| 918 | if (!found.ok) return found; | |
| 919 | const ctx = found.value; | |
| 920 | const opened = await this.open(ctx, a.folio_id, "view"); | |
| 921 | if (!opened.ok) return opened; | |
| 922 | const { row } = opened.value; | |
| 923 | if (!kindModel(row.kind)) return fail("invalid", `${kindLabel(row.kind)} aren't here yet.`); | |
| 924 | // Beside the original where they may add, else in their Private. Never shared wider than the original: no grants, no general access. | |
| 925 | let space: string | null = null; | |
| 926 | let parent: FolioRow | null = null; | |
| 927 | if (row.parent_id) { | |
| 928 | const p = await this.open(ctx, row.parent_id, "edit"); | |
| 929 | if (p.ok) { | |
| 930 | parent = p.value.row; | |
| 931 | space = parent.space_id; | |
| 932 | } | |
| 933 | } else if (row.space_id && atLeast(ctx.spaceById.get(row.space_id)?.role, "edit")) space = row.space_id; | |
| 934 | const besides = !!parent || !!space; | |
| 935 | const room = await this.ready(ctx.workspace, row); | |
| 936 | const state = await room.state(); | |
| 937 | const text = await room.text(); | |
| 938 | const copy = await this.insertFolio(ctx, { | |
| 939 | kind: row.kind, | |
| 940 | owner: ctx.key, | |
| 941 | created_by: ctx.key, | |
| 942 | space_id: space, | |
| 943 | parent, | |
| 944 | title: cleanTitle(`${row.title || "Untitled"} (copy)`), | |
| 945 | icon: row.icon, | |
| 946 | text, | |
| 947 | state, | |
| 948 | inherit: besides ? !!row.inherit : true, | |
| 949 | position: besides ? row.position + 0.5 : undefined, | |
| 950 | }); | |
| 951 | return ok(await this.folioOf(ctx, copy)); | |
| 952 | } | |
| 953 | ||
| 954 | async trash(a: Args & { folio_id: string }): Promise<Result<Folio>> { | |
| 955 | const found = await this.ctx(a.workspace, a.viewer); | |
| 956 | if (!found.ok) return found; | |
| 957 | const ctx = found.value; | |
| 958 | const opened = await this.open(ctx, a.folio_id, "edit"); | |
| 959 | if (!opened.ok) return opened; | |
| 960 | const { row } = opened.value; | |
| 961 | const ids = (await subtree(this.db, row)).filter((r) => !r.trashed_at).map((r) => r.id); | |
| 962 | const at = now(); | |
| 963 | await runBatches( | |
| 964 | this.db, | |
| 965 | ids.map((id) => this.db.prepare("UPDATE folios SET trashed_at = ?, trashed_by = ? WHERE id = ? AND trashed_at IS NULL").bind(at, ctx.key, id)), | |
| 966 | ); | |
| 967 | for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).closeAll("Moved to the trash").catch(() => undefined)); | |
| 968 | this.defer(forgetFolios(this.env, ids)); | |
| 969 | const open = await workspaceReadable(this.db, row.id).catch(() => false); | |
| 970 | this.defer(publishFolioEvent(this.env.EVENTS, "folio.trashed", { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, folioId: row.id, kind: row.kind, spaceId: row.space_id, title: open ? row.title : null }, ctx.key)); | |
| 971 | const [folio] = await this.toFolios(ctx, [{ ...row, trashed_at: at, trashed_by: ctx.key }]); | |
| 972 | return ok(folio!); | |
| 973 | } | |
| 974 | ||
| 975 | async restore(a: Args & { folio_id: string }): Promise<Result<Folio>> { | |
| 976 | const found = await this.ctx(a.workspace, a.viewer); | |
| 977 | if (!found.ok) return found; | |
| 978 | const ctx = found.value; | |
| 979 | const opened = await this.open(ctx, a.folio_id, "edit", { trashed: true }); | |
| 980 | if (!opened.ok) return opened; | |
| 981 | const { row } = opened.value; | |
| 982 | if (!row.trashed_at) return fail("invalid", "That artifact isn't in the trash."); | |
| 983 | const below = await subtree(this.db, row); | |
| 984 | const ids = below.filter((r) => r.trashed_at === row.trashed_at).map((r) => r.id); | |
| 985 | const parent = row.parent_id ? (await foliosById(this.db, [row.parent_id])).get(row.parent_id) : null; | |
| 986 | const statements = ids.map((id) => this.db.prepare("UPDATE folios SET trashed_at = NULL, trashed_by = NULL WHERE id = ?").bind(id)); | |
| 987 | // Its parent is gone or still in the trash: it comes back at the top of where it was. | |
| 988 | const detach = !!row.parent_id && (!parent || !!parent.trashed_at); | |
| 989 | if (detach) statements.push(this.db.prepare("UPDATE folios SET parent_id = NULL WHERE id = ?").bind(row.id)); | |
| 990 | await runBatches(this.db, statements); | |
| 991 | if (detach) await this.afterAccessChange(ctx, row.id, below.length); | |
| 992 | else this.defer((async () => { for (const id of ids.slice(0, 2000)) await indexFolio(this.env, id); })()); | |
| 993 | const open = await workspaceReadable(this.db, row.id).catch(() => false); | |
| 994 | this.defer(publishFolioEvent(this.env.EVENTS, "folio.restored", { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, folioId: row.id, kind: row.kind, spaceId: row.space_id, title: open ? row.title : null }, ctx.key)); | |
| 995 | return ok(await this.folioOf(ctx, row)); | |
| 996 | } | |
| 997 | ||
| 998 | async delete(a: Args & { folio_id: string }): Promise<Result<boolean>> { | |
| 999 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1000 | if (!found.ok) return found; | |
| 1001 | const ctx = found.value; | |
| 1002 | const opened = await this.open(ctx, a.folio_id, "manage", { trashed: true }); | |
| 1003 | if (!opened.ok) return opened; | |
| 1004 | const { row } = opened.value; | |
| 1005 | if (!row.trashed_at) return fail("invalid", "Move it to the trash first."); | |
| 1006 | // Deepest first, so no parent goes before its children. | |
| 1007 | const ids = (await subtree(this.db, row)).sort((x, y) => y.path.length - x.path.length).map((r) => r.id); | |
| 1008 | await forgetFolios(this.env, ids); | |
| 1009 | await runBatches( | |
| 1010 | this.db, | |
| 1011 | ids.flatMap((id) => [this.db.prepare("DELETE FROM folios_fts WHERE folio_id = ?").bind(id), this.db.prepare("DELETE FROM folios WHERE id = ?").bind(id)]), | |
| 1012 | ); | |
| 1013 | for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).destroy().catch(() => undefined)); | |
| 1014 | return ok(true); | |
| 1015 | } | |
| 1016 | ||
| 1017 | /** Trashed folios the viewer may restore: the tops of what went to the trash together. */ | |
| 1018 | private async trashedFor(ctx: Ctx, limit: number): Promise<FolioRow[]> { | |
| 1019 | const filter = this.filterOf(ctx); | |
| 1020 | const rows = ( | |
| 1021 | await this.db | |
| 1022 | .prepare(`SELECT ${folioColumns("f")} FROM folios f JOIN folios r ON r.id = f.acl_root WHERE f.workspace_id = ? AND f.trashed_at IS NOT NULL AND ${filter.sql} ORDER BY f.trashed_at DESC LIMIT ?`) | |
| 1023 | .bind(ctx.workspace.id, ...filter.binds, limit * 2) | |
| 1024 | .all<FolioRow>() | |
| 1025 | ).results; | |
| 1026 | const { roles } = await this.roles(ctx, rows); | |
| 1027 | const byId = new Map(rows.map((r) => [r.id, r])); | |
| 1028 | return rows.filter((r) => atLeast(roles.get(r.id), "edit") && !(r.parent_id && byId.get(r.parent_id)?.trashed_at === r.trashed_at)).slice(0, limit); | |
| 1029 | } | |
| 1030 | ||
| 1031 | async trashed(a: Args): Promise<Result<Folio[]>> { | |
| 1032 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1033 | if (!found.ok) return found; | |
| 1034 | return ok(await this.toFolios(found.value, await this.trashedFor(found.value, 200))); | |
| 1035 | } | |
| 1036 | ||
| 1037 | async favorite(a: Args & { folio_id: string; on: boolean }): Promise<Result<boolean>> { | |
| 1038 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1039 | if (!found.ok) return found; | |
| 1040 | const ctx = found.value; | |
| 1041 | const opened = await this.open(ctx, a.folio_id, "view"); | |
| 1042 | if (!opened.ok) return opened; | |
| 1043 | if (a.on) { | |
| 1044 | await this.db | |
| 1045 | .prepare("INSERT OR IGNORE INTO folio_favorites (user_id, folio_id, position, created_at) VALUES (?, ?, (SELECT COALESCE(MAX(position), 0) + 1024 FROM folio_favorites WHERE user_id = ?), ?)") | |
| 1046 | .bind(ctx.viewer.id, opened.value.row.id, ctx.viewer.id, now()) | |
| 1047 | .run(); | |
| 1048 | } else { | |
| 1049 | await this.db.prepare("DELETE FROM folio_favorites WHERE user_id = ? AND folio_id = ?").bind(ctx.viewer.id, opened.value.row.id).run(); | |
| 1050 | } | |
| 1051 | return ok(!!a.on); | |
| 1052 | } | |
| 1053 | ||
| 1054 | // ── Content in the agent form, for a person or their token ────────────── | |
| 1055 | ||
| 1056 | private spaceOf(ctx: Ctx, row: FolioRow): FolioAgentRead["space"] { | |
| 1057 | const space = row.space_id ? ctx.spaceById.get(row.space_id) : undefined; | |
| 1058 | return space ? { id: space.row.id, slug: space.row.slug, name: space.row.name, agent_mode: space.row.agent_mode } : null; | |
| 1059 | } | |
| 1060 | ||
| 1061 | async content(a: Args & { folio_id: string }): Promise<Result<FolioAgentRead>> { | |
| 1062 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1063 | if (!found.ok) return found; | |
| 1064 | const ctx = found.value; | |
| 1065 | const opened = await this.open(ctx, a.folio_id, "view"); | |
| 1066 | if (!opened.ok) return opened; | |
| 1067 | const { row, role } = opened.value; | |
| 1068 | if (!kindModel(row.kind)) return fail("invalid", `${kindLabel(row.kind)} aren't here yet.`); | |
| 1069 | const read = await (await this.ready(ctx.workspace, row)).read(); | |
| 1070 | return ok({ | |
| 1071 | folio: { ...this.ref(ctx.workspace.slug, row), edited_at: row.edited_at }, | |
| 1072 | space: this.spaceOf(ctx, row), | |
| 1073 | content: read.content, | |
| 1074 | ...(read.blocks ? { blocks: read.blocks } : {}), | |
| 1075 | can: { read: true, suggest: atLeast(role, "comment"), edit: atLeast(role, "edit") }, | |
| 1076 | audience_can_read: true, | |
| 1077 | }); | |
| 1078 | } | |
| 1079 | ||
| 1080 | /** What is wrong with an edit for this folio, or null. */ | |
| 1081 | private editError(row: FolioRow, edit: unknown): string | null { | |
| 1082 | const invalid = folioAgentEditError(edit); | |
| 1083 | if (invalid) return invalid; | |
| 1084 | const e = edit as FolioAgentEdit; | |
| 1085 | if (e.kind !== row.kind) return `This is ${kindLabel(row.kind)}, and the edit is for ${kindLabel(e.kind)}.`; | |
| 1086 | if (e.kind === "doc" && !cleanTarget(e.target)) return "Say what to change: append, document, a section by its heading, or blocks by id."; | |
| 1087 | if (e.kind === "doc" && e.markdown.length > MAX_TEXT) return "That edit is too long."; | |
| 1088 | if (e.kind === "doc" && e.target.kind === "append" && !e.markdown.trim()) return "Nothing to add."; | |
| 1089 | return null; | |
| 1090 | } | |
| 1091 | ||
| 1092 | async edit(a: Args & { folio_id: string; edit: FolioAgentEdit }): Promise<Result<FolioAgentEditResult>> { | |
| 1093 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1094 | if (!found.ok) return found; | |
| 1095 | const ctx = found.value; | |
| 1096 | const opened = await this.open(ctx, a.folio_id, "comment"); | |
| 1097 | if (!opened.ok) return opened; | |
| 1098 | const { row, role } = opened.value; | |
| 1099 | const invalid = this.editError(row, a.edit); | |
| 1100 | if (invalid) return fail("invalid", invalid); | |
| 1101 | const edit = a.edit; | |
| 1102 | const ref = this.ref(ctx.workspace.slug, row); | |
| 1103 | if (atLeast(role, "edit") && !edit.suggest_only) { | |
| 1104 | const room = await this.ready(ctx.workspace, row); | |
| 1105 | const result = await room.edit(edit, { key: ctx.key, kind: "edit", note: cleanNote(edit.note), authors: [ctx.key] }); | |
| 1106 | if (!result.applied) return fail("not_found", `${result.summary} Read it again and target what is there now.`); | |
| 1107 | if (edit.marks_current) await this.clearStale(row.id, ctx.key); | |
| 1108 | return ok({ mode: "applied", version_id: result.version_id, folio: ref, summary: result.summary }); | |
| 1109 | } | |
| 1110 | if (edit.kind !== "doc") return fail("forbidden", `Suggesting changes to ${kindLabel(row.kind)} comes with proposals, which aren't here yet.`); | |
| 1111 | const suggestion = await this.fileSuggestion(ctx, row, { author: ctx.key, asked_by: null, agentName: null }, { target: cleanTarget(edit.target)!, markdown: edit.markdown, note: cleanNote(edit.note), marks_current: edit.marks_current === true }); | |
| 1112 | return suggestion.ok ? ok({ mode: "suggested", suggestion: suggestion.value, folio: ref }) : suggestion; | |
| 1113 | } | |
| 1114 | ||
| 1115 | // ── Sharing ───────────────────────────────────────────────────────────── | |
| 1116 | ||
| 1117 | private async accessList(ctx: Ctx, row: FolioRow, role: DocRole, found: Ancestry): Promise<FolioAccessList> { | |
| 1118 | const chain = aclChain(row.id, found.nodes); | |
| 1119 | const root = chain[chain.length - 1] ?? aclNode(row); | |
| 1120 | const entries = explicitAccess(chain, found.grants); | |
| 1121 | const keys = [...entries.keys()]; | |
| 1122 | const people = await this.who.profiles( | |
| 1123 | ctx.workspace, | |
| 1124 | keys.filter((k) => !k.startsWith("team:")), | |
| 1125 | ); | |
| 1126 | const rows: FolioAccessRow[] = []; | |
| 1127 | for (const [principal, entry] of entries) { | |
| 1128 | if (principal === row.owner && entry.via === "owner") continue; | |
| 1129 | const via = entry.via === row.id ? null : found.rows.get(entry.via); | |
| 1130 | const source: FolioAccessRow["source"] = entry.via === row.id ? { kind: "grant" } : via ? { kind: "folio", id: via.id, title: via.title || "Untitled", path: this.ref(ctx.workspace.slug, via).path } : { kind: "grant" }; | |
| 1131 | const profile: FolioAccessRow["profile"] = principal.startsWith("team:") | |
| 1132 | ? { kind: "team", id: principal.slice(5), name: principal.slice(5), display_name: `@${ctx.workspace.slug}/${principal.slice(5)}` } | |
| 1133 | : people.get(principal)!; | |
| 1134 | rows.push({ principal, profile, role: entry.role, source }); | |
| 1135 | } | |
| 1136 | rows.sort((x, y) => RANK[y.role] - RANK[x.role] || x.profile.display_name.localeCompare(y.profile.display_name)); | |
| 1137 | const owner = (await this.who.profiles(ctx.workspace, [row.owner])).get(row.owner)!; | |
| 1138 | const space = row.space_id ? ctx.spaceById.get(row.space_id) : undefined; | |
| 1139 | const parent = row.parent_id ? found.rows.get(row.parent_id) : undefined; | |
| 1140 | let inherited: FolioAccessList["inherited_from"] = null; | |
| 1141 | if (row.inherit && parent) inherited = { kind: "folio", id: parent.id, name: parent.title || "Untitled" }; | |
| 1142 | else if (row.inherit && !row.parent_id && space) inherited = { kind: "space", id: space.row.id, name: space.row.name }; | |
| 1143 | return { | |
| 1144 | folio_id: row.id, | |
| 1145 | owner, | |
| 1146 | rows, | |
| 1147 | general_access: root.general_access, | |
| 1148 | general_role: root.general_access === "none" ? null : ((root.general_role as FolioAccessList["general_role"]) ?? "view"), | |
| 1149 | inherit: !!row.inherit, | |
| 1150 | inherited_from: inherited, | |
| 1151 | agent_mode: row.agent_mode, | |
| 1152 | can_share: canShare(role, this.editorsShare(ctx, row)), | |
| 1153 | public_link: "off", | |
| 1154 | }; | |
| 1155 | } | |
| 1156 | ||
| 1157 | /** Whether the folio's space lets people with edit access share what is in it. */ | |
| 1158 | private editorsShare(ctx: Ctx, row: Pick<FolioRow, "space_id">): boolean { | |
| 1159 | return !!(row.space_id && ctx.spaceById.get(row.space_id)?.row.editors_can_share); | |
| 1160 | } | |
| 1161 | ||
| 1162 | async access(a: Args & { folio_id: string }): Promise<Result<FolioAccessList>> { | |
| 1163 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1164 | if (!found.ok) return found; | |
| 1165 | const ctx = found.value; | |
| 1166 | const opened = await this.open(ctx, a.folio_id, "view"); | |
| 1167 | if (!opened.ok) return opened; | |
| 1168 | return ok(await this.accessList(ctx, opened.value.row, opened.value.role, opened.value.found)); | |
| 1169 | } | |
| 1170 | ||
| 1171 | /** After any sharing change: the rows, the rooms, the index, and the share dialog again. */ | |
| 1172 | private async afterShare(ctx: Ctx, row: FolioRow): Promise<FolioAccessList> { | |
| 1173 | const below = await subtree(this.db, row); | |
| 1174 | await this.afterAccessChange(ctx, row.id, below.length); | |
| 1175 | const again = await this.open(ctx, row.id, "view", { trashed: true }); | |
| 1176 | if (!again.ok) { | |
| 1177 | // They shared themselves out of it. | |
| 1178 | return { folio_id: row.id, owner: (await this.who.profiles(ctx.workspace, [row.owner])).get(row.owner)!, rows: [], general_access: "none", general_role: null, inherit: !!row.inherit, inherited_from: null, agent_mode: null, can_share: false, public_link: "off" }; | |
| 1179 | } | |
| 1180 | return this.accessList(ctx, again.value.row, again.value.role, again.value.found); | |
| 1181 | } | |
| 1182 | ||
| 1183 | async setGrant(a: Args & { folio_id: string; change: FolioAccessChange }): Promise<Result<FolioAccessList>> { | |
| 1184 | const change = a.change; | |
| 1185 | if (!change || (change.op !== "grant" && change.op !== "revoke")) return fail("invalid", "Grants and revokes only; other changes go to set_folio_general_access."); | |
| 1186 | const invalid = folioAccessChangeError(change); | |
| 1187 | if (invalid) return fail("invalid", invalid); | |
| 1188 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1189 | if (!found.ok) return found; | |
| 1190 | const ctx = found.value; | |
| 1191 | const opened = await this.open(ctx, a.folio_id, "view"); | |
| 1192 | if (!opened.ok) return opened; | |
| 1193 | const { row, role } = opened.value; | |
| 1194 | if (!canShare(role, this.editorsShare(ctx, row))) return fail("forbidden", "Only people with full access can share it."); | |
| 1195 | // Editors whose space lets them share give up to edit; full access stays with managers. | |
| 1196 | if (role !== "manage" && change.op === "grant" && change.role === "manage") return fail("forbidden", "Only people with full access can give full access."); | |
| 1197 | const principal = change.principal.startsWith("team:") ? `team:${change.principal.slice(5).toLowerCase()}` : change.principal; | |
| 1198 | if (principal === row.owner) return fail("invalid", "Its owner always has full access."); | |
| 1199 | if (role !== "manage") { | |
| 1200 | const held = await this.db.prepare("SELECT role FROM folio_grants WHERE folio_id = ? AND principal = ?").bind(row.id, principal).first<{ role: DocRole }>(); | |
| 1201 | if (held?.role === "manage") return fail("forbidden", "Only people with full access can change someone else's full access."); | |
| 1202 | } | |
| 1203 | if (change.op === "revoke") { | |
| 1204 | await this.db.prepare("DELETE FROM folio_grants WHERE folio_id = ? AND principal = ?").bind(row.id, principal).run(); | |
| 1205 | return ok(await this.afterShare(ctx, row)); | |
| 1206 | } | |
| 1207 | if (!(await this.principalExists(ctx, principal))) return fail("invalid", "Share with a member, an agent or a team of this workspace."); | |
| 1208 | await this.db | |
| 1209 | .prepare("INSERT INTO folio_grants (folio_id, principal, role, granted_by, granted_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (folio_id, principal) DO UPDATE SET role = excluded.role") | |
| 1210 | .bind(row.id, principal, change.role, ctx.key, now()) | |
| 1211 | .run(); | |
| 1212 | const list = await this.afterShare(ctx, row); | |
| 1213 | const open = await workspaceReadable(this.db, row.id).catch(() => false); | |
| 1214 | this.defer( | |
| 1215 | publishFolioEvent( | |
| 1216 | this.env.EVENTS, | |
| 1217 | "folio.shared", | |
| 1218 | { workspace: ctx.workspace.slug, workspaceId: ctx.workspace.id, folioId: row.id, kind: row.kind, spaceId: row.space_id, title: open ? row.title : null, principals: [principal], role: change.role }, | |
| 1219 | ctx.key, | |
| 1220 | ), | |
| 1221 | ); | |
| 1222 | if (principal.startsWith("user:")) this.defer(this.notifyShared(ctx, row, principal.slice(5), change.role, cleanNote(change.notify))); | |
| 1223 | return ok(list); | |
| 1224 | } | |
| 1225 | ||
| 1226 | /** The person shared with hears of it (they can read it now, so its title may go). */ | |
| 1227 | private async notifyShared(ctx: Ctx, row: FolioRow, userId: string, role: DocRole, message: string | null): Promise<void> { | |
| 1228 | if (!this.env.NOTIFY || userId === ctx.viewer.id) return; | |
| 1229 | const me = (await this.who.profiles(ctx.workspace, [ctx.key])).get(ctx.key)!; | |
| 1230 | const verb = role === "view" ? "view" : role === "comment" ? "comment on" : "edit"; | |
| 1231 | await notifyClient(this.env.NOTIFY) | |
| 1232 | .notify( | |
| 1233 | { user_id: userId }, | |
| 1234 | { | |
| 1235 | id: `folio-shared:${row.id}:${userId}:${Date.now()}`, | |
| 1236 | kind: "inbox", | |
| 1237 | workspace: ctx.workspace.slug, | |
| 1238 | title: `${me.display_name} shared ${row.title || "Untitled"} with you`, | |
| 1239 | body: message ?? `You can ${verb} it.`, | |
| 1240 | href: this.ref(ctx.workspace.slug, row).path, | |
| 1241 | actor: { kind: "user", id: ctx.viewer.id, name: me.display_name, avatar: me.avatar, avatar_seed: null }, | |
| 1242 | created_at: now(), | |
| 1243 | }, | |
| 1244 | ) | |
| 1245 | .catch(() => undefined); | |
| 1246 | } | |
| 1247 | ||
| 1248 | async setGeneralAccess(a: Args & { folio_id: string; change: FolioAccessChange }): Promise<Result<FolioAccessList>> { | |
| 1249 | const change = a.change; | |
| 1250 | if (!change || change.op === "grant" || change.op === "revoke") return fail("invalid", "Grants and revokes go to set_folio_grant."); | |
| 1251 | const invalid = folioAccessChangeError(change); | |
| 1252 | if (invalid) return fail("invalid", invalid); | |
| 1253 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1254 | if (!found.ok) return found; | |
| 1255 | const ctx = found.value; | |
| 1256 | const opened = await this.open(ctx, a.folio_id, "view"); | |
| 1257 | if (!opened.ok) return opened; | |
| 1258 | const { row, role } = opened.value; | |
| 1259 | if (!canShare(role)) return fail("forbidden", "Only people with full access can change who can open it."); | |
| 1260 | const at = now(); | |
| 1261 | if (change.op === "general") { | |
| 1262 | if (row.inherit && row.parent_id) { | |
| 1263 | const parent = opened.value.found.rows.get(row.parent_id); | |
| 1264 | return fail("invalid", `It follows ${parent?.title || "the doc it's in"}. Change it there, or choose "Only people invited" first.`); | |
| 1265 | } | |
| 1266 | await this.db | |
| 1267 | .prepare("UPDATE folios SET general_access = ?, general_role = ?, updated_at = ?, updated_by = ? WHERE id = ?") | |
| 1268 | .bind(change.access, change.access === "none" ? null : change.role, at, ctx.key, row.id) | |
| 1269 | .run(); | |
| 1270 | } else if (change.op === "inherit") { | |
| 1271 | if (!row.parent_id && !row.space_id) return fail("invalid", "It's in Private, so there is nothing for it to follow."); | |
| 1272 | if (change.inherit && !row.inherit) { | |
| 1273 | // Following again: its own general access gives way to what it follows. | |
| 1274 | await this.db.prepare("UPDATE folios SET inherit = 1, general_access = CASE WHEN parent_id IS NULL THEN general_access ELSE 'none' END, general_role = CASE WHEN parent_id IS NULL THEN general_role ELSE NULL END, updated_at = ?, updated_by = ? WHERE id = ?").bind(at, ctx.key, row.id).run(); | |
| 1275 | } else if (!change.inherit && row.inherit) { | |
| 1276 | await this.db.prepare("UPDATE folios SET inherit = 0, updated_at = ?, updated_by = ? WHERE id = ?").bind(at, ctx.key, row.id).run(); | |
| 1277 | } | |
| 1278 | } else if (change.op === "agent_mode") { | |
| 1279 | await this.db.prepare("UPDATE folios SET agent_mode = ?, updated_at = ?, updated_by = ? WHERE id = ?").bind(change.agent_mode, at, ctx.key, row.id).run(); | |
| 1280 | const again = await this.open(ctx, row.id, "view"); | |
| 1281 | if (!again.ok) return again; | |
| 1282 | this.tell(row.id, { type: "folio.access" }); | |
| 1283 | return ok(await this.accessList(ctx, again.value.row, again.value.role, again.value.found)); | |
| 1284 | } | |
| 1285 | return ok(await this.afterShare(ctx, row)); | |
| 1286 | } | |
| 1287 | ||
| 1288 | async requestAccess(a: Args & { folio_id: string; message?: string | null }): Promise<Result<boolean>> { | |
| 1289 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1290 | if (!found.ok) return found; | |
| 1291 | const ctx = found.value; | |
| 1292 | const row = await this.db.prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE id = ? AND workspace_id = ? AND trashed_at IS NULL`).bind(String(a.folio_id ?? ""), ctx.workspace.id).first<FolioRow>(); | |
| 1293 | if (!row) return fail("not_found", "No such artifact."); | |
| 1294 | const { roles } = await this.roles(ctx, [row]); | |
| 1295 | if (roles.get(row.id)) return ok(true); | |
| 1296 | if (!this.env.NOTIFY) return ok(true); | |
| 1297 | if (!(await claimAccessRequest(this.db, row.id, ctx.viewer.id))) return fail("conflict", "You already asked for access to this in the last day. Its owner has your request; you can ask again tomorrow."); | |
| 1298 | // The owner and anyone with full access through a grant hear of it. | |
| 1299 | const managers = ( | |
| 1300 | await this.db.prepare("SELECT principal FROM folio_access WHERE folio_id = ? AND role = 'manage' AND principal LIKE 'user:%'").bind(row.id).all<{ principal: string }>() | |
| 1301 | ).results.map((r) => r.principal.slice(5)); | |
| 1302 | const me = (await this.who.profiles(ctx.workspace, [ctx.key])).get(ctx.key)!; | |
| 1303 | const message = cleanNote(a.message); | |
| 1304 | const notify = notifyClient(this.env.NOTIFY); | |
| 1305 | await Promise.all( | |
| 1306 | [...new Set([row.owner.slice(5), ...managers])].slice(0, REQUEST_RECIPIENTS).map((id) => | |
| 1307 | notify | |
| 1308 | .notify( | |
| 1309 | { user_id: id }, | |
| 1310 | { | |
| 1311 | id: `folio-request:${row.id}:${ctx.viewer.id}:${id}`, | |
| 1312 | kind: "inbox", | |
| 1313 | workspace: ctx.workspace.slug, | |
| 1314 | title: `${me.display_name} asks for access to ${row.title || "Untitled"}`, | |
| 1315 | body: message ?? "Open it and choose Share to let them in.", | |
| 1316 | href: this.ref(ctx.workspace.slug, row).path, | |
| 1317 | actor: { kind: "user", id: ctx.viewer.id, name: me.display_name, avatar: me.avatar, avatar_seed: null }, | |
| 1318 | created_at: now(), | |
| 1319 | }, | |
| 1320 | ) | |
| 1321 | .catch(() => undefined), | |
| 1322 | ), | |
| 1323 | ); | |
| 1324 | return ok(true); | |
| 1325 | } | |
| 1326 | ||
| 1327 | async joinSpace(a: Args & { space_id: string }): Promise<Result<boolean>> { | |
| 1328 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1329 | if (!found.ok) return found; | |
| 1330 | const ctx = found.value; | |
| 1331 | const space = ctx.spaceById.get(String(a.space_id ?? "")); | |
| 1332 | if (!space?.role || space.row.archived_at) return fail("not_found", "No such space."); | |
| 1333 | if (space.row.kind !== "workspace") return fail("invalid", "Only open spaces are joined; you're in team and members-only spaces already."); | |
| 1334 | await this.db | |
| 1335 | .prepare("INSERT OR IGNORE INTO space_joins (space_id, user_id, position, joined_at) VALUES (?, ?, (SELECT COALESCE(MAX(position), 0) + 1024 FROM space_joins WHERE user_id = ?), ?)") | |
| 1336 | .bind(space.row.id, ctx.viewer.id, ctx.viewer.id, now()) | |
| 1337 | .run(); | |
| 1338 | return ok(true); | |
| 1339 | } | |
| 1340 | ||
| 1341 | async leaveSpace(a: Args & { space_id: string }): Promise<Result<boolean>> { | |
| 1342 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1343 | if (!found.ok) return found; | |
| 1344 | await this.db.prepare("DELETE FROM space_joins WHERE space_id = ? AND user_id = ?").bind(String(a.space_id ?? ""), found.value.viewer.id).run(); | |
| 1345 | return ok(true); | |
| 1346 | } | |
| 1347 | ||
| 1348 | // ── Search ────────────────────────────────────────────────────────────── | |
| 1349 | ||
| 1350 | async search(a: Args & { query: { q: string; kinds?: FolioKind[] | null; space_id?: string | null; project?: string | null; owner?: string | null; mode?: "words" | "hybrid" | null; limit?: number | null } }): Promise<Result<FolioSearchHit[]>> { | |
| 1351 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1352 | if (!found.ok) return found; | |
| 1353 | return ok(await this.searchFor(found.value, a.query ?? { q: "" })); | |
| 1354 | } | |
| 1355 | ||
| 1356 | /** Words over titles and text (folios_fts), and by meaning over passages when asked; only folios the viewer can read now. */ | |
| 1357 | private async searchFor( | |
| 1358 | ctx: Ctx, | |
| 1359 | query: { q: string; kinds?: FolioKind[] | null; space_id?: string | null; project?: string | null; owner?: string | null; mode?: "words" | "hybrid" | null; limit?: number | null }, | |
| 1360 | narrow?: (rows: FolioRow[]) => Promise<Set<string>>, | |
| 1361 | ): Promise<FolioSearchHit[]> { | |
| 1362 | const q = ftsQuery(String(query.q ?? "")); | |
| 1363 | const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50); | |
| 1364 | const filter = this.filterOf(ctx); | |
| 1365 | const where: string[] = ["f.workspace_id = ?", "f.trashed_at IS NULL", filter.sql]; | |
| 1366 | const binds: unknown[] = [ctx.workspace.id, ...filter.binds]; | |
| 1367 | if (query.kinds?.length) { | |
| 1368 | where.push("f.kind IN (SELECT value FROM json_each(?))"); | |
| 1369 | binds.push(json(query.kinds.filter(isFolioKind))); | |
| 1370 | } | |
| 1371 | if (query.space_id === "private") where.push("f.space_id IS NULL"); | |
| 1372 | else if (query.space_id) { | |
| 1373 | where.push("f.space_id = ?"); | |
| 1374 | binds.push(query.space_id); | |
| 1375 | } | |
| 1376 | if (query.owner) { | |
| 1377 | where.push("f.owner = ?"); | |
| 1378 | binds.push(query.owner); | |
| 1379 | } | |
| 1380 | const project = query.project ? projectRef(query.project) : null; | |
| 1381 | if (project) { | |
| 1382 | where.push("(f.id IN (SELECT folio_id FROM folio_projects WHERE repo = ?) OR f.space_id IN (SELECT space_id FROM space_projects WHERE repo = ?))"); | |
| 1383 | binds.push(project, project); | |
| 1384 | } | |
| 1385 | type Hit = FolioRow & { snippet: string }; | |
| 1386 | const words: Hit[] = q | |
| 1387 | ? ( | |
| 1388 | await this.db | |
| 1389 | .prepare( | |
| 1390 | `SELECT ${folioColumns("f")}, snippet(folios_fts, 3, '[[', ']]', '…', 16) AS snippet FROM folios_fts JOIN folios f ON f.id = folios_fts.folio_id JOIN folios r ON r.id = f.acl_root | |
| 1391 | WHERE folios_fts MATCH ? AND ${where.join(" AND ")} ORDER BY bm25(folios_fts, 0, 0, 8.0, 1.0) LIMIT ?`, | |
| 1392 | ) | |
| 1393 | .bind(q, ...binds, limit * 3) | |
| 1394 | .all<Hit>() | |
| 1395 | ).results | |
| 1396 | : (await this.db.prepare(`SELECT ${folioColumns("f")}, f.excerpt AS snippet FROM folios f JOIN folios r ON r.id = f.acl_root WHERE ${where.join(" AND ")} ORDER BY f.edited_at DESC LIMIT ?`).bind(...binds, limit * 3).all<Hit>()).results; | |
| 1397 | // Meaning: passages near the query from scopes the viewer may read, each one checked again below. | |
| 1398 | let meaning: { folio_id: string; heading: string | null; text: string; score: number }[] = []; | |
| 1399 | if (q && query.mode === "hybrid") { | |
| 1400 | meaning = await this.meaningPassages(ctx, String(query.q), (await this.allowedScopes(ctx)).scopes).catch((error: unknown) => { | |
| 1401 | console.error("folios could not search by meaning", String(error)); | |
| 1402 | return []; | |
| 1403 | }); | |
| 1404 | meaning = meaning.filter((m) => m.score >= MEANING_FLOOR); | |
| 1405 | } | |
| 1406 | const extra = meaning.length ? await foliosById(this.db, meaning.map((m) => m.folio_id)) : new Map<string, FolioRow>(); | |
| 1407 | const candidates = [...new Map([...words.map((w) => [w.id, w as FolioRow] as const), ...[...extra.values()].filter((r) => r.workspace_id === ctx.workspace.id && !r.trashed_at).map((r) => [r.id, r] as const)]).values()]; | |
| 1408 | const { roles } = await this.roles(ctx, candidates); | |
| 1409 | let readable = new Set(candidates.filter((r) => roles.get(r.id)).map((r) => r.id)); | |
| 1410 | if (narrow) { | |
| 1411 | const allowed = await narrow(candidates.filter((r) => readable.has(r.id))); | |
| 1412 | readable = new Set([...readable].filter((id) => allowed.has(id))); | |
| 1413 | } | |
| 1414 | // Meaning-only hits still have to match the filters. | |
| 1415 | const fits = (r: FolioRow) => (!query.kinds?.length || query.kinds.includes(r.kind)) && (!query.space_id || (query.space_id === "private" ? !r.space_id : r.space_id === query.space_id)) && (!query.owner || r.owner === query.owner); | |
| 1416 | const byWords = new Map(words.filter((w) => readable.has(w.id)).map((w) => [w.id, w])); | |
| 1417 | const bestMeaning = new Map<string, (typeof meaning)[number]>(); | |
| 1418 | for (const m of meaning) { | |
| 1419 | const r = extra.get(m.folio_id); | |
| 1420 | if (!r || !readable.has(r.id) || !fits(r) || (project && !byWords.has(r.id))) continue; | |
| 1421 | if ((bestMeaning.get(m.folio_id)?.score ?? -1) < m.score) bestMeaning.set(m.folio_id, m); | |
| 1422 | } | |
| 1423 | const order = query.mode === "hybrid" ? fuseRanks([...byWords.keys()], [...bestMeaning.values()].sort((x, y) => y.score - x.score).map((m) => m.folio_id)) : [...byWords.keys()]; | |
| 1424 | const spaceName = (id: string | null) => (id ? (ctx.spaceById.get(id)?.row.name ?? null) : null); | |
| 1425 | const out: FolioSearchHit[] = []; | |
| 1426 | for (const id of order) { | |
| 1427 | if (out.length >= limit) break; | |
| 1428 | const w = byWords.get(id); | |
| 1429 | const m = bestMeaning.get(id); | |
| 1430 | const row = w ?? extra.get(id); | |
| 1431 | if (!row) continue; | |
| 1432 | out.push({ | |
| 1433 | ...this.ref(ctx.workspace.slug, row), | |
| 1434 | space_name: spaceName(row.space_id), | |
| 1435 | snippet: w ? (q ? w.snippet : excerpt(w.snippet, 140)) : excerpt(m!.text, 200), | |
| 1436 | edited_at: row.edited_at, | |
| 1437 | heading: m?.heading ?? null, | |
| 1438 | matched: query.mode === "hybrid" ? (w && m ? "both" : w ? "words" : "meaning") : null, | |
| 1439 | }); | |
| 1440 | } | |
| 1441 | return out; | |
| 1442 | } | |
| 1443 | ||
| 1444 | /** | |
| 1445 | * The scopes the viewer may recall from (src/access.ts `folioScope`): | |
| 1446 | * their readable spaces, and the access roots of folios shared with | |
| 1447 | * them, open to the workspace, or whose link they opened. Every hit is | |
| 1448 | * still checked against the folio itself. | |
| 1449 | */ | |
| 1450 | private async allowedScopes(ctx: Ctx): Promise<{ scopes: string[] }> { | |
| 1451 | const keys = personKeys(ctx.person); | |
| 1452 | const [shared, general, visited] = await Promise.all([ | |
| 1453 | this.db | |
| 1454 | .prepare("SELECT DISTINCT f.acl_root AS id FROM folio_access a JOIN folios f ON f.id = a.folio_id WHERE a.principal IN (SELECT value FROM json_each(?)) AND f.workspace_id = ? AND f.trashed_at IS NULL LIMIT 2000") | |
| 1455 | .bind(json(keys), ctx.workspace.id) | |
| 1456 | .all<{ id: string }>(), | |
| 1457 | this.db.prepare("SELECT id FROM folios WHERE workspace_id = ? AND id = acl_root AND general_access = 'workspace' AND trashed_at IS NULL LIMIT 2000").bind(ctx.workspace.id).all<{ id: string }>(), | |
| 1458 | this.db | |
| 1459 | .prepare("SELECT DISTINCT f.acl_root AS id FROM folio_visits v JOIN folios f ON f.id = v.folio_id WHERE v.user_id = ? AND f.workspace_id = ? AND f.trashed_at IS NULL LIMIT 2000") | |
| 1460 | .bind(ctx.viewer.id, ctx.workspace.id) | |
| 1461 | .all<{ id: string }>(), | |
| 1462 | ]); | |
| 1463 | const scopes = new Set<string>(ctx.spaces.filter((s) => s.role).map((s) => `space:${s.row.id}`)); | |
| 1464 | for (const r of [...shared.results, ...general.results, ...visited.results]) scopes.add(`folio:${r.id}`); | |
| 1465 | return { scopes: [...scopes] }; | |
| 1466 | } | |
| 1467 | ||
| 1468 | private async queryVector(query: string, embedder: { embed(texts: string[]): Promise<number[][]> } | null): Promise<number[] | null> { | |
| 1469 | const key = queryKey(query); | |
| 1470 | if (!embedder || !key) return null; | |
| 1471 | const cached = queryVectors.get(key); | |
| 1472 | if (cached) return cached; | |
| 1473 | try { | |
| 1474 | const [vector] = await embedder.embed([key]); | |
| 1475 | if (vector) queryVectors.set(key, vector); | |
| 1476 | return vector ?? null; | |
| 1477 | } catch (error) { | |
| 1478 | console.error("folios could not embed a query; matching words instead", String(error)); | |
| 1479 | return null; | |
| 1480 | } | |
| 1481 | } | |
| 1482 | ||
| 1483 | /** Folio passages nearest the query, from these scopes (by the index's filter, or after). Empty without an index. */ | |
| 1484 | private async meaningPassages(ctx: Ctx, query: string, scopes: string[], kinds?: FolioKind[] | null): Promise<{ id: string; folio_id: string; heading: string | null; text: string; score: number }[]> { | |
| 1485 | const { embedder, store } = folioAdapters(this.env); | |
| 1486 | const plan = vectorQueryPlan(ctx.workspace.id, scopes); | |
| 1487 | if (!store || !plan) return []; | |
| 1488 | const vector = await this.queryVector(query, embedder); | |
| 1489 | if (!vector) return []; | |
| 1490 | let matches: { id: string; score: number }[] = []; | |
| 1491 | try { | |
| 1492 | matches = await store.query(vector, { topK: plan.topK, filter: { workspace_id: ctx.workspace.id, ...(plan.filter.space_ids ? { scopes: plan.filter.space_ids } : {}) } }); | |
| 1493 | } catch (error) { | |
| 1494 | console.error("folios semantic query failed; matching words instead", String(error)); | |
| 1495 | return []; | |
| 1496 | } | |
| 1497 | if (!matches.length) return []; | |
| 1498 | const allowed = new Set(scopes); | |
| 1499 | const rows = ( | |
| 1500 | await this.db | |
| 1501 | .prepare("SELECT id, folio_id, kind, scope, heading, text FROM folio_chunks WHERE workspace_id = ? AND id IN (SELECT value FROM json_each(?))") | |
| 1502 | .bind( | |
| 1503 | ctx.workspace.id, | |
| 1504 | json(matches.map((m) => m.id)), | |
| 1505 | ) | |
| 1506 | .all<{ id: string; folio_id: string; kind: FolioKind; scope: string; heading: string | null; text: string }>() | |
| 1507 | ).results; | |
| 1508 | const byId = new Map(rows.map((r) => [r.id, r])); | |
| 1509 | return matches | |
| 1510 | .map((m) => ({ m, r: byId.get(m.id) })) | |
| 1511 | .filter((x): x is { m: { id: string; score: number }; r: (typeof rows)[number] } => !!x.r && allowed.has(x.r.scope) && (!kinds?.length || kinds.includes(x.r.kind))) | |
| 1512 | .map(({ m, r }) => ({ id: r.id, folio_id: r.folio_id, heading: r.heading, text: r.text, score: m.score })); | |
| 1513 | } | |
| 1514 | ||
| 1515 | // ── History ───────────────────────────────────────────────────────────── | |
| 1516 | ||
| 1517 | private async toVersions(workspace: Workspace, rows: Pick<VersionRow, "id" | "folio_id" | "created_at" | "kind" | "authors" | "note">[]): Promise<FolioVersion[]> { | |
| 1518 | const authors = rows.map((r) => parseJson<string[]>(r.authors, [])); | |
| 1519 | const people = await this.who.profiles(workspace, authors.flat()); | |
| 1520 | return rows.map((r, i) => ({ id: r.id, folio_id: r.folio_id, created_at: r.created_at, kind: r.kind, note: r.note, authors: authors[i]!.map((k) => people.get(k)!).filter(Boolean) })); | |
| 1521 | } | |
| 1522 | ||
| 1523 | async versions(a: Args & { folio_id: string }): Promise<Result<FolioVersion[]>> { | |
| 1524 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1525 | if (!found.ok) return found; | |
| 1526 | const ctx = found.value; | |
| 1527 | const opened = await this.open(ctx, a.folio_id, "view"); | |
| 1528 | if (!opened.ok) return opened; | |
| 1529 | await this.room(opened.value.row.id) | |
| 1530 | .flush() | |
| 1531 | .catch(() => undefined); | |
| 1532 | const rows = ( | |
| 1533 | await this.db.prepare("SELECT id, folio_id, created_at, kind, authors, note FROM folio_versions WHERE folio_id = ? ORDER BY created_at DESC LIMIT 200").bind(opened.value.row.id).all<VersionRow>() | |
| 1534 | ).results; | |
| 1535 | return ok(await this.toVersions(ctx.workspace, rows)); | |
| 1536 | } | |
| 1537 | ||
| 1538 | async version(a: Args & { folio_id: string; version_id: string }): Promise<Result<FolioVersionDetail>> { | |
| 1539 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1540 | if (!found.ok) return found; | |
| 1541 | const ctx = found.value; | |
| 1542 | const opened = await this.open(ctx, a.folio_id, "view"); | |
| 1543 | if (!opened.ok) return opened; | |
| 1544 | const row = await this.db.prepare("SELECT id, folio_id, created_at, kind, authors, note, text FROM folio_versions WHERE id = ? AND folio_id = ?").bind(String(a.version_id ?? ""), opened.value.row.id).first<VersionRow>(); | |
| 1545 | if (!row) return fail("not_found", "No such version."); | |
| 1546 | const before = await this.db.prepare("SELECT text FROM folio_versions WHERE folio_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT 1").bind(row.folio_id, row.created_at).first<{ text: string }>(); | |
| 1547 | const [version] = await this.toVersions(ctx.workspace, [row]); | |
| 1548 | return ok({ ...version!, text: row.text, diff: diffLines(before?.text ?? "", row.text) }); | |
| 1549 | } | |
| 1550 | ||
| 1551 | async restoreVersion(a: Args & { folio_id: string; version_id: string }): Promise<Result<FolioVersion>> { | |
| 1552 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1553 | if (!found.ok) return found; | |
| 1554 | const ctx = found.value; | |
| 1555 | const opened = await this.open(ctx, a.folio_id, "edit"); | |
| 1556 | if (!opened.ok) return opened; | |
| 1557 | const { row } = opened.value; | |
| 1558 | const version = await this.db.prepare("SELECT * FROM folio_versions WHERE id = ? AND folio_id = ?").bind(String(a.version_id ?? ""), row.id).first<VersionRow>(); | |
| 1559 | if (!version) return fail("not_found", "No such version."); | |
| 1560 | let state: Uint8Array | null = version.state ? new Uint8Array(version.state) : null; | |
| 1561 | if (!state && version.state_key) { | |
| 1562 | const stored = await fileStore(this.env) | |
| 1563 | .get(version.state_key) | |
| 1564 | .catch(() => null); | |
| 1565 | if (stored) state = new Uint8Array(await new Response(stored.body).arrayBuffer()); | |
| 1566 | } | |
| 1567 | const room = await this.ready(ctx.workspace, row); | |
| 1568 | const when = new Date(version.created_at).toISOString().slice(0, 16).replace("T", " "); | |
| 1569 | const origin: FolioOrigin = { key: ctx.key, kind: "restore", note: `Restored the version of ${when} UTC` }; | |
| 1570 | const versionId = await room.restore({ state, text: version.text }, origin); | |
| 1571 | const created = versionId ? await this.db.prepare("SELECT id, folio_id, created_at, kind, authors, note FROM folio_versions WHERE id = ?").bind(versionId).first<VersionRow>() : null; | |
| 1572 | if (!created) return fail("conflict", "It could not be restored. Try again."); | |
| 1573 | const [v] = await this.toVersions(ctx.workspace, [created]); | |
| 1574 | this.tell(row.id, { type: "version.created", version: v! }); | |
| 1575 | return ok(v!); | |
| 1576 | } | |
| 1577 | ||
| 1578 | // ── Templates and export ──────────────────────────────────────────────── | |
| 1579 | ||
| 1580 | private async savedTemplate(workspace: Workspace, id: string): Promise<FolioTemplate | null> { | |
| 1581 | const row = await this.db | |
| 1582 | .prepare("SELECT * FROM folio_templates WHERE id = ? AND workspace_id = ?") | |
| 1583 | .bind(id, workspace.id) | |
| 1584 | .first<{ id: string; kind: FolioKind; name: string; description: string; icon: string | null; body: string; created_by: string }>(); | |
| 1585 | if (!row) return null; | |
| 1586 | const by = (await this.who.profiles(workspace, [row.created_by])).get(row.created_by) ?? null; | |
| 1587 | return { id: row.id, kind: row.kind, name: row.name, description: row.description, icon: row.icon, builtin: false, body: row.body, created_by: by }; | |
| 1588 | } | |
| 1589 | ||
| 1590 | async templates(a: Args & { kind?: FolioKind | null }): Promise<Result<FolioTemplate[]>> { | |
| 1591 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1592 | if (!found.ok) return found; | |
| 1593 | const ctx = found.value; | |
| 1594 | const kind = a.kind && isFolioKind(a.kind) ? a.kind : null; | |
| 1595 | const rows = ( | |
| 1596 | await this.db | |
| 1597 | .prepare(`SELECT * FROM folio_templates WHERE workspace_id = ? ${kind ? "AND kind = ?" : ""} ORDER BY name COLLATE NOCASE`) | |
| 1598 | .bind(ctx.workspace.id, ...(kind ? [kind] : [])) | |
| 1599 | .all<{ id: string; kind: FolioKind; name: string; description: string; icon: string | null; body: string; created_by: string }>() | |
| 1600 | ).results; | |
| 1601 | const people = await this.who.profiles( | |
| 1602 | ctx.workspace, | |
| 1603 | rows.map((r) => r.created_by), | |
| 1604 | ); | |
| 1605 | return ok([ | |
| 1606 | ...builtinFolioTemplates(kind), | |
| 1607 | ...rows.map((r) => ({ id: r.id, kind: r.kind, name: r.name, description: r.description, icon: r.icon, builtin: false, body: r.body, created_by: people.get(r.created_by) ?? null })), | |
| 1608 | ]); | |
| 1609 | } | |
| 1610 | ||
| 1611 | async saveTemplate(a: Args & { input: { folio_id: string; name: string; description?: string | null } }): Promise<Result<FolioTemplate>> { | |
| 1612 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1613 | if (!found.ok) return found; | |
| 1614 | const ctx = found.value; | |
| 1615 | const opened = await this.open(ctx, a.input?.folio_id, "view"); | |
| 1616 | if (!opened.ok) return opened; | |
| 1617 | const { row } = opened.value; | |
| 1618 | const name = cleanTitle(a.input.name || row.title, 80); | |
| 1619 | if (!name) return fail("invalid", "Name the template."); | |
| 1620 | const body = await (await this.ready(ctx.workspace, row)).text(); | |
| 1621 | const id = newId("tpl"); | |
| 1622 | const description = cleanTitle(a.input.description ?? "", 200); | |
| 1623 | await this.db | |
| 1624 | .prepare("INSERT INTO folio_templates (id, workspace_id, kind, name, description, icon, body, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 1625 | .bind(id, ctx.workspace.id, row.kind, name, description, row.icon, body, ctx.key, now()) | |
| 1626 | .run(); | |
| 1627 | const me = (await this.who.profiles(ctx.workspace, [ctx.key])).get(ctx.key) ?? null; | |
| 1628 | return ok({ id, kind: row.kind, name, description, icon: row.icon, builtin: false, body, created_by: me }); | |
| 1629 | } | |
| 1630 | ||
| 1631 | async deleteTemplate(a: Args & { template_id: string }): Promise<Result<boolean>> { | |
| 1632 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1633 | if (!found.ok) return found; | |
| 1634 | const ctx = found.value; | |
| 1635 | const row = await this.db.prepare("SELECT created_by FROM folio_templates WHERE id = ? AND workspace_id = ?").bind(String(a.template_id ?? ""), ctx.workspace.id).first<{ created_by: string }>(); | |
| 1636 | if (!row) return fail("not_found", "No such template."); | |
| 1637 | if (row.created_by !== ctx.key && !ctx.owner) return fail("forbidden", "Only whoever saved a template, or an owner, can delete it."); | |
| 1638 | await this.db.prepare("DELETE FROM folio_templates WHERE id = ?").bind(a.template_id).run(); | |
| 1639 | return ok(true); | |
| 1640 | } | |
| 1641 | ||
| 1642 | async export(a: Args & { folio_id: string; format?: "markdown" | "json" | null }): Promise<Result<{ filename: string; content_type: string; body: string }>> { | |
| 1643 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1644 | if (!found.ok) return found; | |
| 1645 | const ctx = found.value; | |
| 1646 | const opened = await this.open(ctx, a.folio_id, "view"); | |
| 1647 | if (!opened.ok) return opened; | |
| 1648 | const { row } = opened.value; | |
| 1649 | if (!kindModel(row.kind)) return fail("invalid", `${kindLabel(row.kind)} aren't here yet.`); | |
| 1650 | const read = await (await this.ready(ctx.workspace, row)).read(); | |
| 1651 | const title = row.title || "Untitled"; | |
| 1652 | const base = title.replace(/[\\/:*?"<>|]+/g, " ").trim() || "artifact"; | |
| 1653 | const markdown = row.kind === "doc" || row.kind === "slides"; | |
| 1654 | if ((a.format ?? (markdown ? "markdown" : "json")) === "markdown" && markdown) { | |
| 1655 | return ok({ filename: `${base}.md`, content_type: "text/markdown; charset=utf-8", body: `# ${title}\n\n${read.content}` }); | |
| 1656 | } | |
| 1657 | return ok({ filename: `${base}.json`, content_type: "application/json", body: JSON.stringify({ kind: row.kind, title, content: read.content }, null, 2) }); | |
| 1658 | } | |
| 1659 | ||
| 1660 | // ── Suggestions, proposals and comments ───────────────────────────────── | |
| 1661 | ||
| 1662 | private async toSuggestions(workspace: Workspace, rows: SuggestionRow[], blocks: (string[] | null)[] = []): Promise<FolioSuggestion[]> { | |
| 1663 | const people = await this.who.profiles( | |
| 1664 | workspace, | |
| 1665 | rows.flatMap((r) => [r.author, r.asked_by, r.decided_by].filter((k): k is string => !!k)), | |
| 1666 | ); | |
| 1667 | return rows.map((r, i) => ({ | |
| 1668 | id: r.id, | |
| 1669 | folio_id: r.folio_id, | |
| 1670 | author: people.get(r.author)!, | |
| 1671 | asked_by: r.asked_by ? (people.get(r.asked_by) ?? null) : null, | |
| 1672 | target: parseJson<DocEditTarget>(r.target, { kind: "append" }), | |
| 1673 | before_markdown: r.before_markdown, | |
| 1674 | after_markdown: r.after_markdown, | |
| 1675 | note: r.note, | |
| 1676 | status: r.status, | |
| 1677 | created_at: r.created_at, | |
| 1678 | decided_by: r.decided_by ? (people.get(r.decided_by) ?? null) : null, | |
| 1679 | decided_at: r.decided_at, | |
| 1680 | block_ids: blocks[i] ?? [], | |
| 1681 | })); | |
| 1682 | } | |
| 1683 | ||
| 1684 | private async openSuggestions(ctx: Ctx, row: FolioRow): Promise<FolioSuggestion[]> { | |
| 1685 | const rows = (await this.db.prepare("SELECT * FROM folio_suggestions WHERE folio_id = ? AND status = 'open' ORDER BY created_at").bind(row.id).all<SuggestionRow>()).results; | |
| 1686 | if (!rows.length) return []; | |
| 1687 | let blocks: (string[] | null)[] = rows.map(() => []); | |
| 1688 | try { | |
| 1689 | blocks = await (await this.ready(ctx.workspace, row)).targets(rows.map((r) => parseJson<DocEditTarget>(r.target, { kind: "append" }))); | |
| 1690 | } catch (error) { | |
| 1691 | console.error("folios could not place suggestions", String(error)); | |
| 1692 | } | |
| 1693 | const gone = rows.filter((_, i) => blocks[i] === null); | |
| 1694 | if (gone.length) await this.db.batch(gone.map((r) => this.db.prepare("UPDATE folio_suggestions SET status = 'stale' WHERE id = ?").bind(r.id))); | |
| 1695 | const live = rows.map((r, i) => ({ r, b: blocks[i] })).filter((x) => x.b !== null); | |
| 1696 | return this.toSuggestions( | |
| 1697 | ctx.workspace, | |
| 1698 | live.map((x) => x.r), | |
| 1699 | live.map((x) => x.b!), | |
| 1700 | ); | |
| 1701 | } | |
| 1702 | ||
| 1703 | /** Files a doc suggestion: someone who can comment (a person, or an agent for one). */ | |
| 1704 | private async fileSuggestion( | |
| 1705 | ctx: Ctx, | |
| 1706 | row: FolioRow, | |
| 1707 | by: { author: string; asked_by: string | null; agentName: string | null }, | |
| 1708 | edit: { target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean }, | |
| 1709 | ): Promise<Result<FolioSuggestion>> { | |
| 1710 | const room = await this.ready(ctx.workspace, row); | |
| 1711 | const current = await room.target(edit.target); | |
| 1712 | if (!current) return fail("not_found", "That part of the doc isn't there. Read it again and target what is there now."); | |
| 1713 | const s: SuggestionRow = { | |
| 1714 | id: newId("sug"), | |
| 1715 | folio_id: row.id, | |
| 1716 | author: by.author, | |
| 1717 | asked_by: by.asked_by, | |
| 1718 | target: JSON.stringify(edit.target), | |
| 1719 | before_markdown: current.markdown, | |
| 1720 | after_markdown: edit.markdown, | |
| 1721 | note: edit.note, | |
| 1722 | status: "open", | |
| 1723 | created_at: now(), | |
| 1724 | decided_by: null, | |
| 1725 | decided_at: null, | |
| 1726 | marks_current: edit.marks_current ? 1 : 0, | |
| 1727 | }; | |
| 1728 | await this.db | |
| 1729 | .prepare("INSERT INTO folio_suggestions (id, folio_id, author, asked_by, target, before_markdown, after_markdown, note, status, created_at, marks_current) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)") | |
| 1730 | .bind(s.id, s.folio_id, s.author, s.asked_by, s.target, s.before_markdown, s.after_markdown, s.note, s.created_at, s.marks_current) | |
| 1731 | .run(); | |
| 1732 | const [suggestion] = await this.toSuggestions(ctx.workspace, [s], [current.block_ids]); | |
| 1733 | this.tell(row.id, { type: "suggestion.created", suggestion: suggestion! }); | |
| 1734 | if (by.agentName) this.defer(room.announce(by.author, by.agentName).catch(() => undefined)); | |
| 1735 | this.defer(this.notifyOwnerOfSuggestion(ctx, row, suggestion!)); | |
| 1736 | return ok(suggestion!); | |
| 1737 | } | |
| 1738 | ||
| 1739 | private async notifyOwnerOfSuggestion(ctx: Ctx, row: FolioRow, suggestion: FolioSuggestion): Promise<void> { | |
| 1740 | if (!this.env.NOTIFY || !row.owner.startsWith("user:") || row.owner === suggestion.author.kind + ":" + suggestion.author.id) return; | |
| 1741 | const id = row.owner.slice(5); | |
| 1742 | await notifyClient(this.env.NOTIFY) | |
| 1743 | .notify( | |
| 1744 | { user_id: id }, | |
| 1745 | { | |
| 1746 | id: `folio-suggestion:${suggestion.id}:${id}`, | |
| 1747 | kind: "inbox", | |
| 1748 | workspace: ctx.workspace.slug, | |
| 1749 | title: `${suggestion.author.display_name} suggested a change to ${row.title || "Untitled"}`, | |
| 1750 | body: suggestion.note ?? excerpt(suggestion.after_markdown, 140), | |
| 1751 | href: this.ref(ctx.workspace.slug, row).path, | |
| 1752 | actor: { kind: suggestion.author.kind, id: suggestion.author.id, name: suggestion.author.display_name, avatar: suggestion.author.avatar, avatar_seed: suggestion.author.avatar_seed ?? null }, | |
| 1753 | created_at: suggestion.created_at, | |
| 1754 | }, | |
| 1755 | ) | |
| 1756 | .catch(() => undefined); | |
| 1757 | } | |
| 1758 | ||
| 1759 | async suggestions(a: Args & { folio_id: string }): Promise<Result<FolioSuggestion[]>> { | |
| 1760 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1761 | if (!found.ok) return found; | |
| 1762 | const ctx = found.value; | |
| 1763 | const opened = await this.open(ctx, a.folio_id, "view"); | |
| 1764 | if (!opened.ok) return opened; | |
| 1765 | if (opened.value.row.kind !== "doc") return ok([]); | |
| 1766 | return ok(await this.openSuggestions(ctx, opened.value.row)); | |
| 1767 | } | |
| 1768 | ||
| 1769 | async decideSuggestion(a: Args & { suggestion_id: string; decision: "accept" | "reject" }): Promise<Result<FolioSuggestion>> { | |
| 1770 | const s = await this.db.prepare("SELECT * FROM folio_suggestions WHERE id = ?").bind(String(a.suggestion_id ?? "")).first<SuggestionRow>(); | |
| 1771 | if (!s) return fail("not_found", "No such suggestion."); | |
| 1772 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1773 | if (!found.ok) return found; | |
| 1774 | const ctx = found.value; | |
| 1775 | const opened = await this.open(ctx, s.folio_id, "edit"); | |
| 1776 | if (!opened.ok) return opened.error.code === "forbidden" ? fail("forbidden", "Only people who can edit it can accept or reject a suggestion.") : opened; | |
| 1777 | const { row } = opened.value; | |
| 1778 | if (s.status !== "open") return fail("conflict", "That suggestion was already decided."); | |
| 1779 | let status: DocSuggestion["status"] = a.decision === "accept" ? "accepted" : "rejected"; | |
| 1780 | if (a.decision === "accept") { | |
| 1781 | const people = await this.who.profiles(ctx.workspace, [s.author, ctx.key]); | |
| 1782 | const room = await this.ready(ctx.workspace, row); | |
| 1783 | const result = await room.edit( | |
| 1784 | { kind: "doc", target: parseJson<DocEditTarget>(s.target, { kind: "append" }), markdown: s.after_markdown }, | |
| 1785 | { key: ctx.key, kind: "suggestion", note: `Suggested by @${people.get(s.author)!.name}, accepted by @${people.get(ctx.key)!.name}`, authors: [s.author, ctx.key] }, | |
| 1786 | ); | |
| 1787 | if (!result.applied) status = "stale"; | |
| 1788 | else if (s.marks_current) await this.clearStale(row.id, s.author); | |
| 1789 | } | |
| 1790 | const at = now(); | |
| 1791 | await this.db.prepare("UPDATE folio_suggestions SET status = ?, decided_by = ?, decided_at = ? WHERE id = ?").bind(status, ctx.key, at, s.id).run(); | |
| 1792 | const [after] = await this.toSuggestions(ctx.workspace, [{ ...s, status, decided_by: ctx.key, decided_at: at }]); | |
| 1793 | this.tell(row.id, { type: "suggestion.updated", suggestion: after! }); | |
| 1794 | if (status === "stale") return fail("conflict", "The part this suggestion changes is gone, so it can't be applied."); | |
| 1795 | return ok(after!); | |
| 1796 | } | |
| 1797 | ||
| 1798 | async proposals(a: Args & { folio_id: string }): Promise<Result<FolioProposal[]>> { | |
| 1799 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1800 | if (!found.ok) return found; | |
| 1801 | const ctx = found.value; | |
| 1802 | const opened = await this.open(ctx, a.folio_id, "view"); | |
| 1803 | if (!opened.ok) return opened; | |
| 1804 | const rows = ( | |
| 1805 | await this.db | |
| 1806 | .prepare("SELECT id, folio_id, author, asked_by, note, summary, status, created_at, decided_by, decided_at FROM folio_proposals WHERE folio_id = ? ORDER BY created_at DESC LIMIT 100") | |
| 1807 | .bind(opened.value.row.id) | |
| 1808 | .all<{ id: string; folio_id: string; author: string; asked_by: string | null; note: string | null; summary: string; status: FolioProposal["status"]; created_at: string; decided_by: string | null; decided_at: string | null }>() | |
| 1809 | ).results; | |
| 1810 | const people = await this.who.profiles( | |
| 1811 | ctx.workspace, | |
| 1812 | rows.flatMap((r) => [r.author, r.asked_by, r.decided_by].filter((k): k is string => !!k)), | |
| 1813 | ); | |
| 1814 | return ok( | |
| 1815 | rows.map((r) => ({ | |
| 1816 | id: r.id, | |
| 1817 | folio_id: r.folio_id, | |
| 1818 | author: people.get(r.author)!, | |
| 1819 | asked_by: r.asked_by ? (people.get(r.asked_by) ?? null) : null, | |
| 1820 | note: r.note, | |
| 1821 | summary: r.summary, | |
| 1822 | status: r.status, | |
| 1823 | created_at: r.created_at, | |
| 1824 | decided_by: r.decided_by ? (people.get(r.decided_by) ?? null) : null, | |
| 1825 | decided_at: r.decided_at, | |
| 1826 | })), | |
| 1827 | ); | |
| 1828 | } | |
| 1829 | ||
| 1830 | async decideProposal(a: Args & { proposal_id: string; decision: "accept" | "reject" }): Promise<Result<FolioProposal>> { | |
| 1831 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1832 | if (!found.ok) return found; | |
| 1833 | const row = await this.db.prepare("SELECT folio_id FROM folio_proposals WHERE id = ?").bind(String(a.proposal_id ?? "")).first<{ folio_id: string }>(); | |
| 1834 | if (!row) return fail("not_found", "No such proposal."); | |
| 1835 | const opened = await this.open(found.value, row.folio_id, "edit"); | |
| 1836 | if (!opened.ok) return opened; | |
| 1837 | // Proposals arrive with slides, designs and dashboards (Phases 4 to 6). | |
| 1838 | return fail("invalid", "Proposals can't be applied yet."); | |
| 1839 | } | |
| 1840 | ||
| 1841 | async thread(a: Args & { folio_id: string; action: DocThreadAction }): Promise<Result<unknown>> { | |
| 1842 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1843 | if (!found.ok) return found; | |
| 1844 | const ctx = found.value; | |
| 1845 | const opened = await this.open(ctx, a.folio_id, "comment"); | |
| 1846 | if (!opened.ok) return opened; | |
| 1847 | const { row, role } = opened.value; | |
| 1848 | const room = await this.ready(ctx.workspace, row); | |
| 1849 | const result = (await room.thread(ctx.key, role, a.action)) as ThreadResult; | |
| 1850 | if (!result.ok) return fail(result.code, result.message); | |
| 1851 | if (result.mentions?.length) { | |
| 1852 | const names = result.mentions.filter((k) => k.startsWith("user:")).map((k) => k.slice(5).toLowerCase()); | |
| 1853 | this.defer(this.notifyMentioned(ctx.workspace, row, names, ctx.key, result.text ?? "", result.thread_id ?? null)); | |
| 1854 | } | |
| 1855 | return ok(result.value); | |
| 1856 | } | |
| 1857 | ||
| 1858 | async threads(a: Args & { folio_id: string }): Promise<Result<DocThread[]>> { | |
| 1859 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1860 | if (!found.ok) return found; | |
| 1861 | const ctx = found.value; | |
| 1862 | const opened = await this.open(ctx, a.folio_id, "view"); | |
| 1863 | if (!opened.ok) return opened; | |
| 1864 | const threads = await (await this.ready(ctx.workspace, opened.value.row)).threads(); | |
| 1865 | const people = await this.who.profiles( | |
| 1866 | ctx.workspace, | |
| 1867 | threads.flatMap((t) => t.comments.map((c) => c.author)), | |
| 1868 | ); | |
| 1869 | return ok(threads.map((t) => ({ ...t, comments: t.comments.map((c) => ({ ...c, author: people.get(c.author)! })) }))); | |
| 1870 | } | |
| 1871 | ||
| 1872 | /** | |
| 1873 | * People mentioned (by username) in a folio or a comment on it hear of | |
| 1874 | * it, only when they can read it (leak rule 4); never the person who | |
| 1875 | * wrote it. Agents hear of mentions only through their asker. | |
| 1876 | */ | |
| 1877 | async notifyMentioned(workspace: Workspace, row: FolioRow, usernames: string[], author: string | null, text: string, threadId: string | null): Promise<void> { | |
| 1878 | if (!this.env.NOTIFY) return; | |
| 1879 | const authorName = author?.startsWith("user:") ? ((await this.who.profiles(workspace, [author])).get(author)?.name ?? "").toLowerCase() : ""; | |
| 1880 | const names = [...new Set(usernames.map((n) => n.toLowerCase()))].filter((n) => n && n !== authorName).slice(0, 50); | |
| 1881 | if (!names.length) return; | |
| 1882 | const who = author ? (await this.who.profiles(workspace, [author])).get(author) : null; | |
| 1883 | const href = `${this.ref(workspace.slug, row).path}${threadId ? `?thread=${encodeURIComponent(threadId)}` : ""}`; | |
| 1884 | const notify = notifyClient(this.env.NOTIFY); | |
| 1885 | const identity = identityClient(this.env.IDENTITY); | |
| 1886 | for (const username of names) { | |
| 1887 | const user = await identity.userByUsername(username).catch(() => null); | |
| 1888 | if (!user) continue; | |
| 1889 | const role = await this.roleOfPerson(workspace, row, userKey(user), username); | |
| 1890 | if (!role) continue; | |
| 1891 | await notify | |
| 1892 | .notify( | |
| 1893 | { username }, | |
| 1894 | { | |
| 1895 | id: threadId ? `folio-comment:${row.id}:${threadId}:${username}:${Date.now()}` : `folio-mention:${row.id}:${username}`, | |
| 1896 | kind: "mention", | |
| 1897 | workspace: workspace.slug, | |
| 1898 | title: who ? `${who.display_name} mentioned you in ${row.title || "Untitled"}` : `You were mentioned in ${row.title || "Untitled"}`, | |
| 1899 | body: excerpt(text, 140), | |
| 1900 | href, | |
| 1901 | actor: who ? { kind: who.kind, id: who.id, name: who.display_name, avatar: who.avatar, avatar_seed: who.avatar_seed ?? null } : { kind: "system", id: "g1t", name: "g1t", avatar: null, avatar_seed: null }, | |
| 1902 | created_at: now(), | |
| 1903 | }, | |
| 1904 | ) | |
| 1905 | .catch(() => undefined); | |
| 1906 | } | |
| 1907 | } | |
| 1908 | ||
| 1909 | // ── Dashboards (Phase 5b) ─────────────────────────────────────────────── | |
| 1910 | ||
| 1911 | async queryTile(a: Args & { folio_id: string; tile_id: string }): Promise<Result<never>> { | |
| 1912 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1913 | if (!found.ok) return found; | |
| 1914 | const opened = await this.open(found.value, a.folio_id, "view"); | |
| 1915 | if (!opened.ok) return opened; | |
| 1916 | return fail("invalid", "Dashboards aren't here yet."); | |
| 1917 | } | |
| 1918 | ||
| 1919 | async queryDataset(a: Args & { query: unknown }): Promise<Result<never>> { | |
| 1920 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1921 | if (!found.ok) return found; | |
| 1922 | return fail("invalid", "Dashboards aren't here yet."); | |
| 1923 | } | |
| 1924 | ||
| 1925 | async queryDatasetForAgent(a: AgentArgs): Promise<Result<never>> { | |
| 1926 | const found = await this.agentCtx(a); | |
| 1927 | if (!found.ok) return found; | |
| 1928 | return fail("invalid", "Dashboards aren't here yet."); | |
| 1929 | } | |
| 1930 | ||
| 1931 | // ── Staleness ─────────────────────────────────────────────────────────── | |
| 1932 | ||
| 1933 | private async clearStale(folioId: string, by: string): Promise<boolean> { | |
| 1934 | const done = await this.db.prepare("UPDATE folio_changes SET cleared_at = ?, cleared_by = ? WHERE folio_id = ? AND cleared_at IS NULL").bind(now(), by, folioId).run(); | |
| 1935 | const cleared = (done.meta?.changes ?? 0) > 0; | |
| 1936 | if (cleared) this.tell(folioId, { type: "folio.staleness" }); | |
| 1937 | return cleared; | |
| 1938 | } | |
| 1939 | ||
| 1940 | async markCurrent(a: Args & { folio_id: string }): Promise<Result<boolean>> { | |
| 1941 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1942 | if (!found.ok) return found; | |
| 1943 | const opened = await this.open(found.value, a.folio_id, "edit"); | |
| 1944 | if (!opened.ok) return opened; | |
| 1945 | await this.clearStale(opened.value.row.id, found.value.key); | |
| 1946 | return ok(true); | |
| 1947 | } | |
| 1948 | ||
| 1949 | async reindex(a: Args): Promise<Result<boolean>> { | |
| 1950 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1951 | if (!found.ok) return found; | |
| 1952 | if (!found.value.owner) return fail("forbidden", "Only an owner can index the workspace's artifacts again."); | |
| 1953 | return ok(await startBackfill(this.env, found.value.workspace.id, { force: true })); | |
| 1954 | } | |
| 1955 | ||
| 1956 | // ── Agents ────────────────────────────────────────────────────────────── | |
| 1957 | ||
| 1958 | private async agentCtx(a: AgentArgs): Promise<Result<AgentCtx>> { | |
| 1959 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1960 | if (!found.ok) return found; | |
| 1961 | const ctx = found.value; | |
| 1962 | const agentId = String(a.agent_id ?? ""); | |
| 1963 | const agent = (await this.who.agentsById([agentId])).get(agentId); | |
| 1964 | if (!agent || agent.workspace_id !== ctx.workspace.id || agent.archived_at) return fail("not_found", "No such agent."); | |
| 1965 | const rule = audienceRule(a.audience ?? null, ctx.viewer.id); | |
| 1966 | const people = rule.kind === "people" ? await this.who.peopleByIds(ctx.workspace, rule.user_ids) : []; | |
| 1967 | return ok({ ...ctx, agent, agentKey: principalKey({ kind: "agent", id: agent.id }), rule, people, audienceIds: rule.kind === "people" ? rule.user_ids : [] }); | |
| 1968 | } | |
| 1969 | ||
| 1970 | /** What the agent may reach in each folio for its asker and audience. */ | |
| 1971 | private async reach(actx: AgentCtx, rows: FolioRow[]): Promise<Map<string, AgentReach>> { | |
| 1972 | const out = new Map<string, AgentReach>(); | |
| 1973 | if (!rows.length) return out; | |
| 1974 | const [found, asker] = await Promise.all([ancestry(this.db, rows), visitsOf(this.db, actx.viewer.id, rows)]); | |
| 1975 | let audienceVisits = new Map<string, Set<string>>(); | |
| 1976 | if (actx.rule.kind === "people") { | |
| 1977 | const ids = [...new Set(rows.flatMap((r) => [r.id, r.acl_root]))]; | |
| 1978 | const found2 = await this.db | |
| 1979 | .prepare("SELECT folio_id, user_id FROM folio_visits WHERE user_id IN (SELECT value FROM json_each(?)) AND folio_id IN (SELECT value FROM json_each(?))") | |
| 1980 | .bind(json(actx.audienceIds), json(ids)) | |
| 1981 | .all<{ folio_id: string; user_id: string }>(); | |
| 1982 | audienceVisits = new Map(); | |
| 1983 | for (const v of found2.results) audienceVisits.set(v.user_id, (audienceVisits.get(v.user_id) ?? new Set()).add(v.folio_id)); | |
| 1984 | } | |
| 1985 | const allSpaces = new Map((await this.who.allSpaces(actx.workspace)).map((s) => [s.row.id, s])); | |
| 1986 | for (const row of rows) { | |
| 1987 | const chain = aclChain(row.id, found.nodes); | |
| 1988 | const root = chain[chain.length - 1]; | |
| 1989 | const s = root?.space_id ? allSpaces.get(root.space_id) : undefined; | |
| 1990 | const space: SpaceRules | null = s ? rulesOf(s) : null; | |
| 1991 | const seen = (set: Set<string> | undefined) => !!set && (set.has(row.id) || (!!root && set.has(root.id))); | |
| 1992 | out.set( | |
| 1993 | row.id, | |
| 1994 | agentReach({ | |
| 1995 | chain, | |
| 1996 | grants: found.grants, | |
| 1997 | space, | |
| 1998 | asker: actx.person, | |
| 1999 | askerVisited: seen(asker), | |
| 2000 | rule: actx.rule, | |
| 2001 | people: actx.people, | |
| 2002 | visited: (p) => seen(audienceVisits.get(p.user_id)), | |
| 2003 | agent_mode: this.agentMode(row, actx), | |
| 2004 | }), | |
| 2005 | ); | |
| 2006 | } | |
| 2007 | return out; | |
| 2008 | } | |
| 2009 | ||
| 2010 | /** A folio the agent may reach for its asker: not found when the asker can't read it. */ | |
| 2011 | private async agentOpen(actx: AgentCtx, folioId: unknown): Promise<Result<{ row: FolioRow; reach: AgentReach }>> { | |
| 2012 | const row = await this.db.prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE id = ? AND workspace_id = ? AND trashed_at IS NULL`).bind(String(folioId ?? ""), actx.workspace.id).first<FolioRow>(); | |
| 2013 | if (!row) return fail("not_found", "No such artifact."); | |
| 2014 | const reach = (await this.reach(actx, [row])).get(row.id)!; | |
| 2015 | if (!reach.asker_role) return fail("not_found", "No such artifact."); | |
| 2016 | return ok({ row, reach }); | |
| 2017 | } | |
| 2018 | ||
| 2019 | async foliosForAgent(a: AgentArgs & { query: FolioListQuery }): Promise<Result<FolioList>> { | |
| 2020 | const found = await this.agentCtx(a); | |
| 2021 | if (!found.ok) return found; | |
| 2022 | const actx = found.value; | |
| 2023 | const query = { ...(a.query ?? { tab: "all" as const }), tab: a.query?.tab ?? "all", limit: Math.min(listLimit(a.query?.limit), 50) }; | |
| 2024 | const invalid = folioListQueryError(query); | |
| 2025 | if (invalid) return fail("invalid", invalid); | |
| 2026 | const page = await this.listFor(actx, query); | |
| 2027 | const rows = await foliosById( | |
| 2028 | this.db, | |
| 2029 | page.items.map((f) => f.id), | |
| 2030 | ); | |
| 2031 | const reach = await this.reach(actx, [...rows.values()]); | |
| 2032 | return ok({ items: page.items.filter((f) => agentMayFind(reach.get(f.id) ?? { asker_role: null, audience_can_read: false, can: { read: false, suggest: false, edit: false } })), next_cursor: page.next_cursor }); | |
| 2033 | } | |
| 2034 | ||
| 2035 | async readForAgent(a: AgentArgs & { folio_id: string }): Promise<Result<FolioAgentRead>> { | |
| 2036 | const found = await this.agentCtx(a); | |
| 2037 | if (!found.ok) return found; | |
| 2038 | const actx = found.value; | |
| 2039 | const opened = await this.agentOpen(actx, a.folio_id); | |
| 2040 | if (!opened.ok) return opened; | |
| 2041 | const { row, reach } = opened.value; | |
| 2042 | if (!kindModel(row.kind)) return fail("invalid", `${kindLabel(row.kind)} aren't here yet.`); | |
| 2043 | const read = await (await this.ready(actx.workspace, row)).read(); | |
| 2044 | return ok({ | |
| 2045 | folio: { ...this.ref(actx.workspace.slug, row), edited_at: row.edited_at }, | |
| 2046 | space: this.spaceOf(actx, row), | |
| 2047 | content: read.content, | |
| 2048 | ...(read.blocks ? { blocks: read.blocks } : {}), | |
| 2049 | can: reach.can, | |
| 2050 | audience_can_read: reach.audience_can_read, | |
| 2051 | }); | |
| 2052 | } | |
| 2053 | ||
| 2054 | async createAsAgent( | |
| 2055 | a: AgentArgs & { | |
| 2056 | input: { kind: FolioKind; title: string; content?: FolioContentInput | null; template_id?: string | null; where: { space_id: string } | "private" | { conversation: string[] }; parent_id?: string | null; source?: { title: string; href: string } | null }; | |
| 2057 | }, | |
| 2058 | ): Promise<Result<FolioRef>> { | |
| 2059 | const found = await this.agentCtx({ ...a, audience: null }); | |
| 2060 | if (!found.ok) return found; | |
| 2061 | const actx = found.value; | |
| 2062 | const input = a.input ?? ({} as typeof a.input); | |
| 2063 | const invalid = newFolioError({ kind: input.kind, title: input.title, content: input.content ?? null, template_id: input.template_id ?? null }); | |
| 2064 | if (invalid) return fail("invalid", invalid); | |
| 2065 | if (!kindModel(input.kind)) return fail("invalid", `${kindLabel(input.kind)} aren't here yet.`); | |
| 2066 | const title = cleanTitle(input.title); | |
| 2067 | if (!title && !input.template_id) return fail("invalid", "Give it a title."); | |
| 2068 | const where = input.where ?? "private"; | |
| 2069 | let place: Result<{ space_id: string | null; parent: FolioRow | null }>; | |
| 2070 | let grants: { principal: string; role: DocRole }[] = [{ principal: actx.agentKey, role: "edit" }]; | |
| 2071 | if (input.parent_id) place = await this.placeFor(actx, { parent_id: input.parent_id }); | |
| 2072 | else if (typeof where === "object" && "space_id" in where) place = await this.placeFor(actx, { space_id: where.space_id }); | |
| 2073 | else place = ok({ space_id: null, parent: null }); | |
| 2074 | if (!place.ok) return place.error.code === "forbidden" ? fail("forbidden", `${actx.viewer.username} can't add there.`) : place; | |
| 2075 | if (typeof where === "object" && "conversation" in where) { | |
| 2076 | // Private, and the conversation's people may read it. | |
| 2077 | const ids = [...new Set((Array.isArray(where.conversation) ? where.conversation : []).map(String))].filter((id) => id && id !== actx.viewer.id).slice(0, FOLIO_MAX_SHARE); | |
| 2078 | const people = await this.who.peopleByIds(actx.workspace, ids); | |
| 2079 | grants = [...grants, ...people.filter((p) => !p.user_id.startsWith("outside:")).map((p) => ({ principal: `user:${p.user_id}`, role: "view" as DocRole }))]; | |
| 2080 | } | |
| 2081 | const start = await this.startingPoint(actx, input.kind, { title, template_id: input.template_id, content: input.content }); | |
| 2082 | if (!start.ok) return start; | |
| 2083 | const row = await this.insertFolio(actx, { | |
| 2084 | kind: input.kind, | |
| 2085 | owner: actx.key, | |
| 2086 | created_by: actx.agentKey, | |
| 2087 | space_id: place.value.space_id, | |
| 2088 | parent: place.value.parent, | |
| 2089 | title: start.value.title, | |
| 2090 | icon: start.value.icon, | |
| 2091 | text: start.value.text, | |
| 2092 | spec: start.value.spec, | |
| 2093 | source: cleanSource(input.source), | |
| 2094 | grants, | |
| 2095 | }); | |
| 2096 | return ok(this.ref(actx.workspace.slug, row)); | |
| 2097 | } | |
| 2098 | ||
| 2099 | async editAsAgent(a: AgentArgs & { folio_id: string; edit: FolioAgentEdit }): Promise<Result<FolioAgentEditResult>> { | |
| 2100 | const found = await this.agentCtx({ ...a, audience: null }); | |
| 2101 | if (!found.ok) return found; | |
| 2102 | const actx = found.value; | |
| 2103 | const opened = await this.agentOpen(actx, a.folio_id); | |
| 2104 | if (!opened.ok) return opened; | |
| 2105 | const { row, reach } = opened.value; | |
| 2106 | const invalid = this.editError(row, a.edit); | |
| 2107 | if (invalid) return fail("invalid", invalid); | |
| 2108 | const edit = a.edit; | |
| 2109 | const ref = this.ref(actx.workspace.slug, row); | |
| 2110 | if (reach.can.edit && !edit.suggest_only) { | |
| 2111 | const room = await this.ready(actx.workspace, row); | |
| 2112 | const note = cleanNote(edit.note); | |
| 2113 | const result = await room.edit(edit, { | |
| 2114 | key: actx.agentKey, | |
| 2115 | kind: "agent", | |
| 2116 | note: note ? `@${actx.agent.handle} for @${actx.viewer.username}: ${note}` : `@${actx.agent.handle} for @${actx.viewer.username}`, | |
| 2117 | authors: [actx.agentKey], | |
| 2118 | }); | |
| 2119 | if (!result.applied) return fail("not_found", `${result.summary} Read it again and target what is there now.`); | |
| 2120 | if (edit.marks_current) await this.clearStale(row.id, actx.agentKey); | |
| 2121 | this.defer(room.announce(actx.agentKey, actx.agent.display_name).catch(() => undefined)); | |
| 2122 | return ok({ mode: "applied", version_id: result.version_id, folio: ref, summary: result.summary }); | |
| 2123 | } | |
| 2124 | if (!reach.can.suggest) return fail("forbidden", `${actx.viewer.username} can only read this, so it can't be changed for them.`); | |
| 2125 | if (edit.kind !== "doc") return fail("forbidden", `Changing ${kindLabel(row.kind)} without edit access comes with proposals, which aren't here yet.`); | |
| 2126 | const suggestion = await this.fileSuggestion(actx, row, { author: actx.agentKey, asked_by: actx.key, agentName: actx.agent.display_name }, { target: cleanTarget(edit.target)!, markdown: edit.markdown, note: cleanNote(edit.note), marks_current: edit.marks_current === true }); | |
| 2127 | return suggestion.ok ? ok({ mode: "suggested", suggestion: suggestion.value, folio: ref }) : suggestion; | |
| 2128 | } | |
| 2129 | ||
| 2130 | async shareAsAgent(a: AgentArgs & { folio_id: string; user_ids: string[]; role: "view" | "comment" }): Promise<Result<FolioAccessList>> { | |
| 2131 | if (a.role !== "view" && a.role !== "comment") return fail("invalid", "An agent shares to view or comment only. For more, post a card with a Share button for the person to press."); | |
| 2132 | const found = await this.agentCtx(a); | |
| 2133 | if (!found.ok) return found; | |
| 2134 | const actx = found.value; | |
| 2135 | if (actx.rule.kind !== "people") return fail("forbidden", "An agent shares only with people in a private conversation. Ask the person to use Share instead."); | |
| 2136 | const opened = await this.agentOpen(actx, a.folio_id); | |
| 2137 | if (!opened.ok) return opened; | |
| 2138 | const { row, reach } = opened.value; | |
| 2139 | if (!canShare(reach.asker_role)) return fail("forbidden", `${actx.viewer.username} doesn't have full access, so it can't be shared for them.`); | |
| 2140 | const inConversation = new Set(actx.rule.user_ids); | |
| 2141 | const ids = [...new Set((Array.isArray(a.user_ids) ? a.user_ids : []).map(String))]; | |
| 2142 | if (!ids.length) return fail("invalid", "Name who to share it with."); | |
| 2143 | if (ids.some((id) => !inConversation.has(id))) return fail("forbidden", "An agent shares only with people already in the conversation."); | |
| 2144 | const people = (await this.who.peopleByIds(actx.workspace, ids)).filter((p) => !p.user_id.startsWith("outside:")); | |
| 2145 | const at = now(); | |
| 2146 | // Never lowers what someone already has. | |
| 2147 | await runBatches( | |
| 2148 | this.db, | |
| 2149 | people.map((p) => | |
| 2150 | this.db | |
| 2151 | .prepare( | |
| 2152 | "INSERT INTO folio_grants (folio_id, principal, role, granted_by, granted_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (folio_id, principal) DO UPDATE SET role = CASE WHEN folio_grants.role IN ('edit', 'manage') OR (folio_grants.role = 'comment' AND excluded.role = 'view') THEN folio_grants.role ELSE excluded.role END", | |
| 2153 | ) | |
| 2154 | .bind(row.id, `user:${p.user_id}`, a.role, actx.agentKey, at), | |
| 2155 | ), | |
| 2156 | ); | |
| 2157 | const list = await this.afterShare(actx, row); | |
| 2158 | const open = await workspaceReadable(this.db, row.id).catch(() => false); | |
| 2159 | this.defer( | |
| 2160 | publishFolioEvent( | |
| 2161 | this.env.EVENTS, | |
| 2162 | "folio.shared", | |
| 2163 | { workspace: actx.workspace.slug, workspaceId: actx.workspace.id, folioId: row.id, kind: row.kind, spaceId: row.space_id, title: open ? row.title : null, principals: people.map((p) => `user:${p.user_id}`), role: a.role }, | |
| 2164 | actx.agentKey, | |
| 2165 | ), | |
| 2166 | ); | |
| 2167 | return ok(list); | |
| 2168 | } | |
| 2169 | ||
| 2170 | /** | |
| 2171 | * A file an agent made (`make_file` in services/agents), kept with a | |
| 2172 | * folio as a person's upload is: only where the agent may edit for the | |
| 2173 | * person it acts for, up to the same size, served the same way. | |
| 2174 | */ | |
| 2175 | async attachAsAgent( | |
| 2176 | a: AgentArgs & { folio_id: string; file: { name: string; content_type: string; data: string } }, | |
| 2177 | ): Promise<Result<{ id: string; url: string; name: string; content_type: string; bytes: number }>> { | |
| 2178 | const found = await this.agentCtx({ ...a, audience: null }); | |
| 2179 | if (!found.ok) return found; | |
| 2180 | const actx = found.value; | |
| 2181 | const opened = await this.agentOpen(actx, a.folio_id); | |
| 2182 | if (!opened.ok) return opened; | |
| 2183 | const { row, reach } = opened.value; | |
| 2184 | if (!reach.can.edit) return fail("forbidden", `${actx.viewer.username} can't edit this artifact, so nothing can be attached to it for them.`); | |
| 2185 | let bytes: Uint8Array; | |
| 2186 | try { | |
| 2187 | bytes = Uint8Array.from(atob(String(a.file?.data ?? "")), (c) => c.charCodeAt(0)); | |
| 2188 | } catch { | |
| 2189 | return fail("invalid", "The file isn't base64."); | |
| 2190 | } | |
| 2191 | if (!bytes.length) return fail("invalid", "The file is empty."); | |
| 2192 | if (bytes.length > DOC_MAX_FILE_BYTES) return fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`); | |
| 2193 | const name = safeName(a.file?.name ?? "file"); | |
| 2194 | const contentType = servedType(a.file?.content_type ?? ""); | |
| 2195 | const key = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| 2196 | const id = newId("fil"); | |
| 2197 | await fileStore(this.env).put(`docs/${key}`, bytes, contentType); | |
| 2198 | await this.db | |
| 2199 | .prepare("INSERT INTO folio_files (id, workspace_id, folio_id, key, name, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 2200 | .bind(id, actx.workspace.id, row.id, key, name, contentType, bytes.length, actx.agentKey, now()) | |
| 2201 | .run(); | |
| 2202 | return ok({ id, url: `/docs-files/${key}`, name, content_type: contentType, bytes: bytes.length }); | |
| 2203 | } | |
| 2204 | ||
| 2205 | /** | |
| 2206 | * What the workspace's artifacts (and projects' docs) say about a | |
| 2207 | * query, for an agent about to answer: passages by meaning above the | |
| 2208 | * floor, then by words, at most two per folio, only from folios its | |
| 2209 | * asker and every person in the audience can read, each checked against | |
| 2210 | * the folio itself. Projects' docs come from Docs' index (`g1t-docs`) | |
| 2211 | * until Phase 7 moves them. | |
| 2212 | */ | |
| 2213 | async recallForAgent(a: AgentArgs & { query: string; limit?: number | null; spaces?: string[] | null; kinds?: FolioKind[] | null }): Promise<Result<FolioPassage[]>> { | |
| 2214 | const found = await this.agentCtx(a); | |
| 2215 | if (!found.ok) return found; | |
| 2216 | const actx = found.value; | |
| 2217 | this.defer(ensureIndexed(this.env, actx.workspace.id).catch((error: unknown) => console.error("folios could not start indexing", actx.workspace.id, String(error)))); | |
| 2218 | const query = String(a.query ?? "").trim().slice(0, 2000); | |
| 2219 | if (!query) return ok([]); | |
| 2220 | const limit = recallLimit(a.limit); | |
| 2221 | const kinds = (a.kinds ?? []).filter(isFolioKind); | |
| 2222 | const { scopes } = await this.allowedScopes(actx); | |
| 2223 | const required = new Set((Array.isArray(a.spaces) ? a.spaces : []).map((id) => `space:${id}`).filter((s) => scopes.includes(s))); | |
| 2224 | const fts = ftsAnyQuery(query); | |
| 2225 | const [meaning, words, repo] = await Promise.all([ | |
| 2226 | this.meaningPassages(actx, query, scopes, kinds).catch(() => []), | |
| 2227 | fts | |
| 2228 | ? this.db | |
| 2229 | .prepare( | |
| 2230 | `SELECT c.id, c.folio_id, c.scope, c.heading, c.text FROM folio_chunks_fts JOIN folio_chunks c ON c.id = folio_chunks_fts.chunk_id | |
| 2231 | WHERE folio_chunks_fts MATCH ? AND c.workspace_id = ? ${scopes.length <= 80 ? "AND folio_chunks_fts.scope IN (SELECT value FROM json_each(?))" : ""} ${kinds.length ? "AND c.kind IN (SELECT value FROM json_each(?))" : ""} | |
| 2232 | ORDER BY bm25(folio_chunks_fts, 0, 0, 0, 4.0, 1.0) LIMIT 30`, | |
| 2233 | ) | |
| 2234 | .bind(fts, actx.workspace.id, ...(scopes.length <= 80 ? [json(scopes)] : []), ...(kinds.length ? [json(kinds)] : [])) | |
| 2235 | .all<{ id: string; folio_id: string; scope: string; heading: string | null; text: string }>() | |
| 2236 | .then((r) => r.results) | |
| 2237 | .catch((error: unknown) => { | |
| 2238 | console.error("folios word recall failed", String(error)); | |
| 2239 | return [] as { id: string; folio_id: string; scope: string; heading: string | null; text: string }[]; | |
| 2240 | }) | |
| 2241 | : Promise.resolve([] as { id: string; folio_id: string; scope: string; heading: string | null; text: string }[]), | |
| 2242 | kinds.length && !kinds.includes("doc") ? Promise.resolve([] as FolioPassage[]) : this.recallRepoDocs(actx, query, fts, limit).catch(() => [] as FolioPassage[]), | |
| 2243 | ]); | |
| 2244 | // Every folio a passage came from, checked as the agent's asker and audience. | |
| 2245 | const folioIds = [...new Set([...meaning.map((m) => m.folio_id), ...words.map((w) => w.folio_id)])]; | |
| 2246 | const rows = [...(await foliosById(this.db, folioIds)).values()].filter((r) => r.workspace_id === actx.workspace.id && !r.trashed_at); | |
| 2247 | const reach = await this.reach(actx, rows); | |
| 2248 | const may = new Set(rows.filter((r) => agentMayFind(reach.get(r.id)!)).map((r) => r.id)); | |
| 2249 | const byFolio = new Map(rows.map((r) => [r.id, r])); | |
| 2250 | type C = Candidate & { heading: string | null; text: string }; | |
| 2251 | const scopeOf = (folioId: string) => (required.size && byFolio.get(folioId)?.space_id && required.has(`space:${byFolio.get(folioId)!.space_id}`) ? "required" : "rest"); | |
| 2252 | const candidates: C[] = [ | |
| 2253 | ...meaning.filter((m) => may.has(m.folio_id)).map((m) => ({ id: m.id, doc_id: m.folio_id, space_id: scopeOf(m.folio_id), score: m.score, by: "meaning" as const, heading: m.heading, text: m.text })), | |
| 2254 | ...words.filter((w) => may.has(w.folio_id)).map((w) => ({ id: w.id, doc_id: w.folio_id, space_id: scopeOf(w.folio_id), score: WORDS_SCORE, by: "words" as const, heading: w.heading, text: w.text })), | |
| 2255 | ]; | |
| 2256 | const picked = pickPassages(candidates, { allowed: new Set(["required", "rest"]), required: required.size ? ["required"] : [], limit }); | |
| 2257 | const stale = await this.staleIds(picked.map((c) => c.doc_id)); | |
| 2258 | const passages: FolioPassage[] = picked.map((c) => { | |
| 2259 | const row = byFolio.get(c.doc_id)!; | |
| 2260 | const space = row.space_id ? actx.spaceById.get(row.space_id) : undefined; | |
| 2261 | return { | |
| 2262 | folio: this.ref(actx.workspace.slug, row), | |
| 2263 | repo_file: null, | |
| 2264 | space_name: space?.row.name ?? "Private", | |
| 2265 | heading: c.heading, | |
| 2266 | text: c.text, | |
| 2267 | score: Math.round(c.score * 1000) / 1000, | |
| 2268 | updated_at: row.edited_at, | |
| 2269 | stale: stale.has(row.id), | |
| 2270 | }; | |
| 2271 | }); | |
| 2272 | // Projects' docs fill what's left, best first. | |
| 2273 | const out = [...passages, ...repo.sort((x, y) => y.score - x.score)].slice(0, limit); | |
| 2274 | return ok(out.sort((x, y) => y.score - x.score)); | |
| 2275 | } | |
| 2276 | ||
| 2277 | /** Projects' docs the agent may recall from: repositories its asker and every person in the audience can read. */ | |
| 2278 | private async recallRepoDocs(actx: AgentCtx, query: string, fts: string | null, limit: number): Promise<FolioPassage[]> { | |
| 2279 | const spaces = await this.repoSpacesForAudience(actx); | |
| 2280 | if (!spaces.length) return []; | |
| 2281 | const ids = spaces.map((s) => s.row.id); | |
| 2282 | const { embedder, store } = adapters(this.env); | |
| 2283 | const vector = store ? await this.queryVector(query, embedder) : null; | |
| 2284 | const plan = vectorQueryPlan(actx.workspace.id, ids); | |
| 2285 | const [meaning, words] = await Promise.all([ | |
| 2286 | vector && store && plan ? store.query(vector, { topK: plan.topK, filter: plan.filter }).catch(() => [] as { id: string; score: number }[]) : Promise.resolve([] as { id: string; score: number }[]), | |
| 2287 | fts | |
| 2288 | ? this.db | |
| 2289 | .prepare( | |
| 2290 | `SELECT doc_chunks_fts.chunk_id AS id FROM doc_chunks_fts JOIN doc_chunks c ON c.id = doc_chunks_fts.chunk_id | |
| 2291 | WHERE doc_chunks_fts MATCH ? AND c.workspace_id = ? AND c.repo_file_id IS NOT NULL AND doc_chunks_fts.space_id IN (SELECT value FROM json_each(?)) | |
| 2292 | ORDER BY bm25(doc_chunks_fts, 0, 0, 0, 4.0, 1.0) LIMIT 20`, | |
| 2293 | ) | |
| 2294 | .bind(fts, actx.workspace.id, json(ids)) | |
| 2295 | .all<{ id: string }>() | |
| 2296 | .then((r) => r.results.map((x) => x.id)) | |
| 2297 | .catch(() => [] as string[]) | |
| 2298 | : Promise.resolve([] as string[]), | |
| 2299 | ]); | |
| 2300 | const scores = new Map<string, number>(); | |
| 2301 | for (const m of meaning) if (m.score >= MEANING_FLOOR) scores.set(m.id, Math.max(scores.get(m.id) ?? 0, m.score)); | |
| 2302 | for (const id of words) if (!scores.has(id)) scores.set(id, WORDS_SCORE); | |
| 2303 | if (!scores.size) return []; | |
| 2304 | const rows = ( | |
| 2305 | await this.db | |
| 2306 | .prepare( | |
| 2307 | `SELECT c.id, c.space_id, c.repo_file_id, c.path, c.heading, c.text FROM doc_chunks c JOIN repo_files f ON f.space_id = c.space_id AND f.path = c.path | |
| 2308 | WHERE c.workspace_id = ? AND c.repo_file_id IS NOT NULL AND c.id IN (SELECT value FROM json_each(?))`, | |
| 2309 | ) | |
| 2310 | .bind(actx.workspace.id, json([...scores.keys()])) | |
| 2311 | .all<{ id: string; space_id: string; repo_file_id: string; path: string; heading: string | null; text: string }>() | |
| 2312 | ).results; | |
| 2313 | const bySpace = new Map(spaces.map((s) => [s.row.id, s])); | |
| 2314 | const perFile = new Map<string, number>(); | |
| 2315 | const out: FolioPassage[] = []; | |
| 2316 | for (const r of rows.sort((x, y) => (scores.get(y.id) ?? 0) - (scores.get(x.id) ?? 0))) { | |
| 2317 | const s = bySpace.get(r.space_id); | |
| 2318 | if (!s) continue; | |
| 2319 | const n = perFile.get(r.repo_file_id) ?? 0; | |
| 2320 | if (n >= 2) continue; | |
| 2321 | perFile.set(r.repo_file_id, n + 1); | |
| 2322 | const name = `${s.repo.namespace}/${s.repo.name}`; | |
| 2323 | out.push({ | |
| 2324 | folio: null, | |
| 2325 | repo_file: { repo: name, path: r.path, href: `/${actx.workspace.slug}/-/artifacts/repo/${name}/${r.path.split("/").map(encodeURIComponent).join("/")}` }, | |
| 2326 | space_name: name, | |
| 2327 | heading: r.heading, | |
| 2328 | text: r.text, | |
| 2329 | score: Math.round((scores.get(r.id) ?? 0) * 1000) / 1000, | |
| 2330 | updated_at: s.row.indexed_at ?? s.row.added_at, | |
| 2331 | stale: false, | |
| 2332 | }); | |
| 2333 | if (out.length >= limit) break; | |
| 2334 | } | |
| 2335 | return out; | |
| 2336 | } | |
| 2337 | ||
| 2338 | async staleForAgent(a: AgentArgs & { repo?: string | null; since?: string | null }): Promise<Result<Folio[]>> { | |
| 2339 | const found = await this.agentCtx(a); | |
| 2340 | if (!found.ok) return found; | |
| 2341 | const actx = found.value; | |
| 2342 | const repo = a.repo ? projectRef(a.repo) : null; | |
| 2343 | if (a.repo && !repo) return fail("invalid", "Name the repository as owner/name."); | |
| 2344 | const since = a.since && !Number.isNaN(Date.parse(a.since)) ? new Date(a.since).toISOString() : null; | |
| 2345 | const rows = ( | |
| 2346 | await this.db | |
| 2347 | .prepare( | |
| 2348 | `SELECT ${folioColumns("f")} FROM folios f JOIN (SELECT folio_id, MAX(detected_at) AS flagged FROM folio_changes WHERE cleared_at IS NULL ${repo ? "AND repo = ?" : ""} GROUP BY folio_id) c ON c.folio_id = f.id | |
| 2349 | WHERE f.workspace_id = ? AND f.trashed_at IS NULL ${since ? "AND c.flagged >= ?" : ""} ORDER BY c.flagged DESC LIMIT 200`, | |
| 2350 | ) | |
| 2351 | .bind(...(repo ? [repo] : []), actx.workspace.id, ...(since ? [since] : [])) | |
| 2352 | .all<FolioRow>() | |
| 2353 | ).results; | |
| 2354 | const reach = await this.reach(actx, rows); | |
| 2355 | return ok((await this.toFolios(actx, rows.filter((r) => agentMayFind(reach.get(r.id)!)))).slice(0, 50)); | |
| 2356 | } | |
| 2357 | ||
| 2358 | // ── Projects' docs ────────────────────────────────────────────────────── | |
| 2359 | ||
| 2360 | private async readableRepoSpaces(workspace: Workspace, viewer: User): Promise<{ row: RepoSpaceRow; repo: Repo }[]> { | |
| 2361 | const rows = (await this.db.prepare("SELECT * FROM repo_spaces WHERE workspace_id = ? ORDER BY repo").bind(workspace.id).all<RepoSpaceRow>()).results; | |
| 2362 | if (!rows.length || !this.env.REPOS) return []; | |
| 2363 | const readable = await reposClient(this.env.REPOS).readable( | |
| 2364 | rows.map((r) => r.repo_id), | |
| 2365 | viewer, | |
| 2366 | ); | |
| 2367 | const byId = new Map(readable.map((r) => [r.id, r])); | |
| 2368 | return rows.filter((r) => byId.has(r.repo_id)).map((row) => ({ row, repo: byId.get(row.repo_id)! })); | |
| 2369 | } | |
| 2370 | ||
| 2371 | private async repoSpacesFor(ctx: Ctx): Promise<DocRepoSpace[]> { | |
| 2372 | const found = await this.readableRepoSpaces(ctx.workspace, ctx.viewer); | |
| 2373 | if (!found.length) return []; | |
| 2374 | const [files, people] = await Promise.all([ | |
| 2375 | this.db | |
| 2376 | .prepare("SELECT space_id, path, title FROM repo_files WHERE space_id IN (SELECT value FROM json_each(?))") | |
| 2377 | .bind(json(found.map((f) => f.row.id))) | |
| 2378 | .all<{ space_id: string; path: string; title: string }>(), | |
| 2379 | this.who.profiles( | |
| 2380 | ctx.workspace, | |
| 2381 | found.map((f) => f.row.added_by), | |
| 2382 | ), | |
| 2383 | ]); | |
| 2384 | const readme = (path: string) => (/^readme\./i.test(path) ? 0 : 1); | |
| 2385 | return found.map(({ row, repo }) => ({ | |
| 2386 | id: row.id, | |
| 2387 | repo: `${repo.namespace}/${repo.name}`, | |
| 2388 | default_branch: repo.defaultBranch, | |
| 2389 | commit: row.commit_sha, | |
| 2390 | indexed_at: row.indexed_at, | |
| 2391 | added_by: people.get(row.added_by)!, | |
| 2392 | files: files.results | |
| 2393 | .filter((f) => f.space_id === row.id) | |
| 2394 | .sort((a, b) => readme(a.path) - readme(b.path) || a.path.localeCompare(b.path)) | |
| 2395 | .map((f) => ({ path: f.path, title: f.title })), | |
| 2396 | can_remove: row.added_by === ctx.key || ctx.owner, | |
| 2397 | })); | |
| 2398 | } | |
| 2399 | ||
| 2400 | /** Projects' docs an agent may recall from: the asker's, narrowed to every person in the audience (public repositories only for a workspace audience). */ | |
| 2401 | private async repoSpacesForAudience(actx: AgentCtx): Promise<{ row: RepoSpaceRow; repo: Repo }[]> { | |
| 2402 | const mine = await this.readableRepoSpaces(actx.workspace, actx.viewer); | |
| 2403 | if (!mine.length || actx.rule.kind === "asker") return mine; | |
| 2404 | const publicOnly = () => mine.filter((s) => !s.repo.isPrivate); | |
| 2405 | if (actx.rule.kind === "workspace" || !this.env.REPOS) return publicOnly(); | |
| 2406 | const others = await identityClient(this.env.IDENTITY) | |
| 2407 | .usersForAudience(actx.rule.user_ids) | |
| 2408 | .catch(() => [] as User[]); | |
| 2409 | let keep = new Set(mine.map((s) => s.row.repo_id)); | |
| 2410 | // Someone who isn't a live account reads public repositories only. | |
| 2411 | if (others.length < actx.rule.user_ids.length) keep = new Set(publicOnly().map((s) => s.row.repo_id)); | |
| 2412 | for (const person of others) { | |
| 2413 | const readable = await reposClient(this.env.REPOS) | |
| 2414 | .readable([...keep], person) | |
| 2415 | .catch(() => [] as Repo[]); | |
| 2416 | keep = new Set(readable.map((r) => r.id)); | |
| 2417 | if (!keep.size) break; | |
| 2418 | } | |
| 2419 | return mine.filter((s) => keep.has(s.row.repo_id)); | |
| 2420 | } | |
| 2421 | ||
| 2422 | // ── Sockets and files ─────────────────────────────────────────────────── | |
| 2423 | ||
| 2424 | private viewerFrom(request: Request): Viewer { | |
| 2425 | try { | |
| 2426 | return JSON.parse(request.headers.get(DOCS_VIEWER_HEADER) ?? "null") as Viewer; | |
| 2427 | } catch { | |
| 2428 | return null; | |
| 2429 | } | |
| 2430 | } | |
| 2431 | ||
| 2432 | /** | |
| 2433 | * `GET /live?workspace=<slug>&folio=<id>`, upgraded to a WebSocket. The | |
| 2434 | * viewer comes in DOCS_VIEWER_HEADER, set by the site after checking | |
| 2435 | * the session; trusted only because this Worker is reachable through | |
| 2436 | * service bindings alone. Checked like any read (opening counts for a | |
| 2437 | * link folio), then handed to the room with the viewer's role. | |
| 2438 | */ | |
| 2439 | async live(request: Request): Promise<Response> { | |
| 2440 | if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("Expected a WebSocket upgrade\n", { status: 426 }); | |
| 2441 | const viewer = this.viewerFrom(request); | |
| 2442 | if (!viewer?.id) return new Response("Sign in to use Artifacts\n", { status: 401 }); | |
| 2443 | const url = new URL(request.url); | |
| 2444 | const found = await this.ctx((url.searchParams.get("workspace") ?? "").toLowerCase(), viewer); | |
| 2445 | if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 }); | |
| 2446 | const ctx = found.value; | |
| 2447 | const opened = await this.open(ctx, url.searchParams.get("folio") ?? "", "view", { trashed: true, opening: true }); | |
| 2448 | if (!opened.ok) return new Response(`${opened.error.message}\n`, { status: opened.error.code === "forbidden" ? 403 : 404 }); | |
| 2449 | const { row, role } = opened.value; | |
| 2450 | if (row.trashed_at) return new Response("That artifact is in the trash\n", { status: 410 }); | |
| 2451 | if (!kindModel(row.kind)) return new Response("That kind of artifact isn't here yet\n", { status: 409 }); | |
| 2452 | const room = await this.ready(ctx.workspace, row); | |
| 2453 | const member = (await this.who.profiles(ctx.workspace, [ctx.key])).get(ctx.key)!; | |
| 2454 | const headers = new Headers(request.headers); | |
| 2455 | headers.delete(DOCS_VIEWER_HEADER); | |
| 2456 | headers.set(ROOM_MEMBER_HEADER, JSON.stringify({ folio_id: row.id, workspace_slug: ctx.workspace.slug, key: ctx.key, member, role })); | |
| 2457 | return room.fetch(new Request(request.url, { method: "GET", headers })); | |
| 2458 | } | |
| 2459 | ||
| 2460 | /** `PUT /files?workspace=&folio=&name=`: a file for a folio, from someone who can edit it. */ | |
| 2461 | async upload(request: Request): Promise<Response> { | |
| 2462 | const viewer = this.viewerFrom(request); | |
| 2463 | const url = new URL(request.url); | |
| 2464 | const found = await this.ctx((url.searchParams.get("workspace") ?? "").toLowerCase(), viewer); | |
| 2465 | if (!found.ok) return Response.json(found); | |
| 2466 | const ctx = found.value; | |
| 2467 | const opened = await this.open(ctx, url.searchParams.get("folio") ?? "", "edit"); | |
| 2468 | if (!opened.ok) return Response.json(opened); | |
| 2469 | const bytes = Number(request.headers.get("content-length") ?? "0"); | |
| 2470 | if (!bytes || bytes > DOC_MAX_FILE_BYTES) return Response.json(fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`)); | |
| 2471 | const name = safeName(url.searchParams.get("name") ?? "file"); | |
| 2472 | const contentType = servedType(request.headers.get("content-type") ?? ""); | |
| 2473 | const key = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| 2474 | const id = newId("fil"); | |
| 2475 | await fileStore(this.env).put(`docs/${key}`, request.body ?? new Uint8Array(), contentType); | |
| 2476 | await this.db | |
| 2477 | .prepare("INSERT INTO folio_files (id, workspace_id, folio_id, key, name, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 2478 | .bind(id, ctx.workspace.id, opened.value.row.id, key, name, contentType, bytes, ctx.key, now()) | |
| 2479 | .run(); | |
| 2480 | return Response.json(ok({ id, url: `/docs-files/${key}`, name, content_type: contentType, bytes })); | |
| 2481 | } | |
| 2482 | ||
| 2483 | /** `GET /files/<key>` for a folio's file, or null when the key isn't a folio's (then Docs' pages are asked). */ | |
| 2484 | async file(key: string): Promise<Response | null> { | |
| 2485 | const row = await this.db.prepare("SELECT name, content_type FROM folio_files WHERE key = ?").bind(key).first<{ name: string; content_type: string }>(); | |
| 2486 | if (!row) return null; | |
| 2487 | const stored = await fileStore(this.env).get(`docs/${key}`); | |
| 2488 | if (!stored) return new Response("Not found\n", { status: 404 }); | |
| 2489 | const inline = row.content_type !== "application/octet-stream"; | |
| 2490 | return new Response(stored.body, { | |
| 2491 | headers: { | |
| 2492 | "content-type": row.content_type, | |
| 2493 | "content-length": String(stored.bytes), | |
| 2494 | etag: stored.etag, | |
| 2495 | "content-disposition": `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(row.name)}`, | |
| 2496 | "cache-control": "private, max-age=31536000, immutable", | |
| 2497 | }, | |
| 2498 | }); | |
| 2499 | } | |
| 2500 | } | |
| 2501 | ||
| 2502 | /** A folio's room found people newly mentioned in it: those who can read it hear of it. */ | |
| 2503 | export async function notifyFolioMentions(env: FoliosEnv, slug: string, folioId: string, usernames: string[], last: string | null): Promise<void> { | |
| 2504 | const service = new Folios(env); | |
| 2505 | const workspace = await service.who.workspace(slug); | |
| 2506 | if (!workspace) return; | |
| 2507 | const row = await env.DB.prepare(`SELECT ${FOLIO_COLUMNS.replace("'' AS text", "text")} FROM folios WHERE id = ? AND workspace_id = ?`).bind(folioId, workspace.id).first<FolioRow>(); | |
| 2508 | if (!row || row.trashed_at) return; | |
| 2509 | await service.notifyMentioned(workspace, row, usernames, last, row.text, null); | |
| 2510 | } | |
| 2511 | ||
| 2512 | /** Trashed folios this long ago are deleted for good by the daily cron. */ | |
| 2513 | export const TRASH_DAYS = 30; | |
| 2514 | ||
| 2515 | /** | |
| 2516 | * The daily cron: folios in the trash for over TRASH_DAYS are deleted for | |
| 2517 | * good, deepest first, at most 500 a run (the rest go the next day). | |
| 2518 | */ | |
| 2519 | export async function purgeTrash(env: FoliosEnv, at = new Date()): Promise<number> { | |
| 2520 | const cutoff = new Date(at.getTime() - TRASH_DAYS * 24 * 60 * 60 * 1000).toISOString(); | |
| 2521 | const rows = (await env.DB.prepare("SELECT id, path FROM folios WHERE trashed_at IS NOT NULL AND trashed_at < ? ORDER BY length(path) DESC LIMIT 500").bind(cutoff).all<{ id: string; path: string }>()).results; | |
| 2522 | if (!rows.length) return 0; | |
| 2523 | // Children still alive under one being purged go to the top of where they were. | |
| 2524 | const ids = rows.map((r) => r.id); | |
| 2525 | await env.DB.prepare("UPDATE folios SET parent_id = NULL WHERE parent_id IN (SELECT value FROM json_each(?)) AND id NOT IN (SELECT value FROM json_each(?))").bind(json(ids), json(ids)).run(); | |
| 2526 | await forgetFolios(env, ids); | |
| 2527 | await runBatches( | |
| 2528 | env.DB, | |
| 2529 | ids.flatMap((id) => [env.DB.prepare("DELETE FROM folios_fts WHERE folio_id = ?").bind(id), env.DB.prepare("DELETE FROM folios WHERE id = ?").bind(id)]), | |
| 2530 | ); | |
| 2531 | if (env.FOLIOS) for (const id of ids) await env.FOLIOS.get(env.FOLIOS.idFromName(id)).destroy().catch(() => undefined); | |
| 2532 | return ids.length; | |
| 2533 | } | |
| 2534 | ||
| 2535 | /** The `folios.reacl` job: a large subtree's access, rooms and index brought up to date. */ | |
| 2536 | export async function runReacl(env: FoliosEnv, folioId: string): Promise<void> { | |
| 2537 | const service = new Folios(env); | |
| 2538 | const ids = await rebuildSubtree(env.DB, folioId); | |
| 2539 | await service.followAccess(null, ids); | |
| 2540 | } |