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