Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

2,215 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 state = this.stateFor(ctx.workspace, row);
579 const { roles } = await this.roles(ctx, others);
580 const readable = (list: FolioRow[]) => list.filter((r) => roles.get(r.id)).map((r) => this.ref(ctx.workspace.slug, r));
581 return ok({
582 folio: folio!,
583 text: row.text,
584 state,
585 breadcrumbs: readable(parents),
586 children: readable(childRows.results),
587 backlinks: readable(linkRows.results.filter((r) => r.id !== row.id)),
588 suggestions,
589 });
590 }
591
592 /**
593 * The document the page carries, so the editor opens on it before the
594 * room answers. A folio saved before its state was kept (or whose last
595 * save was too large for a row) has none in D1: warm it in the background
596 * so the next open can read it from the row. Never hold the HTML response
597 * on a cold live room; this open shows saved text and syncs via the socket.
598 */
599 private stateFor(workspace: Workspace, row: FolioRow): string | null {
600 const saved = pageState(row.state);
601 if (saved || row.trashed_at) return saved;
602 this.defer(this.warmPageState(workspace, row));
603 return null;
604 }
605
606 private async warmPageState(workspace: Workspace, row: FolioRow): Promise<void> {
607 try {
608 const room = await this.ready(workspace, row);
609 const state = await room.state();
610 if (!state.byteLength || state.byteLength > MAX_PAGE_STATE) return;
611 await this.db.prepare("UPDATE folios SET state = ? WHERE id = ? AND state IS NULL").bind(state, row.id).run();
612 } catch (error) {
613 console.warn("artifacts: a page opens without its state", row.id, error instanceof Error ? error.message : String(error));
614 }
615 }
616
617 // ── Making and changing ─────────────────────────────────────────────────
618
619 /** Where a new folio may go for this person: a parent doc they can edit, a space they can edit, or their Private. */
620 private async placeFor(ctx: Ctx, input: { space_id?: string | null; parent_id?: string | null }): Promise<Result<{ space_id: string | null; parent: FolioRow | null }>> {
621 if (input.parent_id) {
622 const parent = await this.open(ctx, input.parent_id, "edit");
623 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.");
624 if (parent.value.row.kind !== "doc") return fail("invalid", "Only a doc can hold other artifacts.");
625 if (depthOf(parent.value.row.path) >= MAX_DEPTH) return fail("invalid", `Artifacts go at most ${MAX_DEPTH} deep.`);
626 return ok({ space_id: parent.value.row.space_id, parent: parent.value.row });
627 }
628 if (input.space_id) {
629 const space = ctx.spaceById.get(input.space_id);
630 if (!space?.role || space.row.archived_at || space.row.deleted_at) return fail("not_found", "No such space.");
631 if (!atLeast(space.role, "edit")) return fail("forbidden", `You can read ${space.row.name} but not add to it.`);
632 return ok({ space_id: space.row.id, parent: null });
633 }
634 return ok({ space_id: null, parent: null });
635 }
636
637 /** Where a new folio starts: a template's or the given content, and its title and icon. */
638 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 }>> {
639 let text = "";
640 let spec: unknown = undefined;
641 let title = cleanTitle(input.title);
642 let icon = cleanIcon(input.icon);
643 if (input.template_id) {
644 const template = builtinFolioTemplate(input.template_id) ?? (await this.savedTemplate(ctx.workspace, input.template_id));
645 if (!template) return fail("not_found", "No such template.");
646 if (template.kind !== kind) return fail("invalid", `That template is for ${kindLabel(template.kind)}, not ${kindLabel(kind)}.`);
647 if (kind === "doc" || kind === "slides") text = template.body;
648 else spec = parseJson(template.body, null);
649 if (!title) title = template.name;
650 if (!icon) icon = template.icon;
651 } else if (input.content) {
652 if ("markdown" in input.content) text = String(input.content.markdown ?? "").slice(0, MAX_TEXT);
653 else spec = input.content.spec;
654 }
655 return ok({ text, spec, title, icon });
656 }
657
658 /** Whether a member key may be shared with: a member or a team of this workspace. */
659 private async principalExists(ctx: Ctx, principal: string): Promise<boolean> {
660 const p = parsePrincipalKey(principal);
661 if (principal.startsWith("team:")) {
662 const slug = principal.slice(5).toLowerCase();
663 return [...(await this.who.teamsOf(ctx.workspace)).values()].some((set) => set.has(slug));
664 }
665 if (!p || p.kind !== "user") return false;
666 await this.who.nameUsers([p.id]);
667 const username = this.who.usernames.get(p.id);
668 return !!username && (await this.who.members(ctx.workspace)).has(username.toLowerCase());
669 }
670
671 /** Inserts a folio and fills its room. */
672 private async insertFolio(
673 ctx: Ctx,
674 input: {
675 kind: FolioKind;
676 owner: string;
677 created_by: string;
678 space_id: string | null;
679 parent: FolioRow | null;
680 title: string;
681 icon: string | null;
682 text: string;
683 spec?: unknown;
684 state?: Uint8Array | null;
685 inherit?: boolean;
686 source?: { title: string; href: string } | null;
687 grants?: { principal: string; role: DocRole }[];
688 position?: number;
689 },
690 ): Promise<FolioRow> {
691 const id = newId("fol");
692 const at = now();
693 const siblings = input.parent
694 ? await this.db.prepare("SELECT MAX(position) AS p FROM folios WHERE parent_id = ?").bind(input.parent.id).first<{ p: number | null }>()
695 : input.space_id
696 ? 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 }>()
697 : 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 }>();
698 const position = input.position ?? (siblings?.p ?? 0) + 1024;
699 const inherit = input.inherit ?? true;
700 const aclRoot = !inherit || !input.parent ? id : input.parent.acl_root;
701 const path = input.parent ? `${input.parent.path}${id}/` : `/${id}/`;
702 const row: FolioRow = {
703 id,
704 workspace_id: ctx.workspace.id,
705 kind: input.kind,
706 title: input.title,
707 icon: input.icon,
708 cover: null,
709 owner: input.owner,
710 space_id: input.space_id,
711 parent_id: input.parent?.id ?? null,
712 position,
713 inherit: inherit ? 1 : 0,
714 acl_root: aclRoot,
715 path,
716 general_access: "none",
717 general_role: null,
718 agent_mode: null,
719 text: input.text,
720 excerpt: excerpt(input.text),
721 preview: null,
722 source: input.source ? JSON.stringify(input.source) : null,
723 mentioned: "[]",
724 created_by: input.created_by,
725 created_at: at,
726 updated_by: input.created_by,
727 updated_at: at,
728 edited_by: input.created_by,
729 edited_at: at,
730 trashed_at: null,
731 trashed_by: null,
732 };
733 const grants = (input.grants ?? []).filter((g) => g.principal !== input.owner);
734 await this.db.batch([
735 this.db
736 .prepare(
737 `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)
738 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
739 )
740 .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),
741 this.db.prepare("INSERT INTO folios_fts (folio_id, kind, title, body) VALUES (?, ?, ?, ?)").bind(id, row.kind, row.title, searchText(row.text)),
742 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),
743 ...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)),
744 ]);
745 await rebuildSubtree(this.db, id);
746 const room = this.room(id);
747 await room.ensure({ folio_id: id, kind: row.kind, workspace_slug: ctx.workspace.slug, text: input.text, spec: input.spec, state: input.state ?? null });
748 // The rendition the room makes of it, its card, links and citations, now.
749 await room.flush();
750 const open = await workspaceReadable(this.db, id).catch(() => false);
751 this.defer(
752 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),
753 );
754 this.defer(indexFolio(this.env, id));
755 return (await foliosById(this.db, [id])).get(id) ?? row;
756 }
757
758 /** Grants asked for at creation: people and teams of this workspace, never above `edit` for teams' sake of sense. */
759 private async cleanShares(ctx: Ctx, share: { principal: string; role: DocRole }[] | null | undefined): Promise<Result<{ principal: string; role: DocRole }[]>> {
760 const out: { principal: string; role: DocRole }[] = [];
761 for (const s of (share ?? []).slice(0, FOLIO_MAX_SHARE)) {
762 const principal = s.principal.startsWith("team:") ? `team:${s.principal.slice(5).toLowerCase()}` : s.principal;
763 if (!(await this.principalExists(ctx, principal))) return fail("invalid", `${s.principal} isn't a member or team of this workspace.`);
764 out.push({ principal, role: s.role });
765 }
766 return ok(out);
767 }
768
769 async create(a: Args & { input: Parameters<typeof newFolioError>[0] }): Promise<Result<Folio>> {
770 const input = a.input ?? ({ kind: "doc" } as Parameters<typeof newFolioError>[0]);
771 const invalid = newFolioError(input);
772 if (invalid) return fail("invalid", invalid);
773 if (!kindModel(input.kind)) return fail("invalid", `${kindLabel(input.kind)} aren't here yet.`);
774 const found = await this.ctx(a.workspace, a.viewer);
775 if (!found.ok) return found;
776 const ctx = found.value;
777 const place = await this.placeFor(ctx, input);
778 if (!place.ok) return place;
779 const start = await this.startingPoint(ctx, input.kind, input);
780 if (!start.ok) return start;
781 const shares = await this.cleanShares(ctx, input.share_with);
782 if (!shares.ok) return shares;
783 const row = await this.insertFolio(ctx, {
784 kind: input.kind,
785 owner: ctx.key,
786 created_by: ctx.key,
787 space_id: place.value.space_id,
788 parent: place.value.parent,
789 title: start.value.title,
790 icon: start.value.icon,
791 text: start.value.text,
792 spec: start.value.spec,
793 source: cleanSource(input.source),
794 grants: shares.value,
795 });
796 return ok(await this.folioOf(ctx, row));
797 }
798
799 async update(a: Args & { folio_id: string; change: FolioChange }): Promise<Result<Folio>> {
800 const found = await this.ctx(a.workspace, a.viewer);
801 if (!found.ok) return found;
802 const ctx = found.value;
803 const opened = await this.open(ctx, a.folio_id, "edit");
804 if (!opened.ok) return opened;
805 const { row } = opened.value;
806 const c = a.change ?? {};
807 const sets: string[] = [];
808 const values: unknown[] = [];
809 const statements: D1PreparedStatement[] = [];
810 if (c.title !== undefined) {
811 sets.push("title = ?");
812 values.push(cleanTitle(c.title));
813 statements.push(this.db.prepare("UPDATE folios_fts SET title = ? WHERE folio_id = ?").bind(cleanTitle(c.title), row.id));
814 }
815 if (c.icon !== undefined) {
816 sets.push("icon = ?");
817 values.push(cleanIcon(c.icon));
818 }
819 if (c.cover !== undefined) {
820 sets.push("cover = ?");
821 values.push(cleanCover(c.cover));
822 }
823 if (sets.length) {
824 sets.push("updated_at = ?", "updated_by = ?");
825 values.push(now(), ctx.key);
826 statements.unshift(this.db.prepare(`UPDATE folios SET ${sets.join(", ")} WHERE id = ?`).bind(...values, row.id));
827 }
828 if (c.projects !== undefined) {
829 statements.push(this.db.prepare("DELETE FROM folio_projects WHERE folio_id = ?").bind(row.id));
830 const projects = [...new Set((Array.isArray(c.projects) ? c.projects : []).map((p) => projectRef(String(p))).filter((p): p is string => !!p))].slice(0, 20);
831 for (const repo of projects) statements.push(this.db.prepare("INSERT INTO folio_projects (folio_id, repo) VALUES (?, ?)").bind(row.id, repo));
832 }
833 if (statements.length) await this.db.batch(statements);
834 const folio = await this.folioOf(ctx, row);
835 this.tell(row.id, { type: "folio.updated", folio });
836 if (c.title !== undefined && cleanTitle(c.title) !== row.title) this.defer(indexFolio(this.env, row.id));
837 return ok(folio);
838 }
839
840 async move(a: Args & { folio_id: string; move: FolioMove }): Promise<Result<Folio>> {
841 const found = await this.ctx(a.workspace, a.viewer);
842 if (!found.ok) return found;
843 const ctx = found.value;
844 const opened = await this.open(ctx, a.folio_id, "edit");
845 if (!opened.ok) return opened;
846 const { row } = opened.value;
847 const move = a.move ?? ({ space_id: null, parent_id: null } as FolioMove);
848 let spaceId: string | null;
849 let parent: FolioRow | null = null;
850 if (move.parent_id) {
851 const target = await this.open(ctx, move.parent_id, "edit");
852 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.");
853 parent = target.value.row;
854 if (parent.kind !== "doc") return fail("invalid", "Only a doc can hold other artifacts.");
855 if (parent.path.startsWith(row.path)) return fail("invalid", "An artifact can't go inside itself.");
856 spaceId = parent.space_id;
857 } else if (move.space_id) {
858 const space = ctx.spaceById.get(move.space_id);
859 if (!space?.role || space.row.archived_at || space.row.deleted_at) return fail("not_found", "No such space.");
860 if (!atLeast(space.role, "edit")) return fail("forbidden", `You can't add to ${space.row.name}.`);
861 spaceId = space.row.id;
862 } else {
863 // Private is its owner's: only they put something at its top.
864 if (row.owner !== ctx.key) return fail("forbidden", "Only its owner can move it to their Private section.");
865 spaceId = null;
866 }
867 const below = await subtree(this.db, row);
868 if (depthOf(parent?.path ?? "") + 1 + subtreeHeight(row, below) > MAX_DEPTH) return fail("invalid", `Artifacts go at most ${MAX_DEPTH} deep.`);
869 const siblings = (
870 parent
871 ? 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 }>()
872 : spaceId
873 ? 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 }>()
874 : await this.db
875 .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")
876 .bind(ctx.workspace.id, row.owner)
877 .all<{ id: string; parent_id: string | null; position: number }>()
878 ).results;
879 const placed = placeBefore(siblings, row.id, parent?.id ?? null, move.before_id ?? null);
880 const statements: D1PreparedStatement[] = [
881 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),
882 ];
883 for (const [id, position] of placed.renumber) statements.push(this.db.prepare("UPDATE folios SET position = ? WHERE id = ?").bind(position, id));
884 await this.db.batch(statements);
885 await this.afterAccessChange(ctx, row.id, below.length);
886 const folio = await this.folioOf(ctx, row);
887 this.tell(row.id, { type: "folio.updated", folio });
888 return ok(folio);
889 }
890
891 /**
892 * After a move or a sharing change: the subtree's places and
893 * `folio_access` rebuilt, open rooms told of their people's new roles,
894 * and passages filed under their new scope. A subtree past
895 * FOLIO_INLINE_REACL goes to the queue (`folios.reacl`).
896 */
897 private async afterAccessChange(ctx: Ctx | null, rootId: string, size: number): Promise<void> {
898 if (size > FOLIO_INLINE_REACL && this.env.JOBS) {
899 await this.env.JOBS.send({ type: "folios.reacl", folio_id: rootId });
900 return;
901 }
902 const ids = await rebuildSubtree(this.db, rootId);
903 this.defer(this.followAccess(ctx?.workspace ?? null, ids));
904 }
905
906 /** Open rooms in these folios re-check each socket's person; the index files their passages under their scope now. */
907 async followAccess(workspace: Workspace | null, ids: string[]): Promise<void> {
908 try {
909 const rows = [...(await foliosById(this.db, ids)).values()];
910 if (!rows.length) return;
911 const ws = workspace ?? (await this.workspaceById(rows[0]!.workspace_id));
912 if (ws) {
913 for (const row of rows.slice(0, INLINE_ROOMS)) {
914 const room = this.room(row.id);
915 const members = await room.members().catch(() => [] as { key: string; name: string }[]);
916 if (members.length) {
917 for (const m of members) {
918 const role = await this.roleOfPerson(ws, row, m.key, m.name);
919 await room.setRole(m.key, role).catch(() => undefined);
920 }
921 await room.notice({ type: "folio.access" }).catch(() => undefined);
922 }
923 }
924 }
925 for (const row of rows.slice(0, 2000)) await indexFolio(this.env, row.id);
926 } catch (error) {
927 console.error("folios could not follow an access change", String(error));
928 }
929 }
930
931 private async workspaceById(id: string): Promise<Workspace | null> {
932 const names = await identityClient(this.env.IDENTITY)
933 .usernames([id])
934 .catch(() => ({}) as Record<string, string>);
935 return names[id] ? this.who.workspace(names[id]!) : null;
936 }
937
938 /** Someone's role on a folio, by their member key and username (for open sockets and mentions). */
939 private async roleOfPerson(workspace: Workspace, row: FolioRow, key: string, username: string): Promise<DocRole | null> {
940 if (!key.startsWith("user:")) return null;
941 const userId = key.slice(5);
942 const member = (await this.who.members(workspace)).get(username.toLowerCase());
943 if (!member) return null;
944 const person = await this.who.personOf(workspace, { id: userId, username }, member.role === "owner");
945 const spaces = await this.who.spacesFor(workspace, person);
946 const byId = new Map(spaces.map((s) => [s.row.id, s]));
947 const [found, visits] = await Promise.all([ancestry(this.db, [row]), visitsOf(this.db, userId, [row])]);
948 return rolesFrom(found, [row], { person, spaceRole: (id) => byId.get(id)?.role ?? null, visits }).get(row.id) ?? null;
949 }
950
951 async duplicate(a: Args & { folio_id: string }): Promise<Result<Folio>> {
952 const found = await this.ctx(a.workspace, a.viewer);
953 if (!found.ok) return found;
954 const ctx = found.value;
955 const opened = await this.open(ctx, a.folio_id, "view");
956 if (!opened.ok) return opened;
957 const { row } = opened.value;
958 if (!kindModel(row.kind)) return fail("invalid", `${kindLabel(row.kind)} aren't here yet.`);
959 // Beside the original where they may add, else in their Private. Never shared wider than the original: no grants, no general access.
960 let space: string | null = null;
961 let parent: FolioRow | null = null;
962 if (row.parent_id) {
963 const p = await this.open(ctx, row.parent_id, "edit");
964 if (p.ok) {
965 parent = p.value.row;
966 space = parent.space_id;
967 }
968 } else if (row.space_id && atLeast(ctx.spaceById.get(row.space_id)?.role, "edit")) space = row.space_id;
969 const besides = !!parent || !!space;
970 const room = await this.ready(ctx.workspace, row);
971 const state = await room.state();
972 const text = await room.text();
973 const copy = await this.insertFolio(ctx, {
974 kind: row.kind,
975 owner: ctx.key,
976 created_by: ctx.key,
977 space_id: space,
978 parent,
979 title: cleanTitle(`${row.title || "Untitled"} (copy)`),
980 icon: row.icon,
981 text,
982 state,
983 inherit: besides ? !!row.inherit : true,
984 position: besides ? row.position + 0.5 : undefined,
985 });
986 return ok(await this.folioOf(ctx, copy));
987 }
988
989 async trash(a: Args & { folio_id: string }): Promise<Result<Folio>> {
990 const found = await this.ctx(a.workspace, a.viewer);
991 if (!found.ok) return found;
992 const ctx = found.value;
993 const opened = await this.open(ctx, a.folio_id, "edit");
994 if (!opened.ok) return opened;
995 const { row } = opened.value;
996 const ids = (await subtree(this.db, row)).filter((r) => !r.trashed_at).map((r) => r.id);
997 const at = now();
998 await runBatches(
999 this.db,
1000 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)),
1001 );
1002 for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).closeAll("Moved to the trash").catch(() => undefined));
1003 this.defer(forgetFolios(this.env, ids));
1004 const open = await workspaceReadable(this.db, row.id).catch(() => false);
1005 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));
1006 const [folio] = await this.toFolios(ctx, [{ ...row, trashed_at: at, trashed_by: ctx.key }]);
1007 return ok(folio!);
1008 }
1009
1010 async restore(a: Args & { folio_id: string }): Promise<Result<Folio>> {
1011 const found = await this.ctx(a.workspace, a.viewer);
1012 if (!found.ok) return found;
1013 const ctx = found.value;
1014 const opened = await this.open(ctx, a.folio_id, "edit", { trashed: true });
1015 if (!opened.ok) return opened;
1016 const { row } = opened.value;
1017 if (!row.trashed_at) return fail("invalid", "That artifact isn't in the trash.");
1018 const below = await subtree(this.db, row);
1019 const ids = below.filter((r) => r.trashed_at === row.trashed_at).map((r) => r.id);
1020 const parent = row.parent_id ? (await foliosById(this.db, [row.parent_id])).get(row.parent_id) : null;
1021 const statements = ids.map((id) => this.db.prepare("UPDATE folios SET trashed_at = NULL, trashed_by = NULL WHERE id = ?").bind(id));
1022 // Its parent is gone or still in the trash: it comes back at the top of where it was.
1023 const detach = !!row.parent_id && (!parent || !!parent.trashed_at);
1024 if (detach) statements.push(this.db.prepare("UPDATE folios SET parent_id = NULL WHERE id = ?").bind(row.id));
1025 // Its space was deleted: a top comes back at the end of General (when
1026 // there is one the restorer may add to), else of its owner's Private; a
1027 // child whose parent lives follows its parent's place (rebuilt below).
1028 const gone = !!row.space_id && !!ctx.spaceById.get(row.space_id)?.row.deleted_at;
1029 const relocate = gone && (detach || !row.parent_id);
1030 let spaceId = row.space_id;
1031 if (relocate) {
1032 spaceId = landingFor(ctx.spaces)?.row.id ?? null;
1033 const last = spaceId
1034 ? 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 }>()
1035 : 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 }>();
1036 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));
1037 }
1038 await runBatches(this.db, statements);
1039 if (detach || gone) await this.afterAccessChange(ctx, row.id, below.length);
1040 else this.defer((async () => { for (const id of ids.slice(0, 2000)) await indexFolio(this.env, id); })());
1041 const open = await workspaceReadable(this.db, row.id).catch(() => false);
1042 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));
1043 return ok(await this.folioOf(ctx, row));
1044 }
1045
1046 async delete(a: Args & { folio_id: string }): Promise<Result<boolean>> {
1047 const found = await this.ctx(a.workspace, a.viewer);
1048 if (!found.ok) return found;
1049 const ctx = found.value;
1050 const opened = await this.open(ctx, a.folio_id, "manage", { trashed: true });
1051 if (!opened.ok) return opened;
1052 const { row } = opened.value;
1053 if (!row.trashed_at) return fail("invalid", "Move it to the trash first.");
1054 const open = await workspaceReadable(this.db, row.id).catch(() => false);
1055 const ids = await eraseFolios(this.env, await subtree(this.db, row));
1056 for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).destroy().catch(() => undefined));
1057 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));
1058 return ok(true);
1059 }
1060
1061 /**
1062 * Empties the viewer's trash: everything `trashed` lists for them that
1063 * they have full access to goes for good, with everything under it, as
1064 * `delete` does one at a time. What they may only edit stays. Tells one
1065 * `folio.deleted` per top, with no title (many folios, each a chain to
1066 * check). Answers how many tops went.
1067 */
1068 async emptyTrash(a: Args): Promise<Result<{ deleted: number }>> {
1069 const found = await this.ctx(a.workspace, a.viewer);
1070 if (!found.ok) return found;
1071 const ctx = found.value;
1072 let deleted = 0;
1073 for (;;) {
1074 const tops = await this.trashedFor(ctx, 500, "manage");
1075 if (!tops.length) break;
1076 const rows: FolioRow[] = [];
1077 for (const top of tops) rows.push(...(await subtree(this.db, top)));
1078 const ids = await eraseFolios(this.env, rows);
1079 for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).destroy().catch(() => undefined));
1080 for (const top of tops) {
1081 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));
1082 }
1083 deleted += tops.length;
1084 if (tops.length < 500) break;
1085 }
1086 return ok({ deleted });
1087 }
1088
1089 /** Trashed folios the viewer has `need` role on (edit: may restore; manage: may delete): the tops of what went to the trash together. */
1090 private async trashedFor(ctx: Ctx, limit: number, need: DocRole = "edit"): Promise<FolioRow[]> {
1091 const filter = this.filterOf(ctx);
1092 const rows = (
1093 await this.db
1094 .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 ?`)
1095 .bind(ctx.workspace.id, ...filter.binds, limit * 2)
1096 .all<FolioRow>()
1097 ).results;
1098 const { roles } = await this.roles(ctx, rows);
1099 const byId = new Map(rows.map((r) => [r.id, r]));
1100 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);
1101 }
1102
1103 async trashed(a: Args): Promise<Result<Folio[]>> {
1104 const found = await this.ctx(a.workspace, a.viewer);
1105 if (!found.ok) return found;
1106 return ok(await this.toFolios(found.value, await this.trashedFor(found.value, 200)));
1107 }
1108
1109 /**
1110 * Deletes a space, the General space too: everything in it goes to the
1111 * trash together (whoever could edit an artifact restores it as usual;
1112 * it then lands in General, or in its owner's Private), and the space is
1113 * gone for good, its row kept marked (SpaceRow.deleted_at), so General
1114 * is never made again and a workspace may have no spaces. Full access to
1115 * the space, or a workspace owner who can open it.
1116 */
1117 async deleteSpace(a: Args & { space_id: string }): Promise<Result<{ trashed: number }>> {
1118 const found = await this.ctx(a.workspace, a.viewer);
1119 if (!found.ok) return found;
1120 const ctx = found.value;
1121 const space = ctx.spaceById.get(String(a.space_id ?? ""));
1122 if (!space?.role || space.row.deleted_at) return fail("not_found", "No such space.");
1123 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.");
1124 const ids = await deleteSpaceRows(this.db, space.row, now(), ctx.key);
1125 for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).closeAll("Moved to the trash").catch(() => undefined));
1126 this.defer(forgetFolios(this.env, ids));
1127 this.who.forgetSpaces();
1128 this.defer(
1129 publishSpaceDeleted(
1130 this.env.EVENTS,
1131 { 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 },
1132 ctx.key,
1133 ),
1134 );
1135 return ok({ trashed: ids.length });
1136 }
1137
1138 async favorite(a: Args & { folio_id: string; on: boolean }): Promise<Result<boolean>> {
1139 const found = await this.ctx(a.workspace, a.viewer);
1140 if (!found.ok) return found;
1141 const ctx = found.value;
1142 const opened = await this.open(ctx, a.folio_id, "view");
1143 if (!opened.ok) return opened;
1144 if (a.on) {
1145 await this.db
1146 .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 = ?), ?)")
1147 .bind(ctx.viewer.id, opened.value.row.id, ctx.viewer.id, now())
1148 .run();
1149 } else {
1150 await this.db.prepare("DELETE FROM folio_favorites WHERE user_id = ? AND folio_id = ?").bind(ctx.viewer.id, opened.value.row.id).run();
1151 }
1152 return ok(!!a.on);
1153 }
1154
1155 // ── Content in the agent form, for a person or their token ──────────────
1156
1157 private spaceOf(ctx: Ctx, row: FolioRow): FolioAgentRead["space"] {
1158 const space = row.space_id ? ctx.spaceById.get(row.space_id) : undefined;
1159 return space ? { id: space.row.id, slug: space.row.slug, name: space.row.name, agent_mode: space.row.agent_mode } : null;
1160 }
1161
1162 async content(a: Args & { folio_id: string }): Promise<Result<FolioAgentRead>> {
1163 const found = await this.ctx(a.workspace, a.viewer);
1164 if (!found.ok) return found;
1165 const ctx = found.value;
1166 const opened = await this.open(ctx, a.folio_id, "view");
1167 if (!opened.ok) return opened;
1168 const { row, role } = opened.value;
1169 if (!kindModel(row.kind)) return fail("invalid", `${kindLabel(row.kind)} aren't here yet.`);
1170 const read = await (await this.ready(ctx.workspace, row)).read();
1171 return ok({
1172 folio: { ...this.ref(ctx.workspace.slug, row), edited_at: row.edited_at },
1173 space: this.spaceOf(ctx, row),
1174 content: read.content,
1175 ...(read.blocks ? { blocks: read.blocks } : {}),
1176 can: { read: true, suggest: atLeast(role, "comment"), edit: atLeast(role, "edit"), attach: atLeast(role, "edit") },
1177 audience_can_read: true,
1178 });
1179 }
1180
1181 /** What is wrong with an edit for this folio, or null. */
1182 private editError(row: FolioRow, edit: unknown): string | null {
1183 const invalid = folioAgentEditError(edit);
1184 if (invalid) return invalid;
1185 const e = edit as FolioAgentEdit;
1186 if (e.kind !== row.kind) return `This is ${kindLabel(row.kind)}, and the edit is for ${kindLabel(e.kind)}.`;
1187 if (e.kind === "doc" && !cleanTarget(e.target)) return "Say what to change: append, document, a section by its heading, or blocks by id.";
1188 if (e.kind === "doc" && e.markdown.length > MAX_TEXT) return "That edit is too long.";
1189 if (e.kind === "doc" && e.target.kind === "append" && !e.markdown.trim()) return "Nothing to add.";
1190 return null;
1191 }
1192
1193 async edit(a: Args & { folio_id: string; edit: FolioAgentEdit }): Promise<Result<FolioAgentEditResult>> {
1194 const found = await this.ctx(a.workspace, a.viewer);
1195 if (!found.ok) return found;
1196 const ctx = found.value;
1197 const opened = await this.open(ctx, a.folio_id, "comment");
1198 if (!opened.ok) return opened;
1199 const { row, role } = opened.value;
1200 const invalid = this.editError(row, a.edit);
1201 if (invalid) return fail("invalid", invalid);
1202 const edit = a.edit;
1203 const ref = this.ref(ctx.workspace.slug, row);
1204 if (atLeast(role, "edit") && !edit.suggest_only) {
1205 const room = await this.ready(ctx.workspace, row);
1206 const result = await room.edit(edit, { key: ctx.key, kind: "edit", note: cleanNote(edit.note), authors: [ctx.key] });
1207 if (!result.applied) return fail("not_found", `${result.summary} Read it again and target what is there now.`);
1208 if (edit.marks_current) await this.clearStale(row.id, ctx.key);
1209 return ok({ mode: "applied", version_id: result.version_id, folio: ref, summary: result.summary });
1210 }
1211 if (edit.kind !== "doc") return fail("forbidden", `Suggesting changes to ${kindLabel(row.kind)} comes with proposals, which aren't here yet.`);
1212 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 });
1213 return suggestion.ok ? ok({ mode: "suggested", suggestion: suggestion.value, folio: ref }) : suggestion;
1214 }
1215
1216 // ── Sharing ─────────────────────────────────────────────────────────────
1217
1218 private async accessList(ctx: Ctx, row: FolioRow, role: DocRole, found: Ancestry): Promise<FolioAccessList> {
1219 const chain = aclChain(row.id, found.nodes);
1220 const root = chain[chain.length - 1] ?? aclNode(row);
1221 const entries = explicitAccess(chain, found.grants);
1222 const keys = [...entries.keys()];
1223 const people = await this.who.profiles(
1224 ctx.workspace,
1225 keys.filter((k) => !k.startsWith("team:")),
1226 );
1227 const rows: FolioAccessRow[] = [];
1228 for (const [principal, entry] of entries) {
1229 if (principal === row.owner && entry.via === "owner") continue;
1230 const via = entry.via === row.id ? null : found.rows.get(entry.via);
1231 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" };
1232 const profile: FolioAccessRow["profile"] = principal.startsWith("team:")
1233 ? { kind: "team", id: principal.slice(5), name: principal.slice(5), display_name: `@${ctx.workspace.slug}/${principal.slice(5)}` }
1234 : people.get(principal)!;
1235 rows.push({ principal, profile, role: entry.role, source });
1236 }
1237 rows.sort((x, y) => RANK[y.role] - RANK[x.role] || x.profile.display_name.localeCompare(y.profile.display_name));
1238 const owner = (await this.who.profiles(ctx.workspace, [row.owner])).get(row.owner)!;
1239 const space = row.space_id ? ctx.spaceById.get(row.space_id) : undefined;
1240 const parent = row.parent_id ? found.rows.get(row.parent_id) : undefined;
1241 let inherited: FolioAccessList["inherited_from"] = null;
1242 if (row.inherit && parent) inherited = { kind: "folio", id: parent.id, name: parent.title || "Untitled" };
1243 else if (row.inherit && !row.parent_id && space) inherited = { kind: "space", id: space.row.id, name: space.row.name };
1244 return {
1245 folio_id: row.id,
1246 owner,
1247 rows,
1248 general_access: root.general_access,
1249 general_role: root.general_access === "none" ? null : ((root.general_role as FolioAccessList["general_role"]) ?? "view"),
1250 inherit: !!row.inherit,
1251 inherited_from: inherited,
1252 agent_mode: row.agent_mode,
1253 can_share: canShare(role, this.editorsShare(ctx, row)),
1254 public_link: "off",
1255 };
1256 }
1257
1258 /** Whether the folio's space lets people with edit access share what is in it. */
1259 private editorsShare(ctx: Ctx, row: Pick<FolioRow, "space_id">): boolean {
1260 return !!(row.space_id && ctx.spaceById.get(row.space_id)?.row.editors_can_share);
1261 }
1262
1263 async access(a: Args & { folio_id: string }): Promise<Result<FolioAccessList>> {
1264 const found = await this.ctx(a.workspace, a.viewer);
1265 if (!found.ok) return found;
1266 const ctx = found.value;
1267 const opened = await this.open(ctx, a.folio_id, "view");
1268 if (!opened.ok) return opened;
1269 return ok(await this.accessList(ctx, opened.value.row, opened.value.role, opened.value.found));
1270 }
1271
1272 /** After any sharing change: the rows, the rooms, the index, and the share dialog again. */
1273 private async afterShare(ctx: Ctx, row: FolioRow): Promise<FolioAccessList> {
1274 const below = await subtree(this.db, row);
1275 await this.afterAccessChange(ctx, row.id, below.length);
1276 const again = await this.open(ctx, row.id, "view", { trashed: true });
1277 if (!again.ok) {
1278 // They shared themselves out of it.
1279 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" };
1280 }
1281 return this.accessList(ctx, again.value.row, again.value.role, again.value.found);
1282 }
1283
1284 async setGrant(a: Args & { folio_id: string; change: FolioAccessChange }): Promise<Result<FolioAccessList>> {
1285 const change = a.change;
1286 if (!change || (change.op !== "grant" && change.op !== "revoke")) return fail("invalid", "Grants and revokes only; other changes go to set_folio_general_access.");
1287 const invalid = folioAccessChangeError(change);
1288 if (invalid) return fail("invalid", invalid);
1289 const found = await this.ctx(a.workspace, a.viewer);
1290 if (!found.ok) return found;
1291 const ctx = found.value;
1292 const opened = await this.open(ctx, a.folio_id, "view");
1293 if (!opened.ok) return opened;
1294 const { row, role } = opened.value;
1295 if (!canShare(role, this.editorsShare(ctx, row))) return fail("forbidden", "Only people with full access can share it.");
1296 // Editors whose space lets them share give up to edit; full access stays with managers.
1297 if (role !== "manage" && change.op === "grant" && change.role === "manage") return fail("forbidden", "Only people with full access can give full access.");
1298 const principal = change.principal.startsWith("team:") ? `team:${change.principal.slice(5).toLowerCase()}` : change.principal;
1299 if (principal === row.owner) return fail("invalid", "Its owner always has full access.");
1300 if (role !== "manage") {
1301 const held = await this.db.prepare("SELECT role FROM folio_grants WHERE folio_id = ? AND principal = ?").bind(row.id, principal).first<{ role: DocRole }>();
1302 if (held?.role === "manage") return fail("forbidden", "Only people with full access can change someone else's full access.");
1303 }
1304 if (change.op === "revoke") {
1305 await this.db.prepare("DELETE FROM folio_grants WHERE folio_id = ? AND principal = ?").bind(row.id, principal).run();
1306 return ok(await this.afterShare(ctx, row));
1307 }
1308 if (!(await this.principalExists(ctx, principal))) return fail("invalid", "Share with a member or a team of this workspace.");
1309 await this.db
1310 .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")
1311 .bind(row.id, principal, change.role, ctx.key, now())
1312 .run();
1313 const list = await this.afterShare(ctx, row);
1314 const open = await workspaceReadable(this.db, row.id).catch(() => false);
1315 this.defer(
1316 publishFolioEvent(
1317 this.env.EVENTS,
1318 "folio.shared",
1319 { 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 },
1320 ctx.key,
1321 ),
1322 );
1323 if (principal.startsWith("user:")) this.defer(this.notifyShared(ctx, row, principal.slice(5), change.role, cleanNote(change.notify)));
1324 return ok(list);
1325 }
1326
1327 /** The person shared with hears of it (they can read it now, so its title may go). */
1328 private async notifyShared(ctx: Ctx, row: FolioRow, userId: string, role: DocRole, message: string | null): Promise<void> {
1329 if (!this.env.NOTIFY || userId === ctx.viewer.id) return;
1330 const me = (await this.who.profiles(ctx.workspace, [ctx.key])).get(ctx.key)!;
1331 const verb = role === "view" ? "view" : role === "comment" ? "comment on" : "edit";
1332 await notifyClient(this.env.NOTIFY)
1333 .notify(
1334 { user_id: userId },
1335 {
1336 id: `folio-shared:${row.id}:${userId}:${Date.now()}`,
1337 kind: "inbox",
1338 workspace: ctx.workspace.slug,
1339 title: `${me.display_name} shared ${row.title || "Untitled"} with you`,
1340 body: message ?? `You can ${verb} it.`,
1341 href: this.ref(ctx.workspace.slug, row).path,
1342 actor: { kind: "user", id: ctx.viewer.id, name: me.display_name, avatar: me.avatar, avatar_seed: null },
1343 created_at: now(),
1344 },
1345 )
1346 .catch(() => undefined);
1347 }
1348
1349 async setGeneralAccess(a: Args & { folio_id: string; change: FolioAccessChange }): Promise<Result<FolioAccessList>> {
1350 const change = a.change;
1351 if (!change || change.op === "grant" || change.op === "revoke") return fail("invalid", "Grants and revokes go to set_folio_grant.");
1352 const invalid = folioAccessChangeError(change);
1353 if (invalid) return fail("invalid", invalid);
1354 const found = await this.ctx(a.workspace, a.viewer);
1355 if (!found.ok) return found;
1356 const ctx = found.value;
1357 const opened = await this.open(ctx, a.folio_id, "view");
1358 if (!opened.ok) return opened;
1359 const { row, role } = opened.value;
1360 if (!canShare(role)) return fail("forbidden", "Only people with full access can change who can open it.");
1361 const at = now();
1362 if (change.op === "general") {
1363 if (row.inherit && row.parent_id) {
1364 const parent = opened.value.found.rows.get(row.parent_id);
1365 return fail("invalid", `It follows ${parent?.title || "the doc it's in"}. Change it there, or choose "Only people invited" first.`);
1366 }
1367 await this.db
1368 .prepare("UPDATE folios SET general_access = ?, general_role = ?, updated_at = ?, updated_by = ? WHERE id = ?")
1369 .bind(change.access, change.access === "none" ? null : change.role, at, ctx.key, row.id)
1370 .run();
1371 } else if (change.op === "inherit") {
1372 if (!row.parent_id && !row.space_id) return fail("invalid", "It's in Private, so there is nothing for it to follow.");
1373 if (change.inherit && !row.inherit) {
1374 // Following again: its own general access gives way to what it follows.
1375 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();
1376 } else if (!change.inherit && row.inherit) {
1377 await this.db.prepare("UPDATE folios SET inherit = 0, updated_at = ?, updated_by = ? WHERE id = ?").bind(at, ctx.key, row.id).run();
1378 }
1379 } else if (change.op === "agent_mode") {
1380 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();
1381 const again = await this.open(ctx, row.id, "view");
1382 if (!again.ok) return again;
1383 this.tell(row.id, { type: "folio.access" });
1384 return ok(await this.accessList(ctx, again.value.row, again.value.role, again.value.found));
1385 }
1386 return ok(await this.afterShare(ctx, row));
1387 }
1388
1389 async requestAccess(a: Args & { folio_id: string; message?: string | null }): Promise<Result<boolean>> {
1390 const found = await this.ctx(a.workspace, a.viewer);
1391 if (!found.ok) return found;
1392 const ctx = found.value;
1393 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>();
1394 if (!row) return fail("not_found", "No such artifact.");
1395 const { roles } = await this.roles(ctx, [row]);
1396 if (roles.get(row.id)) return ok(true);
1397 if (!this.env.NOTIFY) return ok(true);
1398 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.");
1399 // The owner and anyone with full access through a grant hear of it.
1400 const managers = (
1401 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 }>()
1402 ).results.map((r) => r.principal.slice(5));
1403 const me = (await this.who.profiles(ctx.workspace, [ctx.key])).get(ctx.key)!;
1404 const message = cleanNote(a.message);
1405 const notify = notifyClient(this.env.NOTIFY);
1406 await Promise.all(
1407 [...new Set([row.owner.slice(5), ...managers])].slice(0, REQUEST_RECIPIENTS).map((id) =>
1408 notify
1409 .notify(
1410 { user_id: id },
1411 {
1412 id: `folio-request:${row.id}:${ctx.viewer.id}:${id}`,
1413 kind: "inbox",
1414 workspace: ctx.workspace.slug,
1415 title: `${me.display_name} asks for access to ${row.title || "Untitled"}`,
1416 body: message ?? "Open it and choose Share to let them in.",
1417 href: this.ref(ctx.workspace.slug, row).path,
1418 actor: { kind: "user", id: ctx.viewer.id, name: me.display_name, avatar: me.avatar, avatar_seed: null },
1419 created_at: now(),
1420 },
1421 )
1422 .catch(() => undefined),
1423 ),
1424 );
1425 return ok(true);
1426 }
1427
1428 async joinSpace(a: Args & { space_id: string }): Promise<Result<boolean>> {
1429 const found = await this.ctx(a.workspace, a.viewer);
1430 if (!found.ok) return found;
1431 const ctx = found.value;
1432 const space = ctx.spaceById.get(String(a.space_id ?? ""));
1433 if (!space?.role || space.row.archived_at || space.row.deleted_at) return fail("not_found", "No such space.");
1434 if (space.row.kind !== "workspace") return fail("invalid", "Only open spaces are joined; you're in team and members-only spaces already.");
1435 await this.db
1436 .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 = ?), ?)")
1437 .bind(space.row.id, ctx.viewer.id, ctx.viewer.id, now())
1438 .run();
1439 return ok(true);
1440 }
1441
1442 async leaveSpace(a: Args & { space_id: string }): Promise<Result<boolean>> {
1443 const found = await this.ctx(a.workspace, a.viewer);
1444 if (!found.ok) return found;
1445 await this.db.prepare("DELETE FROM space_joins WHERE space_id = ? AND user_id = ?").bind(String(a.space_id ?? ""), found.value.viewer.id).run();
1446 return ok(true);
1447 }
1448
1449 // ── Search ──────────────────────────────────────────────────────────────
1450
1451 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[]>> {
1452 const found = await this.ctx(a.workspace, a.viewer);
1453 if (!found.ok) return found;
1454 return ok(await this.searchFor(found.value, a.query ?? { q: "" }));
1455 }
1456
1457 /** Words over titles and text (folios_fts), and by meaning over passages when asked; only folios the viewer can read now. */
1458 private async searchFor(
1459 ctx: Ctx,
1460 query: { q: string; kinds?: FolioKind[] | null; space_id?: string | null; project?: string | null; owner?: string | null; mode?: "words" | "hybrid" | null; limit?: number | null },
1461 narrow?: (rows: FolioRow[]) => Promise<Set<string>>,
1462 ): Promise<FolioSearchHit[]> {
1463 const q = ftsQuery(String(query.q ?? ""));
1464 const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1465 const filter = this.filterOf(ctx);
1466 const where: string[] = ["f.workspace_id = ?", "f.trashed_at IS NULL", filter.sql];
1467 const binds: unknown[] = [ctx.workspace.id, ...filter.binds];
1468 if (query.kinds?.length) {
1469 where.push("f.kind IN (SELECT value FROM json_each(?))");
1470 binds.push(json(query.kinds.filter(isFolioKind)));
1471 }
1472 if (query.space_id === "private") where.push("f.space_id IS NULL");
1473 else if (query.space_id) {
1474 where.push("f.space_id = ?");
1475 binds.push(query.space_id);
1476 }
1477 if (query.owner) {
1478 where.push("f.owner = ?");
1479 binds.push(query.owner);
1480 }
1481 const project = query.project ? projectRef(query.project) : null;
1482 if (project) {
1483 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 = ?))");
1484 binds.push(project, project);
1485 }
1486 type Hit = FolioRow & { snippet: string };
1487 const words: Hit[] = q
1488 ? (
1489 await this.db
1490 .prepare(
1491 `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
1492 WHERE folios_fts MATCH ? AND ${where.join(" AND ")} ORDER BY bm25(folios_fts, 0, 0, 8.0, 1.0) LIMIT ?`,
1493 )
1494 .bind(q, ...binds, limit * 3)
1495 .all<Hit>()
1496 ).results
1497 : (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;
1498 // Meaning: passages near the query from scopes the viewer may read, each one checked again below.
1499 let meaning: { folio_id: string; heading: string | null; text: string; score: number }[] = [];
1500 if (q && query.mode === "hybrid") {
1501 meaning = await this.meaningPassages(ctx, String(query.q), (await this.allowedScopes(ctx)).scopes).catch((error: unknown) => {
1502 console.error("folios could not search by meaning", String(error));
1503 return [];
1504 });
1505 meaning = meaning.filter((m) => m.score >= MEANING_FLOOR);
1506 }
1507 const extra = meaning.length ? await foliosById(this.db, meaning.map((m) => m.folio_id)) : new Map<string, FolioRow>();
1508 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()];
1509 const { roles } = await this.roles(ctx, candidates);
1510 let readable = new Set(candidates.filter((r) => roles.get(r.id)).map((r) => r.id));
1511 if (narrow) {
1512 const allowed = await narrow(candidates.filter((r) => readable.has(r.id)));
1513 readable = new Set([...readable].filter((id) => allowed.has(id)));
1514 }
1515 // Meaning-only hits still have to match the filters.
1516 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);
1517 const byWords = new Map(words.filter((w) => readable.has(w.id)).map((w) => [w.id, w]));
1518 const bestMeaning = new Map<string, (typeof meaning)[number]>();
1519 for (const m of meaning) {
1520 const r = extra.get(m.folio_id);
1521 if (!r || !readable.has(r.id) || !fits(r) || (project && !byWords.has(r.id))) continue;
1522 if ((bestMeaning.get(m.folio_id)?.score ?? -1) < m.score) bestMeaning.set(m.folio_id, m);
1523 }
1524 const order = query.mode === "hybrid" ? fuseRanks([...byWords.keys()], [...bestMeaning.values()].sort((x, y) => y.score - x.score).map((m) => m.folio_id)) : [...byWords.keys()];
1525 const spaceName = (id: string | null) => (id ? (ctx.spaceById.get(id)?.row.name ?? null) : null);
1526 const out: FolioSearchHit[] = [];
1527 for (const id of order) {
1528 if (out.length >= limit) break;
1529 const w = byWords.get(id);
1530 const m = bestMeaning.get(id);
1531 const row = w ?? extra.get(id);
1532 if (!row) continue;
1533 out.push({
1534 ...this.ref(ctx.workspace.slug, row),
1535 space_name: spaceName(row.space_id),
1536 snippet: w ? (q ? w.snippet : excerpt(w.snippet, 140)) : excerpt(m!.text, 200),
1537 edited_at: row.edited_at,
1538 heading: m?.heading ?? null,
1539 matched: query.mode === "hybrid" ? (w && m ? "both" : w ? "words" : "meaning") : null,
1540 });
1541 }
1542 return out;
1543 }
1544
1545 /**
1546 * The scopes the viewer may recall from (src/access.ts `folioScope`):
1547 * their readable spaces, and the access roots of folios shared with
1548 * them, open to the workspace, or whose link they opened. Every hit is
1549 * still checked against the folio itself.
1550 */
1551 private async allowedScopes(ctx: Ctx): Promise<{ scopes: string[] }> {
1552 const keys = personKeys(ctx.person);
1553 const [shared, general, visited] = await Promise.all([
1554 this.db
1555 .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")
1556 .bind(json(keys), ctx.workspace.id)
1557 .all<{ id: string }>(),
1558 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 }>(),
1559 this.db
1560 .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")
1561 .bind(ctx.viewer.id, ctx.workspace.id)
1562 .all<{ id: string }>(),
1563 ]);
1564 const scopes = new Set<string>(ctx.spaces.filter((s) => s.role).map((s) => `space:${s.row.id}`));
1565 for (const r of [...shared.results, ...general.results, ...visited.results]) scopes.add(`folio:${r.id}`);
1566 return { scopes: [...scopes] };
1567 }
1568
1569 private async queryVector(query: string, embedder: { embed(texts: string[]): Promise<number[][]> } | null): Promise<number[] | null> {
1570 const key = queryKey(query);
1571 if (!embedder || !key) return null;
1572 const cached = queryVectors.get(key);
1573 if (cached) return cached;
1574 try {
1575 const [vector] = await embedder.embed([key]);
1576 if (vector) queryVectors.set(key, vector);
1577 return vector ?? null;
1578 } catch (error) {
1579 console.error("folios could not embed a query; matching words instead", String(error));
1580 return null;
1581 }
1582 }
1583
1584 /** Folio passages nearest the query, from these scopes (by the index's filter, or after). Empty without an index. */
1585 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 }[]> {
1586 const { embedder, store } = folioAdapters(this.env);
1587 const plan = vectorQueryPlan(ctx.workspace.id, scopes);
1588 if (!store || !plan) return [];
1589 const vector = await this.queryVector(query, embedder);
1590 if (!vector) return [];
1591 let matches: { id: string; score: number }[] = [];
1592 try {
1593 matches = await store.query(vector, { topK: plan.topK, filter: { workspace_id: ctx.workspace.id, ...(plan.filter.space_ids ? { scopes: plan.filter.space_ids } : {}) } });
1594 } catch (error) {
1595 console.error("folios semantic query failed; matching words instead", String(error));
1596 return [];
1597 }
1598 if (!matches.length) return [];
1599 const allowed = new Set(scopes);
1600 const rows = (
1601 await this.db
1602 .prepare("SELECT id, folio_id, kind, scope, heading, text FROM folio_chunks WHERE workspace_id = ? AND id IN (SELECT value FROM json_each(?))")
1603 .bind(
1604 ctx.workspace.id,
1605 json(matches.map((m) => m.id)),
1606 )
1607 .all<{ id: string; folio_id: string; kind: FolioKind; scope: string; heading: string | null; text: string }>()
1608 ).results;
1609 const byId = new Map(rows.map((r) => [r.id, r]));
1610 return matches
1611 .map((m) => ({ m, r: byId.get(m.id) }))
1612 .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)))
1613 .map(({ m, r }) => ({ id: r.id, folio_id: r.folio_id, heading: r.heading, text: r.text, score: m.score }));
1614 }
1615
1616 // ── History ─────────────────────────────────────────────────────────────
1617
1618 private async toVersions(workspace: Workspace, rows: Pick<VersionRow, "id" | "folio_id" | "created_at" | "kind" | "authors" | "note">[]): Promise<FolioVersion[]> {
1619 const authors = rows.map((r) => parseJson<string[]>(r.authors, []));
1620 const people = await this.who.profiles(workspace, authors.flat());
1621 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) }));
1622 }
1623
1624 async versions(a: Args & { folio_id: string }): Promise<Result<FolioVersion[]>> {
1625 const found = await this.ctx(a.workspace, a.viewer);
1626 if (!found.ok) return found;
1627 const ctx = found.value;
1628 const opened = await this.open(ctx, a.folio_id, "view");
1629 if (!opened.ok) return opened;
1630 await this.room(opened.value.row.id)
1631 .flush()
1632 .catch(() => undefined);
1633 const rows = (
1634 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>()
1635 ).results;
1636 return ok(await this.toVersions(ctx.workspace, rows));
1637 }
1638
1639 async version(a: Args & { folio_id: string; version_id: string }): Promise<Result<FolioVersionDetail>> {
1640 const found = await this.ctx(a.workspace, a.viewer);
1641 if (!found.ok) return found;
1642 const ctx = found.value;
1643 const opened = await this.open(ctx, a.folio_id, "view");
1644 if (!opened.ok) return opened;
1645 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>();
1646 if (!row) return fail("not_found", "No such version.");
1647 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 }>();
1648 const [version] = await this.toVersions(ctx.workspace, [row]);
1649 return ok({ ...version!, text: row.text, diff: diffLines(before?.text ?? "", row.text) });
1650 }
1651
1652 async restoreVersion(a: Args & { folio_id: string; version_id: string }): Promise<Result<FolioVersion>> {
1653 const found = await this.ctx(a.workspace, a.viewer);
1654 if (!found.ok) return found;
1655 const ctx = found.value;
1656 const opened = await this.open(ctx, a.folio_id, "edit");
1657 if (!opened.ok) return opened;
1658 const { row } = opened.value;
1659 const version = await this.db.prepare("SELECT * FROM folio_versions WHERE id = ? AND folio_id = ?").bind(String(a.version_id ?? ""), row.id).first<VersionRow>();
1660 if (!version) return fail("not_found", "No such version.");
1661 let state: Uint8Array | null = version.state ? new Uint8Array(version.state) : null;
1662 if (!state && version.state_key) {
1663 const stored = await fileStore(this.env)
1664 .get(version.state_key)
1665 .catch(() => null);
1666 if (stored) state = new Uint8Array(await new Response(stored.body).arrayBuffer());
1667 }
1668 const room = await this.ready(ctx.workspace, row);
1669 const when = new Date(version.created_at).toISOString().slice(0, 16).replace("T", " ");
1670 const origin: FolioOrigin = { key: ctx.key, kind: "restore", note: `Restored the version of ${when} UTC` };
1671 const versionId = await room.restore({ state, text: version.text }, origin);
1672 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;
1673 if (!created) return fail("conflict", "It could not be restored. Try again.");
1674 const [v] = await this.toVersions(ctx.workspace, [created]);
1675 this.tell(row.id, { type: "version.created", version: v! });
1676 return ok(v!);
1677 }
1678
1679 // ── Templates and export ────────────────────────────────────────────────
1680
1681 private async savedTemplate(workspace: Workspace, id: string): Promise<FolioTemplate | null> {
1682 const row = await this.db
1683 .prepare("SELECT * FROM folio_templates WHERE id = ? AND workspace_id = ?")
1684 .bind(id, workspace.id)
1685 .first<{ id: string; kind: FolioKind; name: string; description: string; icon: string | null; body: string; created_by: string }>();
1686 if (!row) return null;
1687 const by = (await this.who.profiles(workspace, [row.created_by])).get(row.created_by) ?? null;
1688 return { id: row.id, kind: row.kind, name: row.name, description: row.description, icon: row.icon, builtin: false, body: row.body, created_by: by };
1689 }
1690
1691 async templates(a: Args & { kind?: FolioKind | null }): Promise<Result<FolioTemplate[]>> {
1692 const found = await this.ctx(a.workspace, a.viewer);
1693 if (!found.ok) return found;
1694 const ctx = found.value;
1695 const kind = a.kind && isFolioKind(a.kind) ? a.kind : null;
1696 const rows = (
1697 await this.db
1698 .prepare(`SELECT * FROM folio_templates WHERE workspace_id = ? ${kind ? "AND kind = ?" : ""} ORDER BY name COLLATE NOCASE`)
1699 .bind(ctx.workspace.id, ...(kind ? [kind] : []))
1700 .all<{ id: string; kind: FolioKind; name: string; description: string; icon: string | null; body: string; created_by: string }>()
1701 ).results;
1702 const people = await this.who.profiles(
1703 ctx.workspace,
1704 rows.map((r) => r.created_by),
1705 );
1706 return ok([
1707 ...builtinFolioTemplates(kind),
1708 ...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 })),
1709 ]);
1710 }
1711
1712 async saveTemplate(a: Args & { input: { folio_id: string; name: string; description?: string | null } }): Promise<Result<FolioTemplate>> {
1713 const found = await this.ctx(a.workspace, a.viewer);
1714 if (!found.ok) return found;
1715 const ctx = found.value;
1716 const opened = await this.open(ctx, a.input?.folio_id, "view");
1717 if (!opened.ok) return opened;
1718 const { row } = opened.value;
1719 const name = cleanTitle(a.input.name || row.title, 80);
1720 if (!name) return fail("invalid", "Name the template.");
1721 const body = await (await this.ready(ctx.workspace, row)).text();
1722 const id = newId("tpl");
1723 const description = cleanTitle(a.input.description ?? "", 200);
1724 await this.db
1725 .prepare("INSERT INTO folio_templates (id, workspace_id, kind, name, description, icon, body, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
1726 .bind(id, ctx.workspace.id, row.kind, name, description, row.icon, body, ctx.key, now())
1727 .run();
1728 const me = (await this.who.profiles(ctx.workspace, [ctx.key])).get(ctx.key) ?? null;
1729 return ok({ id, kind: row.kind, name, description, icon: row.icon, builtin: false, body, created_by: me });
1730 }
1731
1732 async deleteTemplate(a: Args & { template_id: string }): Promise<Result<boolean>> {
1733 const found = await this.ctx(a.workspace, a.viewer);
1734 if (!found.ok) return found;
1735 const ctx = found.value;
1736 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 }>();
1737 if (!row) return fail("not_found", "No such template.");
1738 if (row.created_by !== ctx.key && !ctx.owner) return fail("forbidden", "Only whoever saved a template, or an owner, can delete it.");
1739 await this.db.prepare("DELETE FROM folio_templates WHERE id = ?").bind(a.template_id).run();
1740 return ok(true);
1741 }
1742
1743 async export(a: Args & { folio_id: string; format?: "markdown" | "json" | null }): Promise<Result<{ filename: string; content_type: string; body: string }>> {
1744 const found = await this.ctx(a.workspace, a.viewer);
1745 if (!found.ok) return found;
1746 const ctx = found.value;
1747 const opened = await this.open(ctx, a.folio_id, "view");
1748 if (!opened.ok) return opened;
1749 const { row } = opened.value;
1750 if (!kindModel(row.kind)) return fail("invalid", `${kindLabel(row.kind)} aren't here yet.`);
1751 const read = await (await this.ready(ctx.workspace, row)).read();
1752 const title = row.title || "Untitled";
1753 const base = title.replace(/[\\/:*?"<>|]+/g, " ").trim() || "artifact";
1754 const markdown = row.kind === "doc" || row.kind === "slides";
1755 if ((a.format ?? (markdown ? "markdown" : "json")) === "markdown" && markdown) {
1756 return ok({ filename: `${base}.md`, content_type: "text/markdown; charset=utf-8", body: `# ${title}\n\n${read.content}` });
1757 }
1758 return ok({ filename: `${base}.json`, content_type: "application/json", body: JSON.stringify({ kind: row.kind, title, content: read.content }, null, 2) });
1759 }
1760
1761 // ── Suggestions, proposals and comments ─────────────────────────────────
1762
1763 private async toSuggestions(workspace: Workspace, rows: SuggestionRow[], blocks: (string[] | null)[] = []): Promise<FolioSuggestion[]> {
1764 const people = await this.who.profiles(
1765 workspace,
1766 rows.flatMap((r) => [r.author, r.asked_by, r.decided_by].filter((k): k is string => !!k)),
1767 );
1768 return rows.map((r, i) => ({
1769 id: r.id,
1770 folio_id: r.folio_id,
1771 author: people.get(r.author)!,
1772 asked_by: r.asked_by ? (people.get(r.asked_by) ?? null) : null,
1773 target: parseJson<DocEditTarget>(r.target, { kind: "append" }),
1774 before_markdown: r.before_markdown,
1775 after_markdown: r.after_markdown,
1776 note: r.note,
1777 status: r.status,
1778 created_at: r.created_at,
1779 decided_by: r.decided_by ? (people.get(r.decided_by) ?? null) : null,
1780 decided_at: r.decided_at,
1781 block_ids: blocks[i] ?? [],
1782 }));
1783 }
1784
1785 private async openSuggestions(ctx: Ctx, row: FolioRow): Promise<FolioSuggestion[]> {
1786 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;
1787 if (!rows.length) return [];
1788 let blocks: (string[] | null)[] = rows.map(() => []);
1789 try {
1790 blocks = await (await this.ready(ctx.workspace, row)).targets(rows.map((r) => parseJson<DocEditTarget>(r.target, { kind: "append" })));
1791 } catch (error) {
1792 console.error("folios could not place suggestions", String(error));
1793 }
1794 const gone = rows.filter((_, i) => blocks[i] === null);
1795 if (gone.length) await this.db.batch(gone.map((r) => this.db.prepare("UPDATE folio_suggestions SET status = 'stale' WHERE id = ?").bind(r.id)));
1796 const live = rows.map((r, i) => ({ r, b: blocks[i] })).filter((x) => x.b !== null);
1797 return this.toSuggestions(
1798 ctx.workspace,
1799 live.map((x) => x.r),
1800 live.map((x) => x.b!),
1801 );
1802 }
1803
1804 /** Files a doc suggestion: someone who can comment. */
1805 private async fileSuggestion(
1806 ctx: Ctx,
1807 row: FolioRow,
1808 by: { author: string; asked_by: string | null },
1809 edit: { target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean },
1810 ): Promise<Result<FolioSuggestion>> {
1811 const room = await this.ready(ctx.workspace, row);
1812 const current = await room.target(edit.target);
1813 if (!current) return fail("not_found", "That part of the doc isn't there. Read it again and target what is there now.");
1814 const s: SuggestionRow = {
1815 id: newId("sug"),
1816 folio_id: row.id,
1817 author: by.author,
1818 asked_by: by.asked_by,
1819 target: JSON.stringify(edit.target),
1820 before_markdown: current.markdown,
1821 after_markdown: edit.markdown,
1822 note: edit.note,
1823 status: "open",
1824 created_at: now(),
1825 decided_by: null,
1826 decided_at: null,
1827 marks_current: edit.marks_current ? 1 : 0,
1828 };
1829 await this.db
1830 .prepare("INSERT INTO folio_suggestions (id, folio_id, author, asked_by, target, before_markdown, after_markdown, note, status, created_at, marks_current) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)")
1831 .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)
1832 .run();
1833 const [suggestion] = await this.toSuggestions(ctx.workspace, [s], [current.block_ids]);
1834 this.tell(row.id, { type: "suggestion.created", suggestion: suggestion! });
1835 this.defer(this.notifyOwnerOfSuggestion(ctx, row, suggestion!));
1836 return ok(suggestion!);
1837 }
1838
1839 private async notifyOwnerOfSuggestion(ctx: Ctx, row: FolioRow, suggestion: FolioSuggestion): Promise<void> {
1840 if (!this.env.NOTIFY || !row.owner.startsWith("user:") || row.owner === suggestion.author.kind + ":" + suggestion.author.id) return;
1841 const id = row.owner.slice(5);
1842 await notifyClient(this.env.NOTIFY)
1843 .notify(
1844 { user_id: id },
1845 {
1846 id: `folio-suggestion:${suggestion.id}:${id}`,
1847 kind: "inbox",
1848 workspace: ctx.workspace.slug,
1849 title: `${suggestion.author.display_name} suggested a change to ${row.title || "Untitled"}`,
1850 body: suggestion.note ?? excerpt(suggestion.after_markdown, 140),
1851 href: this.ref(ctx.workspace.slug, row).path,
1852 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 },
1853 created_at: suggestion.created_at,
1854 },
1855 )
1856 .catch(() => undefined);
1857 }
1858
1859 async suggestions(a: Args & { folio_id: string }): Promise<Result<FolioSuggestion[]>> {
1860 const found = await this.ctx(a.workspace, a.viewer);
1861 if (!found.ok) return found;
1862 const ctx = found.value;
1863 const opened = await this.open(ctx, a.folio_id, "view");
1864 if (!opened.ok) return opened;
1865 if (opened.value.row.kind !== "doc") return ok([]);
1866 return ok(await this.openSuggestions(ctx, opened.value.row));
1867 }
1868
1869 async decideSuggestion(a: Args & { suggestion_id: string; decision: "accept" | "reject" }): Promise<Result<FolioSuggestion>> {
1870 const s = await this.db.prepare("SELECT * FROM folio_suggestions WHERE id = ?").bind(String(a.suggestion_id ?? "")).first<SuggestionRow>();
1871 if (!s) return fail("not_found", "No such suggestion.");
1872 const found = await this.ctx(a.workspace, a.viewer);
1873 if (!found.ok) return found;
1874 const ctx = found.value;
1875 const opened = await this.open(ctx, s.folio_id, "edit");
1876 if (!opened.ok) return opened.error.code === "forbidden" ? fail("forbidden", "Only people who can edit it can accept or reject a suggestion.") : opened;
1877 const { row } = opened.value;
1878 if (s.status !== "open") return fail("conflict", "That suggestion was already decided.");
1879 let status: DocSuggestion["status"] = a.decision === "accept" ? "accepted" : "rejected";
1880 if (a.decision === "accept") {
1881 const people = await this.who.profiles(ctx.workspace, [s.author, ctx.key]);
1882 const room = await this.ready(ctx.workspace, row);
1883 const result = await room.edit(
1884 { kind: "doc", target: parseJson<DocEditTarget>(s.target, { kind: "append" }), markdown: s.after_markdown },
1885 { 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] },
1886 );
1887 if (!result.applied) status = "stale";
1888 else if (s.marks_current) await this.clearStale(row.id, s.author);
1889 }
1890 const at = now();
1891 await this.db.prepare("UPDATE folio_suggestions SET status = ?, decided_by = ?, decided_at = ? WHERE id = ?").bind(status, ctx.key, at, s.id).run();
1892 const [after] = await this.toSuggestions(ctx.workspace, [{ ...s, status, decided_by: ctx.key, decided_at: at }]);
1893 this.tell(row.id, { type: "suggestion.updated", suggestion: after! });
1894 if (status === "stale") return fail("conflict", "The part this suggestion changes is gone, so it can't be applied.");
1895 return ok(after!);
1896 }
1897
1898 async proposals(a: Args & { folio_id: string }): Promise<Result<FolioProposal[]>> {
1899 const found = await this.ctx(a.workspace, a.viewer);
1900 if (!found.ok) return found;
1901 const ctx = found.value;
1902 const opened = await this.open(ctx, a.folio_id, "view");
1903 if (!opened.ok) return opened;
1904 const rows = (
1905 await this.db
1906 .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")
1907 .bind(opened.value.row.id)
1908 .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 }>()
1909 ).results;
1910 const people = await this.who.profiles(
1911 ctx.workspace,
1912 rows.flatMap((r) => [r.author, r.asked_by, r.decided_by].filter((k): k is string => !!k)),
1913 );
1914 return ok(
1915 rows.map((r) => ({
1916 id: r.id,
1917 folio_id: r.folio_id,
1918 author: people.get(r.author)!,
1919 asked_by: r.asked_by ? (people.get(r.asked_by) ?? null) : null,
1920 note: r.note,
1921 summary: r.summary,
1922 status: r.status,
1923 created_at: r.created_at,
1924 decided_by: r.decided_by ? (people.get(r.decided_by) ?? null) : null,
1925 decided_at: r.decided_at,
1926 })),
1927 );
1928 }
1929
1930 async decideProposal(a: Args & { proposal_id: string; decision: "accept" | "reject" }): Promise<Result<FolioProposal>> {
1931 const found = await this.ctx(a.workspace, a.viewer);
1932 if (!found.ok) return found;
1933 const row = await this.db.prepare("SELECT folio_id FROM folio_proposals WHERE id = ?").bind(String(a.proposal_id ?? "")).first<{ folio_id: string }>();
1934 if (!row) return fail("not_found", "No such proposal.");
1935 const opened = await this.open(found.value, row.folio_id, "edit");
1936 if (!opened.ok) return opened;
1937 // Proposals arrive with slides, designs and dashboards (Phases 4 to 6).
1938 return fail("invalid", "Proposals can't be applied yet.");
1939 }
1940
1941 async thread(a: Args & { folio_id: string; action: DocThreadAction }): Promise<Result<unknown>> {
1942 const found = await this.ctx(a.workspace, a.viewer);
1943 if (!found.ok) return found;
1944 const ctx = found.value;
1945 const opened = await this.open(ctx, a.folio_id, "comment");
1946 if (!opened.ok) return opened;
1947 const { row, role } = opened.value;
1948 const room = await this.ready(ctx.workspace, row);
1949 const result = (await room.thread(ctx.key, role, a.action)) as ThreadResult;
1950 if (!result.ok) return fail(result.code, result.message);
1951 if (result.mentions?.length) {
1952 const names = result.mentions.filter((k) => k.startsWith("user:")).map((k) => k.slice(5).toLowerCase());
1953 this.defer(this.notifyMentioned(ctx.workspace, row, names, ctx.key, result.text ?? "", result.thread_id ?? null));
1954 }
1955 return ok(result.value);
1956 }
1957
1958 async threads(a: Args & { folio_id: string }): Promise<Result<DocThread[]>> {
1959 const found = await this.ctx(a.workspace, a.viewer);
1960 if (!found.ok) return found;
1961 const ctx = found.value;
1962 const opened = await this.open(ctx, a.folio_id, "view");
1963 if (!opened.ok) return opened;
1964 const threads = await (await this.ready(ctx.workspace, opened.value.row)).threads();
1965 const people = await this.who.profiles(
1966 ctx.workspace,
1967 threads.flatMap((t) => t.comments.map((c) => c.author)),
1968 );
1969 return ok(threads.map((t) => ({ ...t, comments: t.comments.map((c) => ({ ...c, author: people.get(c.author)! })) })));
1970 }
1971
1972 /**
1973 * People mentioned (by username) in a folio or a comment on it hear of
1974 * it, only when they can read it (leak rule 4); never the person who
1975 * wrote it. Agents hear of mentions only through their asker.
1976 */
1977 async notifyMentioned(workspace: Workspace, row: FolioRow, usernames: string[], author: string | null, text: string, threadId: string | null): Promise<void> {
1978 if (!this.env.NOTIFY) return;
1979 const authorName = author?.startsWith("user:") ? ((await this.who.profiles(workspace, [author])).get(author)?.name ?? "").toLowerCase() : "";
1980 const names = [...new Set(usernames.map((n) => n.toLowerCase()))].filter((n) => n && n !== authorName).slice(0, 50);
1981 if (!names.length) return;
1982 const who = author ? (await this.who.profiles(workspace, [author])).get(author) : null;
1983 const href = `${this.ref(workspace.slug, row).path}${threadId ? `?thread=${encodeURIComponent(threadId)}` : ""}`;
1984 const notify = notifyClient(this.env.NOTIFY);
1985 const identity = identityClient(this.env.IDENTITY);
1986 for (const username of names) {
1987 const user = await identity.userByUsername(username).catch(() => null);
1988 if (!user) continue;
1989 const role = await this.roleOfPerson(workspace, row, userKey(user), username);
1990 if (!role) continue;
1991 await notify
1992 .notify(
1993 { username },
1994 {
1995 id: threadId ? `folio-comment:${row.id}:${threadId}:${username}:${Date.now()}` : `folio-mention:${row.id}:${username}`,
1996 kind: "mention",
1997 workspace: workspace.slug,
1998 title: who ? `${who.display_name} mentioned you in ${row.title || "Untitled"}` : `You were mentioned in ${row.title || "Untitled"}`,
1999 body: excerpt(text, 140),
2000 href,
2001 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 },
2002 created_at: now(),
2003 },
2004 )
2005 .catch(() => undefined);
2006 }
2007 }
2008
2009 // ── Dashboards (Phase 5b) ───────────────────────────────────────────────
2010
2011 async queryTile(a: Args & { folio_id: string; tile_id: string }): Promise<Result<never>> {
2012 const found = await this.ctx(a.workspace, a.viewer);
2013 if (!found.ok) return found;
2014 const opened = await this.open(found.value, a.folio_id, "view");
2015 if (!opened.ok) return opened;
2016 return fail("invalid", "Dashboards aren't here yet.");
2017 }
2018
2019 async queryDataset(a: Args & { query: unknown }): Promise<Result<never>> {
2020 const found = await this.ctx(a.workspace, a.viewer);
2021 if (!found.ok) return found;
2022 return fail("invalid", "Dashboards aren't here yet.");
2023 }
2024
2025 // ── Staleness ───────────────────────────────────────────────────────────
2026
2027 private async clearStale(folioId: string, by: string): Promise<boolean> {
2028 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();
2029 const cleared = (done.meta?.changes ?? 0) > 0;
2030 if (cleared) this.tell(folioId, { type: "folio.staleness" });
2031 return cleared;
2032 }
2033
2034 async markCurrent(a: Args & { folio_id: string }): Promise<Result<boolean>> {
2035 const found = await this.ctx(a.workspace, a.viewer);
2036 if (!found.ok) return found;
2037 const opened = await this.open(found.value, a.folio_id, "edit");
2038 if (!opened.ok) return opened;
2039 await this.clearStale(opened.value.row.id, found.value.key);
2040 return ok(true);
2041 }
2042
2043 async reindex(a: Args): Promise<Result<boolean>> {
2044 const found = await this.ctx(a.workspace, a.viewer);
2045 if (!found.ok) return found;
2046 if (!found.value.owner) return fail("forbidden", "Only an owner can index the workspace's artifacts again.");
2047 return ok(await startBackfill(this.env, found.value.workspace.id, { force: true }));
2048 }
2049
2050 // ── Projects' docs ──────────────────────────────────────────────────────
2051
2052 private async readableRepoSpaces(workspace: Workspace, viewer: User): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
2053 const rows = (await this.db.prepare("SELECT * FROM repo_spaces WHERE workspace_id = ? ORDER BY repo").bind(workspace.id).all<RepoSpaceRow>()).results;
2054 if (!rows.length || !this.env.REPOS) return [];
2055 const readable = await reposClient(this.env.REPOS).readable(
2056 rows.map((r) => r.repo_id),
2057 viewer,
2058 );
2059 const byId = new Map(readable.map((r) => [r.id, r]));
2060 return rows.filter((r) => byId.has(r.repo_id)).map((row) => ({ row, repo: byId.get(row.repo_id)! }));
2061 }
2062
2063 private async repoSpacesFor(ctx: Ctx): Promise<DocRepoSpace[]> {
2064 const found = await this.readableRepoSpaces(ctx.workspace, ctx.viewer);
2065 if (!found.length) return [];
2066 const [files, people] = await Promise.all([
2067 this.db
2068 .prepare("SELECT space_id, path, title FROM repo_files WHERE space_id IN (SELECT value FROM json_each(?))")
2069 .bind(json(found.map((f) => f.row.id)))
2070 .all<{ space_id: string; path: string; title: string }>(),
2071 this.who.profiles(
2072 ctx.workspace,
2073 found.map((f) => f.row.added_by),
2074 ),
2075 ]);
2076 const readme = (path: string) => (/^readme\./i.test(path) ? 0 : 1);
2077 return found.map(({ row, repo }) => ({
2078 id: row.id,
2079 repo: `${repo.namespace}/${repo.name}`,
2080 default_branch: repo.defaultBranch,
2081 commit: row.commit_sha,
2082 indexed_at: row.indexed_at,
2083 added_by: people.get(row.added_by)!,
2084 files: files.results
2085 .filter((f) => f.space_id === row.id)
2086 .sort((a, b) => readme(a.path) - readme(b.path) || a.path.localeCompare(b.path))
2087 .map((f) => ({ path: f.path, title: f.title })),
2088 can_remove: row.added_by === ctx.key || ctx.owner,
2089 }));
2090 }
2091
2092 // ── Sockets and files ───────────────────────────────────────────────────
2093
2094 private viewerFrom(request: Request): Viewer {
2095 try {
2096 return JSON.parse(request.headers.get(DOCS_VIEWER_HEADER) ?? "null") as Viewer;
2097 } catch {
2098 return null;
2099 }
2100 }
2101
2102 /**
2103 * `GET /live?workspace=<slug>&folio=<id>`, upgraded to a WebSocket. The
2104 * viewer comes in DOCS_VIEWER_HEADER, set by the site after checking
2105 * the session; trusted only because this Worker is reachable through
2106 * service bindings alone. Checked like any read (opening counts for a
2107 * link folio), then handed to the room with the viewer's role.
2108 */
2109 async live(request: Request): Promise<Response> {
2110 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("Expected a WebSocket upgrade\n", { status: 426 });
2111 const viewer = this.viewerFrom(request);
2112 if (!viewer?.id) return new Response("Sign in to use Artifacts\n", { status: 401 });
2113 const url = new URL(request.url);
2114 const found = await this.ctx((url.searchParams.get("workspace") ?? "").toLowerCase(), viewer);
2115 if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 });
2116 const ctx = found.value;
2117 const opened = await this.open(ctx, url.searchParams.get("folio") ?? "", "view", { trashed: true, opening: true });
2118 if (!opened.ok) return new Response(`${opened.error.message}\n`, { status: opened.error.code === "forbidden" ? 403 : 404 });
2119 const { row, role } = opened.value;
2120 if (row.trashed_at) return new Response("That artifact is in the trash\n", { status: 410 });
2121 if (!kindModel(row.kind)) return new Response("That kind of artifact isn't here yet\n", { status: 409 });
2122 // The room (one call, when it has the document) and who this is (identity) together.
2123 const [room, member] = await Promise.all([this.ready(ctx.workspace, row), this.who.profiles(ctx.workspace, [ctx.key]).then((people) => people.get(ctx.key)!)]);
2124 const headers = new Headers(request.headers);
2125 headers.delete(DOCS_VIEWER_HEADER);
2126 headers.set(ROOM_MEMBER_HEADER, JSON.stringify({ folio_id: row.id, workspace_slug: ctx.workspace.slug, key: ctx.key, member, role }));
2127 return room.fetch(new Request(request.url, { method: "GET", headers }));
2128 }
2129
2130 /** `PUT /files?workspace=&folio=&name=`: a file for a folio, from someone who can edit it. */
2131 async upload(request: Request): Promise<Response> {
2132 const viewer = this.viewerFrom(request);
2133 const url = new URL(request.url);
2134 const found = await this.ctx((url.searchParams.get("workspace") ?? "").toLowerCase(), viewer);
2135 if (!found.ok) return Response.json(found);
2136 const ctx = found.value;
2137 const opened = await this.open(ctx, url.searchParams.get("folio") ?? "", "edit");
2138 if (!opened.ok) return Response.json(opened);
2139 const bytes = Number(request.headers.get("content-length") ?? "0");
2140 if (!bytes || bytes > DOC_MAX_FILE_BYTES) return Response.json(fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`));
2141 const refused = await uploadRefused(this.db, this.env, ctx.workspace, bytes);
2142 if (refused) return Response.json(fail("limit", refused));
2143 const name = safeName(url.searchParams.get("name") ?? "file");
2144 const contentType = servedType(request.headers.get("content-type") ?? "");
2145 const key = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
2146 const id = newId("fil");
2147 await fileStore(this.env).put(`docs/${key}`, request.body ?? new Uint8Array(), contentType);
2148 try {
2149 const inserted = await this.db
2150 .prepare("INSERT INTO folio_files (id, workspace_id, folio_id, key, name, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
2151 .bind(id, ctx.workspace.id, opened.value.row.id, key, name, contentType, bytes, ctx.key, now())
2152 .run();
2153 if (Number(inserted.meta.changes) === 0) {
2154 await fileStore(this.env).delete(`docs/${key}`);
2155 return new Response("Upload destination erased", { status: 410 });
2156 }
2157 } catch (error) {
2158 await fileStore(this.env).delete(`docs/${key}`);
2159 throw error;
2160 }
2161 return Response.json(ok({ id, url: `/docs-files/${key}`, name, content_type: contentType, bytes }));
2162 }
2163
2164 /** `GET /files/<key>` for a folio's file, or null when the key isn't a folio's (then Docs' pages are asked). */
2165 async file(key: string): Promise<Response | null> {
2166 const row = await this.db.prepare("SELECT name, content_type FROM folio_files WHERE key = ?").bind(key).first<{ name: string; content_type: string }>();
2167 if (!row) return null;
2168 const stored = await fileStore(this.env).get(`docs/${key}`);
2169 if (!stored) return new Response("Not found\n", { status: 404 });
2170 const inline = row.content_type !== "application/octet-stream";
2171 return new Response(stored.body, {
2172 headers: {
2173 "content-type": row.content_type,
2174 "content-length": String(stored.bytes),
2175 etag: stored.etag,
2176 "content-disposition": `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(row.name)}`,
2177 "cache-control": "private, max-age=31536000, immutable",
2178 },
2179 });
2180 }
2181}
2182
2183/** A folio's room found people newly mentioned in it: those who can read it hear of it. */
2184export async function notifyFolioMentions(env: FoliosEnv, slug: string, folioId: string, usernames: string[], last: string | null): Promise<void> {
2185 const service = new Folios(env);
2186 const workspace = await service.who.workspace(slug);
2187 if (!workspace) return;
2188 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>();
2189 if (!row || row.trashed_at) return;
2190 await service.notifyMentioned(workspace, row, usernames, last, row.text, null);
2191}
2192
2193/** Trashed folios this long ago are deleted for good by the daily cron. */
2194export const TRASH_DAYS = 30;
2195
2196/**
2197 * The daily cron: folios in the trash for over TRASH_DAYS are deleted for
2198 * good (`eraseFolios`, as emptying the trash does), at most 500 a run
2199 * (the rest go the next day).
2200 */
2201export async function purgeTrash(env: FoliosEnv, at = new Date()): Promise<number> {
2202 const cutoff = new Date(at.getTime() - TRASH_DAYS * 24 * 60 * 60 * 1000).toISOString();
2203 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;
2204 if (!rows.length) return 0;
2205 const ids = await eraseFolios(env, rows);
2206 if (env.FOLIOS) for (const id of ids) await env.FOLIOS.get(env.FOLIOS.idFromName(id)).destroy().catch(() => undefined);
2207 return ids.length;
2208}
2209
2210/** The `folios.reacl` job: a large subtree's access, rooms and index brought up to date. */
2211export async function runReacl(env: FoliosEnv, folioId: string): Promise<void> {
2212 const service = new Folios(env);
2213 const ids = await rebuildSubtree(env.DB, folioId);
2214 await service.followAccess(null, ids);
2215}