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