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