Skip to content

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

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