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