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