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