Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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