Skip to content

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

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