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