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.

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