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