Skip to content
2,540 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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