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