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