Skip to content

Commit

The docs service answers every artifacts call: docs can be made, listed, shared, moved, trashed, restored, searched, versioned and edited live in their own rooms, agents read, write and recall them only where their person and everyone in the conversation can, and folio events go out on the bus, while Docs' pages keep working as before.

syntaqxcommitted Parent88fca2dBrowse files
26 files+4850−300/26 viewed
+3−2
9898
9999 /**
100100 * Services whose cron triggers scheduler.mjs runs here: sweeps and
101− * reminders that need nothing self-hosting lacks. Not run: actions (its
101+ * reminders that need nothing self-hosting lacks (the docs service's is
102+ * emptying artifacts' trash after 30 days). Not run: actions (its
102103 * minute would start scheduled workflows with no runner to take them),
103104 * billing (reconciles against Cloudflare and Stripe), deployments (calls
104105 * Cloudflare's API) and the services that are off.
105106 */
106−const SELF_HOST_CRONS = new Set(["g1t-repos", "g1t-events", "g1t-identity", "g1t-security", "g1t-webhooks", "g1t-packages"]);
107+const SELF_HOST_CRONS = new Set(["g1t-repos", "g1t-events", "g1t-identity", "g1t-security", "g1t-webhooks", "g1t-packages", "g1t-docs-service"]);
107108
108109 /** Queues whose consumers are off: events stops sending to them. */
109110 const OFF_QUEUES = new Set(["g1t-events-runner", "g1t-events-context"]);
+1−1
55 "type": "module",
66 "license": "MIT",
77 "scripts": {
8− "test": "node --test src/*.test.ts",
8+ "test": "node --test \"src/**/*.test.ts\"",
99 "typecheck": "wrangler types --include-env=false && tsc -p tsconfig.json",
1010 "deploy": "wrangler deploy"
1111 },
+3−3
288288 return `folio:${root.id}`;
289289 }
290290
291−export type FolioAccessRow = { folio_id: string; principal: string; role: DocRole; via: string; since: string };
291+export type FolioAccessRecord = { folio_id: string; principal: string; role: DocRole; via: string; since: string };
292292
293293 /** The rows of `folio_access` for these folios: everything `explicitAccess` finds along each one's chain. */
294−export function materialize(ids: readonly string[], byId: ReadonlyMap<string, FolioAclNode>, grants: FolioGrants): FolioAccessRow[] {
295− const out: FolioAccessRow[] = [];
294+export function materialize(ids: readonly string[], byId: ReadonlyMap<string, FolioAclNode>, grants: FolioGrants): FolioAccessRecord[] {
295+ const out: FolioAccessRecord[] = [];
296296 for (const id of ids) {
297297 const chain = aclChain(id, byId);
298298 if (!chain.length) continue;
+275−0
1+/**
2+ * Folio access in D1: reading a folio's chain (itself and the ancestors
3+ * its access comes from) and grants, keeping each subtree's denormalized
4+ * `acl_root` and `path` right, and rebuilding the materialized
5+ * `folio_access` rows the lists and search filter by. The rules
6+ * themselves are pure, in src/access.ts.
7+ */
8+import type { DocRole, FolioGeneralAccess } from "@g1t/contracts";
9+
10+import { aclChain, aclRootOf, effectiveRole, folioPathOf, folioReadableByWorkspace, folioScope, materialize, type FolioAclNode, type FolioGrant, type Person } from "../access.ts";
11+
12+/** A folio row as stored. `text` is left out of lists (see FOLIO_COLUMNS). */
13+export type FolioRow = {
14+ id: string;
15+ workspace_id: string;
16+ kind: "doc" | "slides" | "design" | "dashboard";
17+ title: string;
18+ icon: string | null;
19+ cover: string | null;
20+ owner: string;
21+ space_id: string | null;
22+ parent_id: string | null;
23+ position: number;
24+ inherit: number;
25+ acl_root: string;
26+ path: string;
27+ general_access: FolioGeneralAccess;
28+ general_role: DocRole | null;
29+ agent_mode: "suggest" | "edit" | null;
30+ text: string;
31+ excerpt: string;
32+ preview: string | null;
33+ source: string | null;
34+ mentioned: string;
35+ created_by: string;
36+ created_at: string;
37+ updated_by: string | null;
38+ updated_at: string;
39+ edited_by: string | null;
40+ edited_at: string;
41+ trashed_at: string | null;
42+ trashed_by: string | null;
43+};
44+
45+/** Every column but the text, as lists read them. */
46+export const FOLIO_COLUMNS =
47+ "id, workspace_id, kind, title, icon, cover, owner, space_id, parent_id, position, inherit, acl_root, path, general_access, general_role, agent_mode, '' AS text, excerpt, preview, source, mentioned, created_by, created_at, updated_by, updated_at, edited_by, edited_at, trashed_at, trashed_by";
48+
49+/** The same, prefixed by a table alias. */
50+export function folioColumns(alias: string): string {
51+ return FOLIO_COLUMNS.split(", ")
52+ .map((c) => (c.startsWith("'") ? c : `${alias}.${c}`))
53+ .join(", ");
54+}
55+
56+/** D1 binds at most 100 parameters; lists go in as one JSON parameter instead. */
57+export const json = (values: readonly string[]) => JSON.stringify([...new Set(values)]);
58+
59+/** How many statements one batch carries. */
60+const BATCH = 80;
61+
62+export async function runBatches(db: D1Database, statements: D1PreparedStatement[]): Promise<void> {
63+ for (let i = 0; i < statements.length; i += BATCH) await db.batch(statements.slice(i, i + BATCH));
64+}
65+
66+export function aclNode(row: Pick<FolioRow, "id" | "owner" | "parent_id" | "space_id" | "inherit" | "general_access" | "general_role" | "created_at">): FolioAclNode {
67+ return {
68+ id: row.id,
69+ owner: row.owner,
70+ parent_id: row.parent_id,
71+ space_id: row.space_id,
72+ inherit: !!row.inherit,
73+ general_access: row.general_access,
74+ general_role: row.general_role,
75+ created_at: row.created_at,
76+ };
77+}
78+
79+/** The ids in a path, top first. */
80+export function pathIds(path: string): string[] {
81+ return String(path ?? "")
82+ .split("/")
83+ .filter(Boolean);
84+}
85+
86+/** Folio rows by id (any workspace's; callers check), without their text. */
87+export async function foliosById(db: D1Database, ids: readonly string[]): Promise<Map<string, FolioRow>> {
88+ const out = new Map<string, FolioRow>();
89+ const unique = [...new Set(ids)];
90+ for (let i = 0; i < unique.length; i += 500) {
91+ const rows = await db
92+ .prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE id IN (SELECT value FROM json_each(?))`)
93+ .bind(json(unique.slice(i, i + 500)))
94+ .all<FolioRow>();
95+ for (const r of rows.results) out.set(r.id, r);
96+ }
97+ return out;
98+}
99+
100+/** Grants on these folios, by folio. */
101+export async function grantsOf(db: D1Database, ids: readonly string[]): Promise<Map<string, FolioGrant[]>> {
102+ const out = new Map<string, FolioGrant[]>();
103+ const unique = [...new Set(ids)];
104+ for (let i = 0; i < unique.length; i += 500) {
105+ const rows = await db
106+ .prepare("SELECT folio_id, principal, role, granted_at FROM folio_grants WHERE folio_id IN (SELECT value FROM json_each(?))")
107+ .bind(json(unique.slice(i, i + 500)))
108+ .all<{ folio_id: string; principal: string; role: DocRole; granted_at: string }>();
109+ for (const r of rows.results) out.set(r.folio_id, [...(out.get(r.folio_id) ?? []), { principal: r.principal, role: r.role, granted_at: r.granted_at }]);
110+ }
111+ return out;
112+}
113+
114+/** Everything access needs for these folios: them and their ancestors as nodes, and every grant on them. */
115+export type Ancestry = { rows: Map<string, FolioRow>; nodes: Map<string, FolioAclNode>; grants: Map<string, FolioGrant[]> };
116+
117+export async function ancestry(db: D1Database, rows: readonly FolioRow[]): Promise<Ancestry> {
118+ const all = new Map(rows.map((r) => [r.id, r]));
119+ const missing = [...new Set(rows.flatMap((r) => pathIds(r.path)))].filter((id) => !all.has(id));
120+ if (missing.length) for (const [id, row] of await foliosById(db, missing)) all.set(id, row);
121+ const grants = await grantsOf(db, [...all.keys()]);
122+ return { rows: all, nodes: new Map([...all.values()].map((r) => [r.id, aclNode(r)])), grants };
123+}
124+
125+/** What a reader's role depends on beyond the folio: their space roles and the links they opened. */
126+export type ReaderContext = {
127+ person: Person;
128+ /** Their role in each space (null: none). */
129+ spaceRole: (spaceId: string) => DocRole | null;
130+ /** Folio ids they opened. */
131+ visits: ReadonlySet<string>;
132+};
133+
134+/** Of these folios, the ones whose link the person opened (or whose access root's). */
135+export async function visitsOf(db: D1Database, userId: string, rows: readonly Pick<FolioRow, "id" | "acl_root">[]): Promise<Set<string>> {
136+ const ids = [...new Set(rows.flatMap((r) => [r.id, r.acl_root]))];
137+ if (!ids.length) return new Set();
138+ const found = await db
139+ .prepare("SELECT folio_id FROM folio_visits WHERE user_id = ? AND folio_id IN (SELECT value FROM json_each(?))")
140+ .bind(userId, json(ids))
141+ .all<{ folio_id: string }>();
142+ return new Set(found.results.map((r) => r.folio_id));
143+}
144+
145+/** A reader's role on each folio, from the full chain (defence in depth behind the list SQL). */
146+export function rolesFrom(found: Ancestry, rows: readonly FolioRow[], reader: ReaderContext): Map<string, DocRole | null> {
147+ const out = new Map<string, DocRole | null>();
148+ for (const row of rows) {
149+ const chain = aclChain(row.id, found.nodes);
150+ const root = chain[chain.length - 1];
151+ const visited = reader.visits.has(row.id) || (!!root && reader.visits.has(root.id));
152+ out.set(row.id, effectiveRole(chain, found.grants, root?.space_id ? reader.spaceRole(root.space_id) : null, reader.person, { visited }));
153+ }
154+ return out;
155+}
156+
157+/**
158+ * The list filter (plan section 2.3): a folio is readable when one of the
159+ * reader's keys has a `folio_access` row on it; or its access root is at
160+ * the top of a space they can read and inherits it; or its access root
161+ * is open to the workspace; or it is a link folio they opened. `f` is the
162+ * folio and `r` its access root (`JOIN folios r ON r.id = f.acl_root`).
163+ */
164+export function readableWhere(keys: readonly string[], spaceIds: readonly string[], userId: string): { sql: string; binds: unknown[] } {
165+ return {
166+ sql: `(f.id IN (SELECT folio_id FROM folio_access WHERE principal IN (SELECT value FROM json_each(?)))
167+ OR (r.parent_id IS NULL AND r.inherit = 1 AND r.space_id IN (SELECT value FROM json_each(?)))
168+ OR r.general_access = 'workspace'
169+ 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))))`,
170+ binds: [json(keys), json(spaceIds), userId],
171+ };
172+}
173+
174+/** Whether every member of the workspace can read a folio (events and the inbox carry its title only then). */
175+export async function workspaceReadable(db: D1Database, folioId: string): Promise<boolean> {
176+ const row = (await foliosById(db, [folioId])).get(folioId);
177+ if (!row) return false;
178+ const found = await ancestry(db, [row]);
179+ const chain = aclChain(row.id, found.nodes);
180+ const root = chain[chain.length - 1];
181+ let space = null;
182+ if (root?.space_id) {
183+ const s = await db.prepare("SELECT kind, team, default_role FROM spaces WHERE id = ?").bind(root.space_id).first<{ kind: "workspace" | "team" | "private"; team: string | null; default_role: DocRole | null }>();
184+ if (s) space = { ...s, members: [] };
185+ }
186+ return folioReadableByWorkspace(chain, space);
187+}
188+
189+/** The index scope of each of these folios (src/access.ts `folioScope`). */
190+export async function scopesOf(db: D1Database, rows: readonly FolioRow[]): Promise<Map<string, string>> {
191+ const found = await ancestry(db, rows);
192+ return new Map(rows.map((r) => [r.id, folioScope(aclChain(r.id, found.nodes), found.grants)]));
193+}
194+
195+/**
196+ * A subtree: the folio and everything under it, by its path. (A prefix
197+ * compare, not LIKE: D1 refuses LIKE patterns over 50 bytes, which a
198+ * path two deep already is.)
199+ */
200+export async function subtree(db: D1Database, root: Pick<FolioRow, "path" | "workspace_id">): Promise<FolioRow[]> {
201+ return (
202+ await db
203+ .prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE workspace_id = ? AND substr(path, 1, ?) = ? ORDER BY length(path)`)
204+ .bind(root.workspace_id, root.path.length, root.path)
205+ .all<FolioRow>()
206+ ).results;
207+}
208+
209+/**
210+ * Brings a subtree up to date after a move, a restriction, a grant or an
211+ * ownership change: each folio's space (its top's), `acl_root` and `path`
212+ * from its parent down, then its `folio_access` rows. `rootId`'s own
213+ * parent (and space, when it has a parent) must already be set. Returns
214+ * the subtree's ids, top first.
215+ */
216+export async function rebuildSubtree(db: D1Database, rootId: string): Promise<string[]> {
217+ const root = (await foliosById(db, [rootId])).get(rootId);
218+ if (!root) return [];
219+ const below = await subtree(db, root);
220+ const parent = root.parent_id ? ((await foliosById(db, [root.parent_id])).get(root.parent_id) ?? null) : null;
221+ // Top down: each child's place follows its parent's new one.
222+ const next = new Map<string, { space_id: string | null; acl_root: string; path: string }>();
223+ const placeOf = (row: FolioRow, up: { space_id: string | null; acl_root: string; path: string } | null) => ({
224+ space_id: up ? up.space_id : row.space_id,
225+ acl_root: aclRootOf({ id: row.id, inherit: !!row.inherit, parent_id: row.parent_id }, up?.acl_root ?? null),
226+ path: folioPathOf(row.id, up?.path ?? null),
227+ });
228+ const byParent = new Map<string, FolioRow[]>();
229+ for (const r of below) if (r.id !== root.id && r.parent_id) byParent.set(r.parent_id, [...(byParent.get(r.parent_id) ?? []), r]);
230+ const queue: FolioRow[] = [root];
231+ next.set(root.id, placeOf(root, parent ? { space_id: parent.space_id, acl_root: parent.acl_root, path: parent.path } : null));
232+ for (let i = 0; i < queue.length; i++) {
233+ const at = queue[i]!;
234+ for (const child of byParent.get(at.id) ?? []) {
235+ next.set(child.id, placeOf(child, next.get(at.id)!));
236+ queue.push(child);
237+ }
238+ }
239+ const ids = queue.map((r) => r.id);
240+ const statements: D1PreparedStatement[] = [];
241+ for (const row of queue) {
242+ const place = next.get(row.id)!;
243+ if (place.space_id !== row.space_id || place.acl_root !== row.acl_root || place.path !== row.path) {
244+ statements.push(db.prepare("UPDATE folios SET space_id = ?, acl_root = ?, path = ? WHERE id = ?").bind(place.space_id, place.acl_root, place.path, row.id));
245+ Object.assign(row, place);
246+ }
247+ }
248+ await runBatches(db, statements);
249+ await rematerialize(db, queue);
250+ return ids;
251+}
252+
253+/** Rewrites `folio_access` for these folios (their places already right). */
254+export async function rematerialize(db: D1Database, rows: FolioRow[]): Promise<void> {
255+ if (!rows.length) return;
256+ const found = await ancestry(db, rows);
257+ // The rows given win over what was read: they hold the places just written.
258+ for (const r of rows) {
259+ found.rows.set(r.id, r);
260+ found.nodes.set(r.id, aclNode(r));
261+ }
262+ const access = materialize(
263+ rows.map((r) => r.id),
264+ found.nodes,
265+ found.grants,
266+ );
267+ const statements: D1PreparedStatement[] = [];
268+ for (let i = 0; i < rows.length; i += 500) {
269+ statements.push(db.prepare("DELETE FROM folio_access WHERE folio_id IN (SELECT value FROM json_each(?))").bind(json(rows.slice(i, i + 500).map((r) => r.id))));
270+ }
271+ for (const a of access) {
272+ statements.push(db.prepare("INSERT OR REPLACE INTO folio_access (folio_id, principal, role, via, since) VALUES (?, ?, ?, ?, ?)").bind(a.folio_id, a.principal, a.role, a.via, a.since));
273+ }
274+ await runBatches(db, statements);
275+}
+96−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { aclChain, type FolioAclNode, type FolioGrant, type Person, type SpaceRules } from "../access.ts";
5+import { agentMayFind, agentReach, audienceRule, type AudienceRule } from "./agents.ts";
6+
7+// The leak rules of docs/ARTIFACTS_MODE.md section 4.3.
8+
9+const ana: Person = { user_id: "ana", owner: false, teams: new Set(["web"]) };
10+const bo: Person = { user_id: "bo", owner: false, teams: new Set() };
11+const cy: Person = { user_id: "cy", owner: false, teams: new Set(["web"]) };
12+
13+const open: SpaceRules = { kind: "workspace", team: null, default_role: "edit", members: [] };
14+const team: SpaceRules = { kind: "team", team: "web", default_role: "edit", members: [] };
15+
16+function node(id: string, over: Partial<FolioAclNode> = {}): FolioAclNode {
17+ return { id, owner: "user:ana", parent_id: null, space_id: null, inherit: true, general_access: "none", general_role: null, ...over };
18+}
19+
20+function reach(n: FolioAclNode, grants: Record<string, FolioGrant[]>, space: SpaceRules | null, rule: AudienceRule, people: Person[] = [], visits: string[] = [], asker = ana) {
21+ return agentReach({
22+ chain: aclChain(n.id, new Map([[n.id, n]])),
23+ grants: new Map(Object.entries(grants)),
24+ space,
25+ asker,
26+ askerVisited: visits.includes(asker.user_id),
27+ rule,
28+ people,
29+ visited: (p) => visits.includes(p.user_id),
30+ agent_mode: "edit",
31+ });
32+}
33+
34+test("audience rules: none or only the asker is the asker; more than 20 people is the workspace", () => {
35+ assert.deepEqual(audienceRule(null, "ana"), { kind: "asker" });
36+ assert.deepEqual(audienceRule({ kind: "people", user_ids: ["ana"] }, "ana"), { kind: "asker" });
37+ assert.deepEqual(audienceRule({ kind: "people", user_ids: ["ana", "bo", "bo"] }, "ana"), { kind: "people", user_ids: ["bo"] });
38+ assert.deepEqual(audienceRule({ kind: "workspace" }, "ana"), { kind: "workspace" });
39+ const crowd = Array.from({ length: 21 }, (_, i) => `u${i}`);
40+ assert.deepEqual(audienceRule({ kind: "people", user_ids: crowd }, "ana"), { kind: "workspace" });
41+ const twenty = Array.from({ length: 19 }, (_, i) => `u${i}`);
42+ assert.equal(audienceRule({ kind: "people", user_ids: twenty }, "ana").kind, "people");
43+});
44+
45+test("rule 1: a public channel finds only open-space and workspace-wide folios", () => {
46+ const ws: AudienceRule = { kind: "workspace" };
47+ assert.equal(agentMayFind(reach(node("a", { space_id: "open" }), {}, open, ws)), true);
48+ assert.equal(agentMayFind(reach(node("a", { general_access: "workspace", general_role: "view" }), {}, null, ws)), true);
49+ // Private, shared, team, link: never in a public channel.
50+ assert.equal(agentMayFind(reach(node("a"), {}, null, ws)), false);
51+ assert.equal(agentMayFind(reach(node("a"), { a: [{ principal: "user:bo", role: "view" }] }, null, ws)), false);
52+ assert.equal(agentMayFind(reach(node("a", { space_id: "team" }), {}, team, ws)), false);
53+ assert.equal(agentMayFind(reach(node("a", { general_access: "link", general_role: "view" }), {}, null, ws, [], ["ana"])), false);
54+});
55+
56+test("rule 1: a conversation finds only what everyone in it can read", () => {
57+ const withBo: AudienceRule = { kind: "people", user_ids: ["bo"] };
58+ const shared = node("a");
59+ assert.equal(agentMayFind(reach(shared, { a: [{ principal: "user:bo", role: "view" }] }, null, withBo, [bo])), true);
60+ assert.equal(agentMayFind(reach(shared, {}, null, withBo, [bo])), false);
61+ // A team space: Cy is in the team, Bo isn't.
62+ const t = node("t", { space_id: "team" });
63+ assert.equal(agentMayFind(reach(t, {}, team, { kind: "people", user_ids: ["cy"] }, [cy])), true);
64+ assert.equal(agentMayFind(reach(t, {}, team, withBo, [bo])), false);
65+ // A link folio: only when everyone opened it.
66+ const link = node("l", { general_access: "link", general_role: "view" });
67+ assert.equal(agentMayFind(reach(link, {}, null, withBo, [bo], ["ana"])), false);
68+ assert.equal(agentMayFind(reach(link, {}, null, withBo, [bo], ["ana", "bo"])), true);
69+});
70+
71+test("rule 1: the asker's own Private is theirs alone", () => {
72+ assert.equal(agentMayFind(reach(node("p"), {}, null, { kind: "asker" })), true);
73+ assert.equal(agentMayFind(reach(node("p"), {}, null, { kind: "people", user_ids: ["bo"] }, [bo])), false);
74+ // Someone else's Private: not even for its asker.
75+ assert.equal(agentMayFind(reach(node("p", { owner: "user:bo" }), {}, null, { kind: "asker" })), false);
76+});
77+
78+test("rule 2: reading what the audience can't all read says so", () => {
79+ const r = reach(node("p"), {}, null, { kind: "workspace" });
80+ assert.equal(r.asker_role, "manage");
81+ assert.equal(r.audience_can_read, false);
82+ const fine = reach(node("o", { space_id: "open" }), {}, open, { kind: "workspace" });
83+ assert.equal(fine.audience_can_read, true);
84+});
85+
86+test("an agent's grant never widens what it can do for its asker", () => {
87+ const n = node("f", { owner: "user:cy" });
88+ const grants = { f: [{ principal: "agent:ag1", role: "manage" as const }, { principal: "user:bo", role: "comment" as const }] };
89+ const r = reach(n, grants, null, { kind: "asker" }, [], [], bo);
90+ assert.equal(r.asker_role, "comment");
91+ assert.deepEqual(r.can, { read: true, suggest: true, edit: false });
92+ const none = reach(n, grants, null, { kind: "asker" }, [], [], ana);
93+ assert.equal(none.asker_role, null);
94+ assert.deepEqual(none.can, { read: false, suggest: false, edit: false });
95+ assert.equal(agentMayFind(none), false);
96+});
+83−0
1+/**
2+ * What an agent may reach in folios for the person it acts for (its
3+ * asker), and who else will see its answer (the audience): the leak rules
4+ * of docs/ARTIFACTS_MODE.md section 4.3, apart from where rows come from.
5+ * Pure.
6+ *
7+ * - The agent never has more than its asker (`agentFolioRole`).
8+ * - Finding things (lists, search, recall, stale) is narrowed to folios
9+ * every person in the audience can read, so nothing turns up in a
10+ * conversation that someone in it can't open.
11+ * - A public channel's audience is "the workspace": only folios readable
12+ * through an open space or general access `workspace`. Never a link
13+ * folio, a share, or Private. More than 20 people reads the same way.
14+ * - Reading one folio the asker named (a link they gave) works whenever
15+ * the asker can read it; the result says `audience_can_read: false`
16+ * when someone else in the conversation can't, so the agent says it
17+ * sent the link to the asker instead of quoting it.
18+ */
19+import type { DocAgentAbilities, DocAgentMode, DocAudience, DocRole } from "@g1t/contracts";
20+
21+import { agentAbilities, agentFolioRole, effectiveRole, folioReadableByWorkspace, roleOf, type FolioAclNode, type FolioGrants, type Person, type SpaceRules } from "../access.ts";
22+
23+/** More people than this in a conversation read as the whole workspace. */
24+export const AUDIENCE_AS_WORKSPACE = 20;
25+
26+/** Who an answer reaches, as access checks it. */
27+export type AudienceRule =
28+ /** The asker alone (no audience, or only them). */
29+ | { kind: "asker" }
30+ /** These other people besides the asker, by user id. */
31+ | { kind: "people"; user_ids: string[] }
32+ /** Everyone in the workspace. */
33+ | { kind: "workspace" };
34+
35+export function audienceRule(audience: DocAudience | null | undefined, askerId: string): AudienceRule {
36+ if (!audience) return { kind: "asker" };
37+ if (audience.kind === "workspace") return { kind: "workspace" };
38+ if (audience.kind !== "people" || !Array.isArray(audience.user_ids)) return { kind: "asker" };
39+ const others = [...new Set(audience.user_ids.map(String))].filter((id) => id && id !== askerId);
40+ if (!others.length) return { kind: "asker" };
41+ if (others.length + 1 > AUDIENCE_AS_WORKSPACE) return { kind: "workspace" };
42+ return { kind: "people", user_ids: others };
43+}
44+
45+/** One folio as an agent sees it. */
46+export type AgentReach = {
47+ /** The asker's role, or null when they can't read it. */
48+ asker_role: DocRole | null;
49+ /** Whether everyone in the audience can read it too. */
50+ audience_can_read: boolean;
51+ /** What the agent may do: the asker's role, nothing more, by the folio's agent mode. */
52+ can: DocAgentAbilities;
53+};
54+
55+export type ReachInput = {
56+ chain: readonly FolioAclNode[];
57+ grants: FolioGrants;
58+ /** The folio's space's rules, when its chain inherits one (else null). */
59+ space: SpaceRules | null;
60+ asker: Person;
61+ askerVisited: boolean;
62+ rule: AudienceRule;
63+ /** The audience's people (for a `people` rule). */
64+ people: readonly Person[];
65+ /** Whether a person opened the folio's link. */
66+ visited: (person: Person) => boolean;
67+ agent_mode: DocAgentMode;
68+};
69+
70+export function agentReach(input: ReachInput): AgentReach {
71+ const spaceRole = (p: Person) => (input.space ? roleOf(input.space, p) : null);
72+ const asker = effectiveRole(input.chain, input.grants, spaceRole(input.asker), input.asker, { visited: input.askerVisited });
73+ let audience = true;
74+ if (input.rule.kind === "workspace") audience = folioReadableByWorkspace(input.chain, input.space);
75+ else if (input.rule.kind === "people") audience = input.people.every((p) => !!effectiveRole(input.chain, input.grants, spaceRole(p), p, { visited: input.visited(p) }));
76+ const role = agentFolioRole(asker, true);
77+ return { asker_role: role, audience_can_read: !!asker && audience, can: agentAbilities(role, input.agent_mode) };
78+}
79+
80+/** Whether an agent may find a folio (lists, search, recall): its asker and every person in the audience can read it. */
81+export function agentMayFind(reach: AgentReach): boolean {
82+ return !!reach.asker_role && reach.audience_can_read;
83+}
+22−0
1+/**
2+ * What the docs service tells the rest of g1t about folios: `folio.*`
3+ * events on the bus (packages/contracts events.ts, `FolioEventData`).
4+ * Published with no `repoId`, so a folio never reaches a repository's
5+ * timeline or webhooks, and with a title only when the whole workspace
6+ * can read the folio. Never throws: an event that can't be told is
7+ * logged, and the change it is about still happened.
8+ */
9+import { eventsClient, type EventPayloads, type FolioEventData, type ServiceBinding } from "@g1t/contracts";
10+
11+import { actorOf } from "../events.ts";
12+
13+export type FolioEventType = "folio.created" | "folio.updated" | "folio.trashed" | "folio.restored" | "folio.shared" | "folio.stale";
14+
15+export async function publishFolioEvent<T extends FolioEventType>(events: ServiceBinding | undefined, type: T, data: EventPayloads[T] & FolioEventData, actor: string | null): Promise<void> {
16+ if (!events) return;
17+ try {
18+ await eventsClient(events).publish([{ type, source: "docs", repoId: null, actor: actorOf(actor), data } as never]);
19+ } catch (error) {
20+ console.error("folios could not publish", type, String(error));
21+ }
22+}
+66−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { cleanCover, cleanIcon, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, sharedTops, subtreeHeight, treeNodes } from "./list.ts";
5+
6+test("titles, icons, covers and sources are cleaned", () => {
7+ assert.equal(cleanTitle(" Q4 roadmap \n"), "Q4 roadmap");
8+ assert.equal(cleanTitle("x".repeat(300)).length, 200);
9+ assert.equal(cleanIcon(""), null);
10+ assert.equal(cleanIcon("📐 extra words"), "📐 ex");
11+ assert.equal(cleanCover("gradient:3"), "gradient:3");
12+ assert.equal(cleanCover("javascript:alert(1)"), null);
13+ assert.deepEqual(cleanSource({ title: "Launch thread", href: "/acme/-/chat/general?thread=1" }), { title: "Launch thread", href: "/acme/-/chat/general?thread=1" });
14+ assert.equal(cleanSource({ title: "x", href: "https://evil.example" }), null);
15+ assert.equal(cleanSource({ title: "x", href: "//evil.example" }), null);
16+ assert.deepEqual(cleanTarget({ kind: "section", heading: " Risks " }), { kind: "section", heading: "Risks" });
17+ assert.equal(cleanTarget({ kind: "nope" }), null);
18+});
19+
20+test("the cursor goes there and back, and nonsense is no cursor", () => {
21+ const c = { k: "2026-10-09T03:00:00.000Z", id: "fol_01jb2k7x9hfq0b3zj0f5s2m8ra" };
22+ assert.deepEqual(decodeCursor(encodeCursor(c)), c);
23+ assert.equal(decodeCursor("not a cursor"), null);
24+ assert.equal(decodeCursor(null), null);
25+ assert.equal(listLimit(undefined), 30);
26+ assert.equal(listLimit(500), 100);
27+ assert.equal(listLimit(0), 30);
28+});
29+
30+test("a tree puts a row under its parent only when the parent is shown", () => {
31+ const rows = [
32+ { id: "b", kind: "doc" as const, parent_id: "a", position: 2, title: "B", icon: null, inherit: 1 },
33+ { id: "a", kind: "doc" as const, parent_id: null, position: 1, title: "A", icon: null, inherit: 1 },
34+ { id: "c", kind: "doc" as const, parent_id: "hidden", position: 3, title: "C", icon: null, inherit: 0 },
35+ ];
36+ const tree = treeNodes(rows, new Set(["b"]));
37+ assert.deepEqual(
38+ tree.map((n) => [n.id, n.parent_id, n.restricted, n.stale ?? false]),
39+ [
40+ ["a", null, false, false],
41+ ["b", "a", false, true],
42+ ["c", null, true, false],
43+ ],
44+ );
45+ assert.equal(tree[0]!.slug, "a-a");
46+});
47+
48+test("shared tops leave out children of what is shown and anything shown elsewhere", () => {
49+ const readable = [
50+ { id: "top", parent_id: "secret" },
51+ { id: "kid", parent_id: "top" },
52+ { id: "loose", parent_id: null },
53+ { id: "inspace", parent_id: null },
54+ { id: "underspace", parent_id: "spacedoc" },
55+ ];
56+ assert.deepEqual(
57+ sharedTops(readable, new Set(["inspace", "spacedoc"])).map((r) => r.id),
58+ ["top", "loose"],
59+ );
60+});
61+
62+test("depth and height come from paths", () => {
63+ assert.equal(depthOf("/a/"), 1);
64+ assert.equal(depthOf("/a/b/c/"), 3);
65+ assert.equal(subtreeHeight({ path: "/a/b/" }, [{ path: "/a/b/" }, { path: "/a/b/c/" }, { path: "/a/b/c/d/" }]), 2);
66+});
+141−0
1+/**
2+ * Folio lists' small rules, apart from where rows come from: cleaning
3+ * input, the paging cursor, sidebar trees and the "Shared" section's
4+ * tops, and a tree's depth. Pure.
5+ */
6+import type { DocEditTarget, FolioTreeNode } from "@g1t/contracts";
7+
8+import { pageSlug } from "../slugs.ts";
9+
10+/** The longest title, in characters (FOLIO_MAX_TITLE). */
11+export const MAX_TITLE = 200;
12+/** The deepest a folio tree goes (FOLIO_MAX_DEPTH). */
13+export const MAX_DEPTH = 10;
14+/** A page of a list, unless asked for fewer (at most FOLIO_LIST_MAX). */
15+export const DEFAULT_LIMIT = 30;
16+export const MAX_LIMIT = 100;
17+/** A note on an edit, a template's description: at most this long. */
18+export const MAX_NOTE = 500;
19+
20+export function cleanTitle(title: unknown, max = MAX_TITLE): string {
21+ return [...String(title ?? "").replace(/\s+/g, " ").trim()].slice(0, max).join("");
22+}
23+
24+/** One emoji (or a few characters), or null. */
25+export function cleanIcon(icon: unknown): string | null {
26+ const s = String(icon ?? "").trim();
27+ return s ? [...s].slice(0, 4).join("") : null;
28+}
29+
30+export function cleanCover(cover: unknown): string | null {
31+ const s = String(cover ?? "").trim();
32+ if (!s) return null;
33+ if (/^gradient:\d{1,2}$/.test(s)) return s;
34+ if (/^https:\/\/[^\s"'<>]{1,500}$/.test(s)) return s;
35+ return null;
36+}
37+
38+export function cleanNote(note: unknown): string | null {
39+ const s = String(note ?? "").trim();
40+ return s ? s.slice(0, MAX_NOTE) : null;
41+}
42+
43+/** Where it was written up from: a link on this site only. */
44+export function cleanSource(source: unknown): { title: string; href: string } | null {
45+ const s = source as { title?: unknown; href?: unknown } | null;
46+ if (!s || typeof s !== "object" || typeof s.href !== "string") return null;
47+ const href = s.href.trim();
48+ if (!href.startsWith("/") || href.startsWith("//") || href.length > 500 || /[\s"'<>]/.test(href)) return null;
49+ return { title: cleanTitle(s.title || "A conversation", 120) || "A conversation", href };
50+}
51+
52+export function cleanTarget(target: unknown): DocEditTarget | null {
53+ const t = target as DocEditTarget | null;
54+ if (!t || typeof t !== "object") return null;
55+ switch (t.kind) {
56+ case "append":
57+ case "document":
58+ return { kind: t.kind };
59+ case "section":
60+ return typeof t.heading === "string" && t.heading.trim() ? { kind: "section", heading: t.heading.trim().slice(0, 300) } : null;
61+ case "blocks":
62+ return typeof t.from_block === "string" && typeof t.to_block === "string" ? { kind: "blocks", from_block: t.from_block, to_block: t.to_block } : null;
63+ default:
64+ return null;
65+ }
66+}
67+
68+export function listLimit(limit: unknown): number {
69+ const n = Math.floor(Number(limit));
70+ if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT;
71+ return Math.min(n, MAX_LIMIT);
72+}
73+
74+/** Where the next page of a list starts: after this sort key and id. */
75+export type Cursor = { k: string; id: string };
76+
77+export function encodeCursor(cursor: Cursor): string {
78+ return btoa(JSON.stringify([cursor.k, cursor.id])).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
79+}
80+
81+export function decodeCursor(value: unknown): Cursor | null {
82+ if (typeof value !== "string" || !value || value.length > 400) return null;
83+ try {
84+ const parsed = JSON.parse(atob(value.replace(/-/g, "+").replace(/_/g, "/"))) as unknown;
85+ if (Array.isArray(parsed) && typeof parsed[0] === "string" && typeof parsed[1] === "string") return { k: parsed[0], id: parsed[1] };
86+ } catch {
87+ // Not one of ours.
88+ }
89+ return null;
90+}
91+
92+/** A folio's last address segment (`<title-slug>-<id>`), as `folioSlug` in contracts. */
93+export function slugOf(title: string, id: string): string {
94+ return pageSlug(title, id);
95+}
96+
97+type TreeRow = { id: string; kind: FolioTreeNode["kind"]; parent_id: string | null; position: number; title: string; icon: string | null; inherit: number | boolean };
98+
99+/**
100+ * A sidebar tree: the rows a reader may see, each under its parent when
101+ * they may see the parent too, otherwise at the top. Ordered by position.
102+ */
103+export function treeNodes(rows: readonly TreeRow[], stale: ReadonlySet<string> = new Set()): FolioTreeNode[] {
104+ const shown = new Set(rows.map((r) => r.id));
105+ return [...rows]
106+ .sort((a, b) => a.position - b.position || a.id.localeCompare(b.id))
107+ .map((r) => ({
108+ id: r.id,
109+ kind: r.kind,
110+ parent_id: r.parent_id && shown.has(r.parent_id) ? r.parent_id : null,
111+ position: r.position,
112+ title: r.title,
113+ icon: r.icon,
114+ slug: slugOf(r.title, r.id),
115+ restricted: !r.inherit && !!r.parent_id,
116+ ...(stale.has(r.id) ? { stale: true } : {}),
117+ }));
118+}
119+
120+/**
121+ * The tops of what is shared with someone: of the folios they can read
122+ * that aren't theirs, those whose parent they can't read (or that have
123+ * none), leaving out anything already in their other sections.
124+ */
125+export function sharedTops<T extends { id: string; parent_id: string | null }>(readable: readonly T[], elsewhere: ReadonlySet<string>): T[] {
126+ const ids = new Set(readable.map((r) => r.id));
127+ return readable.filter((r) => !elsewhere.has(r.id) && (!r.parent_id || (!ids.has(r.parent_id) && !elsewhere.has(r.parent_id))));
128+}
129+
130+/** How deep a folio is: 1 at the top. */
131+export function depthOf(path: string): number {
132+ return String(path ?? "")
133+ .split("/")
134+ .filter(Boolean).length;
135+}
136+
137+/** How many levels a subtree spans below its top (0 for a leaf), from its rows' paths. */
138+export function subtreeHeight(top: { path: string }, rows: readonly { path: string }[]): number {
139+ const base = depthOf(top.path);
140+ return rows.reduce((h, r) => Math.max(h, depthOf(r.path) - base), 0);
141+}
+512−0
1+/**
2+ * One folio's live room, as a Durable Object named by the folio id: the
3+ * same socket protocol, hibernation, roles, save alarm and index alarm as
4+ * a Docs page's room (src/room.ts, PageRoom), for every kind of folio
5+ * through its kind's model (src/kinds/). The kind is kept in `meta`.
6+ *
7+ * It owns the folio's Yjs document: every editor's socket syncs with it
8+ * (y-protocols sync and awareness, binary frames), and every change made
9+ * on the server (an agent's edit, an accepted suggestion, a restore, a
10+ * comment) is applied here, so all of them merge as one CRDT. A few
11+ * seconds after a burst of edits (an alarm) it saves the kind's text
12+ * rendition, card, links, citations and history to D1 (src/persist.ts
13+ * `saveFolio`); half a minute after the text first changes, it brings
14+ * the folio's passages in the index up to date (src/indexer.ts
15+ * `indexFolio`).
16+ *
17+ * The room authorizes nothing about who may open the folio: the Worker
18+ * checks the viewer's role before forwarding a socket
19+ * (src/folios/service.ts, `live`) and puts it in ROOM_MEMBER_HEADER. The
20+ * room enforces that role: a socket that may only view or comment never
21+ * changes the document. Access changes reach open sockets through
22+ * `setRole`; a socket whose access ended closes with 4403.
23+ */
24+import { DurableObject } from "cloudflare:workers";
25+
26+import type { DocEditTarget, DocRole, DocThreadAction, FolioAgentEdit, FolioKind, FoliosLiveEvent, MemberProfile, ServiceBinding } from "@g1t/contracts";
27+import * as decoding from "lib0/decoding";
28+import * as encoding from "lib0/encoding";
29+import * as syncProtocol from "y-protocols/sync";
30+import * as Y from "yjs";
31+
32+import { atLeast } from "../access.ts";
33+import { anchorThread, unanchorThread } from "../edits.ts";
34+import type { FileStoreEnv } from "../files.ts";
35+import { indexFolio, type IndexEnv } from "../indexer.ts";
36+import { docFragment, docTarget, docTargets } from "../kinds/doc/index.ts";
37+import { kindModel } from "../kinds/index.ts";
38+import type { AgentForm, FolioOrigin, KindModel } from "../kinds/types.ts";
39+import { saveFolio } from "../persist.ts";
40+import { ROOM_MEMBER_HEADER, awarenessEntries } from "../room.ts";
41+import { applyThreadAction, listThreads, setQuote, type ThreadResult } from "../threads.ts";
42+import { notifyFolioMentions } from "./service.ts";
43+
44+export { ROOM_MEMBER_HEADER };
45+
46+export type FolioRoomMember = {
47+ folio_id: string;
48+ workspace_slug: string;
49+ /** `user:<id>`. */
50+ key: string;
51+ member: MemberProfile;
52+ role: DocRole;
53+};
54+
55+type Attachment = FolioRoomMember & { clients: number[] };
56+
57+export type FolioRoomEnv = IndexEnv &
58+ FileStoreEnv & {
59+ DB: D1Database;
60+ NOTIFY?: ServiceBinding;
61+ EVENTS?: ServiceBinding;
62+ IDENTITY: ServiceBinding;
63+ AGENTS: ServiceBinding;
64+ REPOS?: ServiceBinding;
65+ /** The rooms themselves, as the Worker binds them (mentions are checked through the service). */
66+ FOLIOS: DurableObjectNamespace<FolioRoom>;
67+ };
68+
69+const MESSAGE_SYNC = 0;
70+const MESSAGE_AWARENESS = 1;
71+const MESSAGE_QUERY_AWARENESS = 3;
72+/** Save this long after the last change. */
73+const SAVE_AFTER_MS = 4_000;
74+/** Index this long after the text first changed: at most twice a minute while someone types. */
75+const INDEX_AFTER_MS = 30_000;
76+/** Compact the stored updates into one snapshot past this many. */
77+const COMPACT_AT = 300;
78+
79+export class FolioRoom extends DurableObject<FolioRoomEnv> {
80+ private doc: Y.Doc | null = null;
81+ /** The last awareness update each client sent. Lost on hibernation; clients resend every 15 s. */
82+ private awareness = new Map<number, Uint8Array>();
83+ private clocks = new Map<number, number>();
84+
85+ constructor(ctx: DurableObjectState, env: FolioRoomEnv) {
86+ super(ctx, env);
87+ ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
88+ ctx.blockConcurrencyWhile(async () => {
89+ this.ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS updates (seq INTEGER PRIMARY KEY AUTOINCREMENT, data BLOB NOT NULL)");
90+ this.ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS snapshot (id INTEGER PRIMARY KEY CHECK (id = 1), data BLOB NOT NULL)");
91+ this.ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
92+ });
93+ }
94+
95+ // ── State ──────────────────────────────────────────────────────────────
96+
97+ private meta<T>(key: string, fallback: T): T {
98+ const row = this.ctx.storage.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = ?", key).toArray()[0];
99+ if (!row) return fallback;
100+ try {
101+ return JSON.parse(row.value) as T;
102+ } catch {
103+ return fallback;
104+ }
105+ }
106+
107+ private setMeta(key: string, value: unknown): void {
108+ this.ctx.storage.sql.exec("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value", key, JSON.stringify(value));
109+ }
110+
111+ /** The kind's model. Every room is named and given its kind by `ensure` (or a socket) before anything else. */
112+ private model(): KindModel {
113+ const model = kindModel(this.meta<string | null>("kind", null));
114+ if (!model) throw new Error(`folio room ${this.meta<string>("folio_id", "?")} has no kind it knows`);
115+ return model;
116+ }
117+
118+ private load(): Y.Doc {
119+ if (this.doc) return this.doc;
120+ const doc = new Y.Doc({ gc: true });
121+ const snapshot = this.ctx.storage.sql.exec<{ data: ArrayBuffer }>("SELECT data FROM snapshot WHERE id = 1").toArray()[0];
122+ if (snapshot) Y.applyUpdate(doc, new Uint8Array(snapshot.data));
123+ for (const row of this.ctx.storage.sql.exec<{ data: ArrayBuffer }>("SELECT data FROM updates ORDER BY seq")) {
124+ Y.applyUpdate(doc, new Uint8Array(row.data));
125+ }
126+ doc.on("update", (update: Uint8Array, origin: unknown) => this.onUpdate(update, origin));
127+ this.doc = doc;
128+ return doc;
129+ }
130+
131+ private onUpdate(update: Uint8Array, origin: unknown): void {
132+ this.ctx.storage.sql.exec("INSERT INTO updates (data) VALUES (?)", update);
133+ const count = this.ctx.storage.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM updates").one().n;
134+ if (count >= COMPACT_AT) this.compact();
135+ const member = origin instanceof WebSocket ? (origin.deserializeAttachment() as Attachment | null) : null;
136+ const key = member ? member.key : (origin as FolioOrigin | null)?.key;
137+ if (member?.member.kind === "user") {
138+ const names = this.meta<string[]>("editor_names", []);
139+ const name = member.member.name.toLowerCase();
140+ if (!names.includes(name)) this.setMeta("editor_names", [...names, name]);
141+ }
142+ if (key) {
143+ const editors = this.meta<string[]>("editors", []).filter((k) => k !== key);
144+ editors.push(key);
145+ this.setMeta("editors", editors);
146+ const pending = this.meta<string[]>("pending_authors", []);
147+ if (!pending.includes(key)) this.setMeta("pending_authors", [...pending, key]);
148+ }
149+ const encoder = encoding.createEncoder();
150+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
151+ syncProtocol.writeUpdate(encoder, update);
152+ this.send(encoding.toUint8Array(encoder), origin instanceof WebSocket ? origin : null);
153+ this.alarmBy(Date.now() + SAVE_AFTER_MS);
154+ }
155+
156+ private compact(): void {
157+ const doc = this.load();
158+ this.ctx.storage.transactionSync(() => {
159+ this.ctx.storage.sql.exec("INSERT INTO snapshot (id, data) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET data = excluded.data", Y.encodeStateAsUpdate(doc));
160+ this.ctx.storage.sql.exec("DELETE FROM updates");
161+ });
162+ }
163+
164+ private alarmBy(when: number): void {
165+ void this.ctx.storage.getAlarm().then((at) => {
166+ if (at == null || at > when) return this.ctx.storage.setAlarm(when);
167+ });
168+ }
169+
170+ private send(message: Uint8Array | string, except: WebSocket | null = null): void {
171+ for (const socket of this.ctx.getWebSockets()) {
172+ if (socket === except) continue;
173+ try {
174+ socket.send(message);
175+ } catch {
176+ // Closing already.
177+ }
178+ }
179+ }
180+
181+ /** Saves to D1 now: the rendition, search, links, citations, and a version if one is due or asked for. */
182+ private async persist(version: FolioOrigin | null = null): Promise<string | null> {
183+ const folioId = this.meta<string | null>("folio_id", null);
184+ if (!folioId || !kindModel(this.meta<string | null>("kind", null))) return null;
185+ const doc = this.load();
186+ const pending = this.meta<string[]>("pending_authors", []);
187+ const result = await saveFolio(this.env, {
188+ folio_id: folioId,
189+ rendition: this.model().render(doc),
190+ editors: this.meta<string[]>("editors", []),
191+ editor_names: this.meta<string[]>("editor_names", []),
192+ state: Y.encodeStateAsUpdate(doc),
193+ version: version ? { kind: version.kind, note: version.note, authors: version.authors ?? [version.key] } : null,
194+ pending_authors: pending,
195+ last_version_at: this.meta<number>("last_version_at", 0),
196+ workspace_slug: this.meta<string | null>("workspace_slug", null),
197+ });
198+ this.setMeta("editors", []);
199+ this.setMeta("editor_names", []);
200+ if (result.changed && this.meta<number | null>("index_at", null) == null) {
201+ const when = Date.now() + INDEX_AFTER_MS;
202+ this.setMeta("index_at", when);
203+ this.alarmBy(when);
204+ }
205+ if (result.version_id) {
206+ this.setMeta("last_version_at", Date.now());
207+ this.setMeta("pending_authors", []);
208+ }
209+ if (result.mentioned.length) {
210+ // Told only if they can read it (the service checks), so this waits on nothing.
211+ const slug = this.meta<string | null>("workspace_slug", null);
212+ if (slug) {
213+ this.ctx.waitUntil(notifyFolioMentions(this.env, slug, folioId, result.mentioned, result.last).catch((error: unknown) => console.error("folios could not tell people they were mentioned", String(error))));
214+ }
215+ }
216+ return result.version_id;
217+ }
218+
219+ override async alarm(): Promise<void> {
220+ await this.persist();
221+ const due = this.meta<number | null>("index_at", null);
222+ if (due == null) return;
223+ if (Date.now() < due) return this.alarmBy(due);
224+ this.setMeta("index_at", null);
225+ const folioId = this.meta<string | null>("folio_id", null);
226+ if (folioId) await indexFolio(this.env, folioId);
227+ }
228+
229+ // ── Calls from the Worker ──────────────────────────────────────────────
230+
231+ /**
232+ * Names the folio and its kind, and fills an empty document: from a
233+ * Yjs state (a duplicate), or the kind's seed from text or a spec (a
234+ * template, an agent's new folio, or blank).
235+ */
236+ async ensure(init: { folio_id: string; kind: FolioKind; workspace_slug: string; text?: string | null; spec?: unknown; state?: Uint8Array | null }): Promise<void> {
237+ this.setMeta("folio_id", init.folio_id);
238+ this.setMeta("kind", init.kind);
239+ if (init.workspace_slug) this.setMeta("workspace_slug", init.workspace_slug);
240+ const doc = this.load();
241+ const model = this.model();
242+ if (!model.isEmpty(doc)) return;
243+ // No origin: filling a new room is its "created" version (written by the service), nobody's edit.
244+ if (init.state) Y.applyUpdate(doc, init.state);
245+ else doc.transact(() => model.seed(doc, { text: init.text ?? null, spec: init.spec }));
246+ }
247+
248+ /** The folio in its agent form. */
249+ async read(): Promise<AgentForm> {
250+ return this.model().read(this.load());
251+ }
252+
253+ /** The text rendition now (unsaved edits included). */
254+ async text(): Promise<string> {
255+ return this.model().render(this.load()).text;
256+ }
257+
258+ /** The whole document's state, for a duplicate. */
259+ async state(): Promise<Uint8Array> {
260+ return Y.encodeStateAsUpdate(this.load());
261+ }
262+
263+ /** A doc's target now, or null when it is gone (doc kind only). */
264+ async target(target: DocEditTarget): Promise<{ markdown: string; block_ids: string[] } | null> {
265+ if (this.model().kind !== "doc") return null;
266+ return docTarget(this.load(), target);
267+ }
268+
269+ async targets(targets: DocEditTarget[]): Promise<(string[] | null)[]> {
270+ if (this.model().kind !== "doc") return targets.map(() => null);
271+ return docTargets(this.load(), targets);
272+ }
273+
274+ /** Applies an edit in the kind's terms and records a version for it. */
275+ async edit(edit: FolioAgentEdit, origin: FolioOrigin): Promise<{ applied: boolean; version_id: string | null; summary: string }> {
276+ const doc = this.load();
277+ const model = this.model();
278+ const result = model.applyAgentEdit(doc, edit, origin);
279+ if (!result.applied) return { applied: false, version_id: null, summary: result.summary };
280+ const version_id = await this.persist({ ...origin, note: origin.note ?? result.summary });
281+ return { applied: true, version_id, summary: result.summary };
282+ }
283+
284+ /** Makes the document what a version's was, as a new version. */
285+ async restore(input: { state: Uint8Array | null; text: string }, origin: FolioOrigin): Promise<string | null> {
286+ const doc = this.load();
287+ const model = this.model();
288+ if (input.state) {
289+ const old = new Y.Doc();
290+ Y.applyUpdate(old, input.state);
291+ model.restore(doc, old, origin);
292+ } else model.restoreText(doc, input.text, origin);
293+ return this.persist(origin);
294+ }
295+
296+ /** A comment operation from `actor` with `role`. Anchoring in the text is the doc kind's. */
297+ async thread(actor: string, role: DocRole, action: DocThreadAction): Promise<ThreadResult> {
298+ const doc = this.load();
299+ const isDoc = this.model().kind === "doc";
300+ if (action.op === "anchor") {
301+ if (!atLeast(role, "comment")) return { ok: false, code: "forbidden", message: "You can read this but not comment on it." };
302+ if (!isDoc) return { ok: false, code: "invalid", message: "Comments on this kind of artifact are pinned, not anchored in text." };
303+ const thread = doc.getMap<Y.Map<unknown>>("threads").get(action.thread_id);
304+ if (!thread) return { ok: false, code: "not_found", message: "That thread is gone." };
305+ const quote = anchorThread(doc, docFragment(doc), action.anchor, action.head, action.thread_id);
306+ if (quote) setQuote(doc, action.thread_id, quote);
307+ return { ok: true, value: { quote } };
308+ }
309+ const result = applyThreadAction(doc, actor, role, action);
310+ if (result.ok && action.op === "delete_thread" && isDoc) unanchorThread(doc, docFragment(doc), action.thread_id);
311+ return result;
312+ }
313+
314+ async threads(): Promise<ReturnType<typeof listThreads>> {
315+ return listThreads(this.load());
316+ }
317+
318+ /** Shows an agent in everyone's presence row for a little while when it edits or suggests. */
319+ async announce(key: string, name: string): Promise<void> {
320+ let id = 0;
321+ for (let i = 0; i < key.length; i++) id = (Math.imul(id, 31) + key.charCodeAt(i)) | 0;
322+ const client = (id & 0x3fffffff) + 1;
323+ const clock = Math.floor(Date.now() / 1000);
324+ const state = JSON.stringify({ user: { name, color: "#b8a6ff", key, kind: "agent", avatar: "" } });
325+ const update = encoding.createEncoder();
326+ encoding.writeVarUint(update, 1);
327+ encoding.writeVarUint(update, client);
328+ encoding.writeVarUint(update, clock);
329+ encoding.writeVarString(update, state);
330+ const bytes = encoding.toUint8Array(update);
331+ this.awareness.set(client, bytes);
332+ this.clocks.set(client, clock);
333+ const message = encoding.createEncoder();
334+ encoding.writeVarUint(message, MESSAGE_AWARENESS);
335+ encoding.writeVarUint8Array(message, bytes);
336+ this.send(encoding.toUint8Array(message));
337+ }
338+
339+ /** Tells everyone with the folio open. */
340+ async notice(event: FoliosLiveEvent): Promise<void> {
341+ this.send(JSON.stringify(event));
342+ }
343+
344+ /** Who has it open: each socket's member key and username, for re-checking access. */
345+ async members(): Promise<{ key: string; name: string }[]> {
346+ const out = new Map<string, string>();
347+ for (const socket of this.ctx.getWebSockets()) {
348+ const who = socket.deserializeAttachment() as Attachment | null;
349+ if (who) out.set(who.key, who.member.name);
350+ }
351+ return [...out].map(([key, name]) => ({ key, name }));
352+ }
353+
354+ /** A member's role changed (null: they can no longer read it): their sockets follow, or close with 4403. */
355+ async setRole(key: string, role: DocRole | null): Promise<void> {
356+ for (const socket of this.ctx.getWebSockets(key)) {
357+ const who = socket.deserializeAttachment() as Attachment | null;
358+ if (!who) continue;
359+ if (!role) {
360+ try {
361+ socket.send(JSON.stringify({ type: "access", role: null } satisfies FoliosLiveEvent));
362+ socket.close(4403, "No longer allowed");
363+ } catch {
364+ // Already closed.
365+ }
366+ continue;
367+ }
368+ if (who.role === role) continue;
369+ socket.serializeAttachment({ ...who, role });
370+ try {
371+ socket.send(JSON.stringify({ type: "access", role } satisfies FoliosLiveEvent));
372+ } catch {
373+ // Closing.
374+ }
375+ }
376+ }
377+
378+ /** Saves now, as before a version list, an export or the trash. */
379+ async flush(): Promise<void> {
380+ if (this.doc) await this.persist();
381+ }
382+
383+ /** Closes every socket: the folio went to the trash. */
384+ async closeAll(reason: string): Promise<void> {
385+ for (const socket of this.ctx.getWebSockets()) {
386+ try {
387+ socket.send(JSON.stringify({ type: "folio.trashed", folio_id: this.meta<string>("folio_id", "") } satisfies FoliosLiveEvent));
388+ socket.close(4410, reason);
389+ } catch {
390+ // Already closed.
391+ }
392+ }
393+ }
394+
395+ /** Forgets everything: the folio was deleted for good. */
396+ async destroy(): Promise<void> {
397+ await this.closeAll("Deleted");
398+ this.doc = null;
399+ await this.ctx.storage.deleteAlarm();
400+ await this.ctx.storage.deleteAll();
401+ }
402+
403+ // ── Sockets ────────────────────────────────────────────────────────────
404+
405+ override async fetch(request: Request): Promise<Response> {
406+ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("Expected a WebSocket upgrade\n", { status: 426 });
407+ let who: FolioRoomMember;
408+ try {
409+ who = JSON.parse(request.headers.get(ROOM_MEMBER_HEADER) ?? "") as FolioRoomMember;
410+ } catch {
411+ return new Response("Missing member\n", { status: 400 });
412+ }
413+ if (!kindModel(this.meta<string | null>("kind", null))) return new Response("Not ready\n", { status: 409 });
414+ if (who.workspace_slug) this.setMeta("workspace_slug", who.workspace_slug);
415+ const pair = new WebSocketPair();
416+ const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
417+ this.ctx.acceptWebSocket(server, [who.key]);
418+ server.serializeAttachment({ ...who, clients: [] } satisfies Attachment);
419+ const doc = this.load();
420+ const encoder = encoding.createEncoder();
421+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
422+ syncProtocol.writeSyncStep1(encoder, doc);
423+ server.send(encoding.toUint8Array(encoder));
424+ for (const update of this.awareness.values()) {
425+ const e = encoding.createEncoder();
426+ encoding.writeVarUint(e, MESSAGE_AWARENESS);
427+ encoding.writeVarUint8Array(e, update);
428+ server.send(encoding.toUint8Array(e));
429+ }
430+ return new Response(null, { status: 101, webSocket: client });
431+ }
432+
433+ override async webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): Promise<void> {
434+ if (typeof message === "string") return;
435+ const who = socket.deserializeAttachment() as Attachment | null;
436+ if (!who) return;
437+ const data = new Uint8Array(message);
438+ const decoder = decoding.createDecoder(data);
439+ const type = decoding.readVarUint(decoder);
440+ if (type === MESSAGE_SYNC) {
441+ // Viewers and commenters may ask for the document (step 1) but never change it.
442+ const peek = decoding.createDecoder(data);
443+ decoding.readVarUint(peek);
444+ const step = decoding.readVarUint(peek);
445+ if (step !== syncProtocol.messageYjsSyncStep1 && !atLeast(who.role, "edit")) return;
446+ const doc = this.load();
447+ const encoder = encoding.createEncoder();
448+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
449+ syncProtocol.readSyncMessage(decoder, encoder, doc, socket);
450+ if (encoding.length(encoder) > 1) socket.send(encoding.toUint8Array(encoder));
451+ return;
452+ }
453+ if (type === MESSAGE_AWARENESS) {
454+ const update = decoding.readVarUint8Array(decoder);
455+ const entries = awarenessEntries(update);
456+ const clients = entries.map((e) => e.id);
457+ for (const e of entries) {
458+ this.clocks.set(e.id, e.clock);
459+ if (e.gone) this.awareness.delete(e.id);
460+ else this.awareness.set(e.id, update);
461+ }
462+ const known = new Set(who.clients);
463+ if (clients.some((id) => !known.has(id))) socket.serializeAttachment({ ...who, clients: [...new Set([...who.clients, ...clients])] });
464+ const encoder = encoding.createEncoder();
465+ encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
466+ encoding.writeVarUint8Array(encoder, update);
467+ this.send(encoding.toUint8Array(encoder), socket);
468+ return;
469+ }
470+ if (type === MESSAGE_QUERY_AWARENESS) {
471+ for (const update of this.awareness.values()) {
472+ const encoder = encoding.createEncoder();
473+ encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
474+ encoding.writeVarUint8Array(encoder, update);
475+ socket.send(encoding.toUint8Array(encoder));
476+ }
477+ }
478+ }
479+
480+ override async webSocketClose(socket: WebSocket, code: number, reason: string): Promise<void> {
481+ this.leave(socket);
482+ try {
483+ socket.close(code, reason);
484+ } catch {
485+ // Already closed.
486+ }
487+ }
488+
489+ override async webSocketError(socket: WebSocket): Promise<void> {
490+ this.leave(socket);
491+ }
492+
493+ private leave(socket: WebSocket): void {
494+ const who = socket.deserializeAttachment() as Attachment | null;
495+ if (!who?.clients.length) return;
496+ for (const id of who.clients) this.awareness.delete(id);
497+ const gone = who.clients.filter((id) => this.clocks.has(id));
498+ if (!gone.length) return;
499+ const encoder = encoding.createEncoder();
500+ encoding.writeVarUint(encoder, gone.length);
501+ for (const id of gone) {
502+ encoding.writeVarUint(encoder, id);
503+ encoding.writeVarUint(encoder, this.clocks.get(id)! + 1);
504+ encoding.writeVarString(encoder, "null");
505+ this.clocks.delete(id);
506+ }
507+ const message = encoding.createEncoder();
508+ encoding.writeVarUint(message, MESSAGE_AWARENESS);
509+ encoding.writeVarUint8Array(message, encoding.toUint8Array(encoder));
510+ this.send(encoding.toUint8Array(message), socket);
511+ }
512+}
+23−0
1+import assert from "node:assert/strict";
2+import { readFileSync } from "node:fs";
3+import { test } from "node:test";
4+
5+import { FOLIO_RPC, folioHandler } from "./rpc.ts";
6+
7+/** FOLIO_RPC_METHODS as the contract lists them (read from the source: Node can't load the contracts package itself). */
8+function contractMethods(): string[] {
9+ const source = readFileSync(new URL("../../../../packages/contracts/src/folios.ts", import.meta.url), "utf8");
10+ const block = source.slice(source.indexOf("export const FOLIO_RPC_METHODS = ["), source.indexOf("] as const;"));
11+ return [...block.matchAll(/^\s*"([a-z_]+)",/gm)].map((m) => m[1]!);
12+}
13+
14+test("every folio RPC method in the contract is answered, and nothing else", () => {
15+ const methods = contractMethods();
16+ assert.ok(methods.length >= 40);
17+ assert.deepEqual(Object.keys(FOLIO_RPC).sort(), [...methods].sort());
18+ for (const m of methods) assert.equal(typeof folioHandler(m), "function", m);
19+});
20+
21+test("Docs' own methods fall through to the legacy switch", () => {
22+ for (const m of ["sidebar", "page", "create_page", "recall_for_agent", "toString", "__proto__", "constructor"]) assert.equal(folioHandler(m), null, m);
23+});
+66−0
1+/**
2+ * The folio RPC: every method in FOLIO_RPC_METHODS (packages/contracts
3+ * folios.ts) and the Folios method that answers it. Typed against the
4+ * contract's list, so a method added there and not here fails the
5+ * typecheck. src/index.ts asks this table first, then Docs' own switch.
6+ */
7+import type { FolioRpcMethod } from "@g1t/contracts";
8+
9+import type { Folios } from "./service.ts";
10+
11+// Bodies come from other services as JSON; each method checks its own.
12+// eslint-disable-next-line @typescript-eslint/no-explicit-any
13+type Handler = (service: Folios, args: any) => Promise<unknown>;
14+
15+export const FOLIO_RPC: Record<FolioRpcMethod, Handler> = {
16+ folio_list: (s, a) => s.list(a),
17+ folio_sidebar: (s, a) => s.sidebar(a),
18+ folio: (s, a) => s.folio(a),
19+ create_folio: (s, a) => s.create(a),
20+ update_folio: (s, a) => s.update(a),
21+ move_folio: (s, a) => s.move(a),
22+ duplicate_folio: (s, a) => s.duplicate(a),
23+ trash_folio: (s, a) => s.trash(a),
24+ restore_folio: (s, a) => s.restore(a),
25+ delete_folio: (s, a) => s.delete(a),
26+ folio_trash: (s, a) => s.trashed(a),
27+ favorite_folio: (s, a) => s.favorite(a),
28+ folio_content: (s, a) => s.content(a),
29+ edit_folio: (s, a) => s.edit(a),
30+ folio_access: (s, a) => s.access(a),
31+ set_folio_grant: (s, a) => s.setGrant(a),
32+ set_folio_general_access: (s, a) => s.setGeneralAccess(a),
33+ request_folio_access: (s, a) => s.requestAccess(a),
34+ join_space: (s, a) => s.joinSpace(a),
35+ leave_space: (s, a) => s.leaveSpace(a),
36+ search_folios: (s, a) => s.search(a),
37+ folio_versions: (s, a) => s.versions(a),
38+ folio_version: (s, a) => s.version(a),
39+ restore_folio_version: (s, a) => s.restoreVersion(a),
40+ folio_templates: (s, a) => s.templates(a),
41+ save_folio_template: (s, a) => s.saveTemplate(a),
42+ delete_folio_template: (s, a) => s.deleteTemplate(a),
43+ export_folio: (s, a) => s.export(a),
44+ folio_suggestions: (s, a) => s.suggestions(a),
45+ decide_folio_suggestion: (s, a) => s.decideSuggestion(a),
46+ folio_proposals: (s, a) => s.proposals(a),
47+ decide_folio_proposal: (s, a) => s.decideProposal(a),
48+ folio_thread: (s, a) => s.thread(a),
49+ folio_threads: (s, a) => s.threads(a),
50+ query_tile: (s, a) => s.queryTile(a),
51+ query_dataset: (s, a) => s.queryDataset(a),
52+ query_dataset_for_agent: (s, a) => s.queryDatasetForAgent(a),
53+ folios_for_agent: (s, a) => s.foliosForAgent(a),
54+ read_folio_for_agent: (s, a) => s.readForAgent(a),
55+ create_folio_as_agent: (s, a) => s.createAsAgent(a),
56+ edit_folio_as_agent: (s, a) => s.editAsAgent(a),
57+ share_folio_as_agent: (s, a) => s.shareAsAgent(a),
58+ recall_folios_for_agent: (s, a) => s.recallForAgent(a),
59+ stale_folios_for_agent: (s, a) => s.staleForAgent(a),
60+ mark_folio_current: (s, a) => s.markCurrent(a),
61+ reindex_folios: (s, a) => s.reindex(a),
62+};
63+
64+export function folioHandler(method: string): Handler | null {
65+ return Object.prototype.hasOwnProperty.call(FOLIO_RPC, method) ? FOLIO_RPC[method as FolioRpcMethod] : null;
66+}
+2443−0
1+/**
2+ * Folios (Artifacts mode): the docs 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+ * Plan and decisions: docs/ARTIFACTS_MODE.md.
6+ *
7+ * Every read goes through one rule (src/access.ts `effectiveRole`) over
8+ * the folio's chain, after the list SQL's coarse filter (`folio_access`,
9+ * readable spaces, general access, link visits). Everything that changes
10+ * a folio's content goes through its room (src/folios/room.ts); this
11+ * class decides who may ask. Agents act for a person and never reach
12+ * more than that person can, narrowed to their audience
13+ * (src/folios/agents.ts).
14+ */
15+import {
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 FolioPassage,
58+ type FolioProposal,
59+ type FolioRef,
60+ type FolioSearchHit,
61+ type FolioSuggestion,
62+ type FolioTemplate,
63+ type FolioTreeNode,
64+ type FolioVersion,
65+ type FolioVersionDetail,
66+ type FoliosLiveEvent,
67+ type FoliosSidebar,
68+ type FoliosSidebarSpace,
69+ type MemberProfile,
70+ type Repo,
71+ type Result,
72+ type ServiceBinding,
73+ type User,
74+ type Viewer,
75+ type Workspace,
76+ type WorkspaceAgent,
77+} from "@g1t/contracts";
78+
79+import { RANK, aclChain, atLeast, canShare, explicitAccess, inheritsSpace, isPrivateFolio, isRole, personKeys, type Person, type SpaceRules } from "../access.ts";
80+import { diffLines } from "../diff.ts";
81+import { fileStore, safeName, servedType } from "../files.ts";
82+import { adapters, folioAdapters, forgetFolios, indexFolio, startBackfill, ensureIndexed, type DocsJob } from "../indexer.ts";
83+import { kindModel } from "../kinds/index.ts";
84+import type { FolioOrigin } from "../kinds/types.ts";
85+import { excerpt, searchText } from "../markdown.ts";
86+import { QueryCache, fuseRanks, pickPassages, queryKey, recallLimit, vectorQueryPlan, MEANING_FLOOR, WORDS_SCORE, type Candidate } from "../recall.ts";
87+import type { RepoSpaceRow } from "../repo-spaces.ts";
88+import { ROOM_MEMBER_HEADER } from "../room.ts";
89+import { ftsAnyQuery, ftsQuery, projectRef } from "../search.ts";
90+import type { ThreadResult } from "../threads.ts";
91+import { placeBefore } from "../tree.ts";
92+import { Who, now, rulesOf, userKey, type Space, type WhoEnv } from "../who.ts";
93+import {
94+ FOLIO_COLUMNS,
95+ aclNode,
96+ ancestry,
97+ folioColumns,
98+ foliosById,
99+ json,
100+ readableWhere,
101+ rebuildSubtree,
102+ rolesFrom,
103+ runBatches,
104+ subtree,
105+ visitsOf,
106+ workspaceReadable,
107+ type Ancestry,
108+ type FolioRow,
109+ type ReaderContext,
110+} from "./access-store.ts";
111+import { agentMayFind, agentReach, audienceRule, type AgentReach, type AudienceRule } from "./agents.ts";
112+import { publishFolioEvent } from "./events.ts";
113+import { MAX_DEPTH, cleanCover, cleanIcon, cleanNote, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, sharedTops, slugOf, subtreeHeight, treeNodes } from "./list.ts";
114+import type { FolioRoom } from "./room.ts";
115+import { builtinFolioTemplate, builtinFolioTemplates } from "./templates.ts";
116+
117+export type FoliosEnv = WhoEnv & {
118+ FOLIOS: DurableObjectNamespace<FolioRoom>;
119+ NOTIFY?: ServiceBinding;
120+ EVENTS?: ServiceBinding;
121+ REPOS?: ServiceBinding;
122+ AI?: Ai;
123+ VECTORS?: Vectorize;
124+ FOLIO_VECTORS?: Vectorize;
125+ JOBS?: Queue<DocsJob>;
126+ FILES?: R2Bucket;
127+ DOCS_FILES?: string;
128+ DOCS_S3_ENDPOINT?: string;
129+ DOCS_S3_BUCKET?: string;
130+ DOCS_S3_REGION?: string;
131+ DOCS_S3_ACCESS_KEY_ID?: string;
132+ DOCS_S3_SECRET_ACCESS_KEY?: string;
133+ DOCS_S3_VIRTUAL_HOSTED?: string;
134+};
135+
136+type Args = { workspace: string; viewer: Viewer };
137+type AgentArgs = Args & { agent_id: string; audience?: DocAudience | null };
138+
139+/** The viewer in their workspace, with their spaces. */
140+type Ctx = { workspace: Workspace; viewer: User; key: string; person: Person; spaces: Space[]; spaceById: Map<string, Space>; owner: boolean };
141+
142+/** An agent's turn: its asker's context, the agent, and who will see the answer. */
143+type AgentCtx = Ctx & { agent: WorkspaceAgent; agentKey: string; rule: AudienceRule; people: Person[]; audienceIds: string[] };
144+
145+type SuggestionRow = {
146+ id: string;
147+ folio_id: string;
148+ author: string;
149+ asked_by: string | null;
150+ target: string;
151+ before_markdown: string;
152+ after_markdown: string;
153+ note: string | null;
154+ status: DocSuggestion["status"];
155+ created_at: string;
156+ decided_by: string | null;
157+ decided_at: string | null;
158+ marks_current: number;
159+};
160+
161+type 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 };
162+
163+/** Queries' embeddings, a minute per isolate. */
164+const queryVectors = new QueryCache();
165+
166+/** Open rooms told of an access change inline; a larger subtree's go with the queue job. */
167+const INLINE_ROOMS = 200;
168+const MAX_TEXT = 512 * 1024;
169+
170+const parseJson = <T>(value: string | null | undefined, fallback: T): T => {
171+ if (!value) return fallback;
172+ try {
173+ return JSON.parse(value) as T;
174+ } catch {
175+ return fallback;
176+ }
177+};
178+
179+const kindLabel = (kind: FolioKind) => FOLIO_KIND_LABELS[kind] ?? kind;
180+
181+export class Folios {
182+ readonly who: Who;
183+
184+ constructor(
185+ private readonly env: FoliosEnv,
186+ private readonly defer: (work: Promise<unknown>) => void = () => {},
187+ ) {
188+ this.who = new Who(env);
189+ }
190+
191+ private get db() {
192+ return this.env.DB;
193+ }
194+
195+ room(folioId: string) {
196+ return this.env.FOLIOS.get(this.env.FOLIOS.idFromName(folioId));
197+ }
198+
199+ private tell(folioId: string, event: FoliosLiveEvent): void {
200+ this.defer(
201+ this.room(folioId)
202+ .notice(event)
203+ .catch((error: unknown) => console.error("folios could not tell a room", folioId, String(error))),
204+ );
205+ }
206+
207+ /** The room, named and given its kind and (when empty) its saved text. */
208+ private async ready(workspace: Workspace, row: FolioRow) {
209+ const room = this.room(row.id);
210+ let text = row.text;
211+ if (!text) text = (await this.db.prepare("SELECT text FROM folios WHERE id = ?").bind(row.id).first<{ text: string }>())?.text ?? "";
212+ await room.ensure({ folio_id: row.id, kind: row.kind, workspace_slug: workspace.slug, text });
213+ return room;
214+ }
215+
216+ // ── Who, where, and what they may do ────────────────────────────────────
217+
218+ private async ctx(slug: string, viewer: Viewer): Promise<Result<Ctx>> {
219+ const found = await this.who.viewerWorkspace(slug, viewer);
220+ if (!found.ok) return found;
221+ const workspace = found.value;
222+ const user = viewer!;
223+ await this.who.ensureDefault(workspace, user);
224+ const person = await this.who.viewerPerson(workspace, user);
225+ const spaces = await this.who.spacesFor(workspace, person);
226+ return ok({ workspace, viewer: user, key: userKey(user), person, spaces, spaceById: new Map(spaces.map((s) => [s.row.id, s])), owner: this.who.viewerOwner(user, workspace.slug) });
227+ }
228+
229+ private reader(ctx: Ctx, visits: ReadonlySet<string>): ReaderContext {
230+ return { person: ctx.person, spaceRole: (id) => ctx.spaceById.get(id)?.role ?? null, visits };
231+ }
232+
233+ /** The viewer's role on each row, from each one's whole chain. */
234+ private async roles(ctx: Ctx, rows: FolioRow[], extraVisits: string[] = []): Promise<{ roles: Map<string, DocRole | null>; found: Ancestry }> {
235+ const [found, visits] = await Promise.all([ancestry(this.db, rows), visitsOf(this.db, ctx.viewer.id, rows)]);
236+ for (const id of extraVisits) visits.add(id);
237+ return { roles: rolesFrom(found, rows, this.reader(ctx, visits)), found };
238+ }
239+
240+ /**
241+ * A folio the viewer may `need`-access, or not found when they can't
242+ * read it at all. `opening` counts as opening its link (the `folio`
243+ * read and the live socket), which is what makes a link folio readable.
244+ */
245+ private async open(ctx: Ctx, folioId: unknown, need: DocRole, options: { trashed?: boolean; opening?: boolean; text?: boolean } = {}): Promise<Result<{ row: FolioRow; role: DocRole; found: Ancestry }>> {
246+ const columns = options.text ? FOLIO_COLUMNS.replace("'' AS text", "text") : FOLIO_COLUMNS;
247+ const row = await this.db.prepare(`SELECT ${columns} FROM folios WHERE id = ? AND workspace_id = ?`).bind(String(folioId ?? ""), ctx.workspace.id).first<FolioRow>();
248+ if (!row) return fail("not_found", "No such artifact.");
249+ if (row.trashed_at && !options.trashed) return fail("not_found", "That artifact is in the trash.");
250+ const { roles, found } = await this.roles(ctx, [row], options.opening ? [row.id] : []);
251+ const role = roles.get(row.id) ?? null;
252+ if (!role) return fail("not_found", "No such artifact.");
253+ if (!atLeast(role, need)) {
254+ 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.";
255+ return fail("forbidden", message);
256+ }
257+ return ok({ row, role, found });
258+ }
259+
260+ private agentMode(row: Pick<FolioRow, "agent_mode" | "space_id">, ctx: Ctx): DocAgentMode {
261+ return row.agent_mode ?? (row.space_id ? ctx.spaceById.get(row.space_id)?.row.agent_mode : null) ?? "suggest";
262+ }
263+
264+ ref(slug: string, row: Pick<FolioRow, "id" | "kind" | "title" | "icon">): FolioRef {
265+ const s = slugOf(row.title, row.id);
266+ return { id: row.id, kind: row.kind, title: row.title, icon: row.icon, slug: s, path: `/${slug}/-/artifacts/${s}` };
267+ }
268+
269+ /** Folios as lists and pages show them, for the viewer. Rows without a role are left out. */
270+ private async toFolios(ctx: Ctx, rows: FolioRow[], known?: { roles: Map<string, DocRole | null>; found: Ancestry }): Promise<Folio[]> {
271+ if (!rows.length) return [];
272+ const { roles, found } = known ?? (await this.roles(ctx, rows));
273+ const readable = rows.filter((r) => roles.get(r.id));
274+ if (!readable.length) return [];
275+ const ids = readable.map((r) => r.id);
276+ const [favorites, counts, kids, stale] = await Promise.all([
277+ 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 }>(),
278+ 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 }>(),
279+ 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 }>(),
280+ this.staleIds(ids),
281+ ]);
282+ const people = await this.who.profiles(
283+ ctx.workspace,
284+ readable.flatMap((r) => [r.owner, r.created_by, ...(r.edited_by ? [r.edited_by] : [])]),
285+ );
286+ const fav = new Set(favorites.results.map((f) => f.folio_id));
287+ const shared = new Map(counts.results.map((c) => [c.folio_id, c.n]));
288+ const parents = new Set(kids.results.map((k) => k.parent_id));
289+ return readable.map((row) => {
290+ const chain = aclChain(row.id, found.nodes);
291+ const root = chain[chain.length - 1] ?? aclNode(row);
292+ const space = row.space_id ? ctx.spaceById.get(row.space_id) : undefined;
293+ const parent = row.parent_id ? found.rows.get(row.parent_id) : undefined;
294+ let inherited: Folio["inherited_from"] = null;
295+ if (row.inherit && parent) inherited = { kind: "folio", id: parent.id, name: parent.title || "Untitled" };
296+ else if (row.inherit && !row.parent_id && space) inherited = { kind: "space", id: space.row.id, name: space.row.name };
297+ const preview = parseJson<Folio["preview"]>(row.preview, null);
298+ return {
299+ ...this.ref(ctx.workspace.slug, row),
300+ workspace_id: row.workspace_id,
301+ space: space ? { id: space.row.id, slug: space.row.slug, name: space.row.name, kind: space.row.kind } : null,
302+ parent_id: row.parent_id,
303+ position: row.position,
304+ owner: people.get(row.owner)!,
305+ created_by: people.get(row.created_by)!,
306+ created_at: row.created_at,
307+ updated_at: row.updated_at,
308+ edited_by: row.edited_by ? (people.get(row.edited_by) ?? null) : null,
309+ edited_at: row.edited_at,
310+ trashed_at: row.trashed_at,
311+ viewer_role: roles.get(row.id)!,
312+ favorite: fav.has(row.id),
313+ private: isPrivateFolio(chain, found.grants),
314+ shared_count: shared.get(row.id) ?? 0,
315+ general_access: root.general_access,
316+ general_role: root.general_access === "none" ? null : ((root.general_role as Folio["general_role"]) ?? "view"),
317+ inherit: !!row.inherit,
318+ inherited_from: inherited,
319+ agent_mode: this.agentMode(row, ctx),
320+ excerpt: row.excerpt,
321+ preview,
322+ source: parseJson<Folio["source"]>(row.source, null),
323+ stale: stale.has(row.id),
324+ has_children: parents.has(row.id),
325+ };
326+ });
327+ }
328+
329+ private async staleIds(ids: string[]): Promise<Set<string>> {
330+ if (!ids.length) return new Set();
331+ const rows = await this.db
332+ .prepare("SELECT DISTINCT folio_id FROM folio_changes WHERE cleared_at IS NULL AND folio_id IN (SELECT value FROM json_each(?))")
333+ .bind(json(ids))
334+ .all<{ folio_id: string }>();
335+ return new Set(rows.results.map((r) => r.folio_id));
336+ }
337+
338+ private async folioOf(ctx: Ctx, row: FolioRow): Promise<Folio> {
339+ const fresh = (await foliosById(this.db, [row.id])).get(row.id) ?? row;
340+ const [folio] = await this.toFolios(ctx, [fresh]);
341+ return folio!;
342+ }
343+
344+ /** The keys and spaces the list filter reads. */
345+ private filterOf(ctx: Ctx) {
346+ return readableWhere(
347+ personKeys(ctx.person),
348+ ctx.spaces.filter((s) => s.role).map((s) => s.row.id),
349+ ctx.viewer.id,
350+ );
351+ }
352+
353+ // ── Lists ───────────────────────────────────────────────────────────────
354+
355+ async list(a: Args & { query: FolioListQuery }): Promise<Result<FolioList>> {
356+ const query = a.query ?? ({ tab: "all" } as FolioListQuery);
357+ const invalid = folioListQueryError({ ...query, tab: query.tab ?? "all" });
358+ if (invalid) return fail("invalid", invalid);
359+ const found = await this.ctx(a.workspace, a.viewer);
360+ if (!found.ok) return found;
361+ return ok(await this.listFor(found.value, { ...query, tab: query.tab ?? "all" }));
362+ }
363+
364+ private async listFor(ctx: Ctx, query: FolioListQuery): Promise<FolioList> {
365+ const limit = listLimit(query.limit);
366+ if (query.q && ftsQuery(query.q)) {
367+ // Words or meaning: the search's order, the list's filters.
368+ 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 });
369+ const rows = await foliosById(
370+ this.db,
371+ hits.map((h) => h.id),
372+ );
373+ 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));
374+ return { items: await this.toFolios(ctx, ordered), next_cursor: null };
375+ }
376+ const keys = personKeys(ctx.person);
377+ const where: string[] = ["f.workspace_id = ?", "f.trashed_at IS NULL"];
378+ const binds: unknown[] = [ctx.workspace.id];
379+ let sortKey = "f.edited_at";
380+ const sortBinds: unknown[] = [];
381+ if (query.tab === "yours") {
382+ where.push("f.owner = ?");
383+ binds.push(ctx.key);
384+ } else if (query.tab === "shared") {
385+ where.push(
386+ "f.owner <> ?",
387+ `(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))))`,
388+ );
389+ binds.push(ctx.key, json(keys), ctx.viewer.id);
390+ 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(?))), ''))";
391+ sortBinds.push(json(keys));
392+ } else {
393+ const filter = this.filterOf(ctx);
394+ where.push(filter.sql);
395+ binds.push(...filter.binds);
396+ }
397+ if (query.kinds?.length) {
398+ where.push("f.kind IN (SELECT value FROM json_each(?))");
399+ binds.push(json(query.kinds));
400+ }
401+ if (query.space_id === "private") where.push("f.space_id IS NULL");
402+ else if (query.space_id) {
403+ where.push("f.space_id = ?");
404+ binds.push(query.space_id);
405+ }
406+ if (query.owner) {
407+ where.push("f.owner = ?");
408+ binds.push(query.owner);
409+ }
410+ const project = query.project ? projectRef(query.project) : null;
411+ if (query.project && !project) return { items: [], next_cursor: null };
412+ if (project) {
413+ 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 = ?))");
414+ binds.push(project, project);
415+ }
416+ const cursor = decodeCursor(query.cursor);
417+ if (cursor) {
418+ where.push(`(${sortKey} < ? OR (${sortKey} = ? AND f.id < ?))`);
419+ binds.push(...sortBinds, cursor.k, ...sortBinds, cursor.k, cursor.id);
420+ }
421+ const rows = (
422+ await this.db
423+ .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 ?`)
424+ .bind(...sortBinds, ...binds, limit + 1)
425+ .all<FolioRow & { sort_key: string }>()
426+ ).results;
427+ const page = rows.slice(0, limit);
428+ const last = page[page.length - 1];
429+ return { items: await this.toFolios(ctx, page), next_cursor: rows.length > limit && last ? encodeCursor({ k: last.sort_key, id: last.id }) : null };
430+ }
431+
432+ async sidebar(a: Args): Promise<Result<FoliosSidebar>> {
433+ const found = await this.ctx(a.workspace, a.viewer);
434+ if (!found.ok) return found;
435+ const ctx = found.value;
436+ const joins = new Set(
437+ (await this.db.prepare("SELECT space_id FROM space_joins WHERE user_id = ?").bind(ctx.viewer.id).all<{ space_id: string }>()).results.map((r) => r.space_id),
438+ );
439+ // Joined open spaces (General always), team spaces of theirs, Members-only spaces they're in.
440+ const shown = ctx.spaces.filter((s) => s.role && !s.row.archived_at && (s.row.kind !== "workspace" || s.row.is_default || joins.has(s.row.id)));
441+ const keys = personKeys(ctx.person);
442+ const [spaceRows, privateRows, sharedRows, favoriteRows, repos, trashed] = await Promise.all([
443+ shown.length
444+ ? this.db
445+ .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`)
446+ .bind(
447+ ctx.workspace.id,
448+ json(shown.map((s) => s.row.id)),
449+ )
450+ .all<FolioRow>()
451+ : Promise.resolve({ results: [] as FolioRow[] }),
452+ // Their Private: everything under a top-level Private folio of theirs.
453+ this.db
454+ .prepare(
455+ `SELECT ${folioColumns("f")} FROM folios f JOIN folios t ON t.id = substr(f.path, 2, instr(substr(f.path, 2), '/') - 1)
456+ 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`,
457+ )
458+ .bind(ctx.workspace.id, ctx.key)
459+ .all<FolioRow>(),
460+ this.db
461+ .prepare(
462+ `SELECT ${folioColumns("f")} FROM folios f JOIN folios r ON r.id = f.acl_root
463+ WHERE f.workspace_id = ? AND f.trashed_at IS NULL AND f.owner <> ?
464+ AND (f.id IN (SELECT folio_id FROM folio_access WHERE principal IN (SELECT value FROM json_each(?)))
465+ 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))))
466+ ORDER BY f.edited_at DESC LIMIT 300`,
467+ )
468+ .bind(ctx.workspace.id, ctx.key, json(keys), ctx.viewer.id)
469+ .all<FolioRow>(),
470+ this.db
471+ .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`)
472+ .bind(ctx.viewer.id, ctx.workspace.id)
473+ .all<FolioRow>(),
474+ this.repoSpacesFor(ctx).catch((error: unknown) => {
475+ console.error("folios could not list projects' docs", String(error));
476+ return [] as DocRepoSpace[];
477+ }),
478+ this.trashedFor(ctx, 200),
479+ ]);
480+ const all = [...spaceRows.results, ...privateRows.results, ...sharedRows.results, ...favoriteRows.results];
481+ const unique = [...new Map(all.map((r) => [r.id, r])).values()];
482+ const { roles } = await this.roles(ctx, unique);
483+ const can = (r: FolioRow) => !!roles.get(r.id);
484+ const inSpaces = spaceRows.results.filter(can);
485+ const mine = privateRows.results.filter(can);
486+ const stale = await this.staleIds([...inSpaces, ...mine].map((r) => r.id));
487+ const elsewhere = new Set([...inSpaces, ...mine].map((r) => r.id));
488+ const spaceCounts = new Map<string, number>();
489+ for (const r of inSpaces) spaceCounts.set(r.space_id!, (spaceCounts.get(r.space_id!) ?? 0) + 1);
490+ const spaces: FoliosSidebarSpace[] = shown.map((s) => ({
491+ ...this.who.toSpace(s, spaceCounts.get(s.row.id) ?? 0),
492+ joined: s.row.kind !== "workspace" || !!s.row.is_default || joins.has(s.row.id),
493+ tree: treeNodes(
494+ inSpaces.filter((r) => r.space_id === s.row.id),
495+ stale,
496+ ),
497+ }));
498+ const ref = (r: FolioRow) => this.ref(ctx.workspace.slug, r);
499+ return ok({
500+ favorites: favoriteRows.results.filter(can).map(ref),
501+ spaces,
502+ private_tree: treeNodes(mine, stale),
503+ shared: sharedTops(sharedRows.results.filter(can), elsewhere).slice(0, 100).map(ref),
504+ repos,
505+ can_create_space: true,
506+ trash_count: trashed.length,
507+ stale_count: stale.size,
508+ });
509+ }
510+
511+ async folio(a: Args & { folio_id: string }): Promise<Result<Folio>> {
512+ const found = await this.ctx(a.workspace, a.viewer);
513+ if (!found.ok) return found;
514+ const ctx = found.value;
515+ const opened = await this.open(ctx, a.folio_id, "view", { trashed: true, opening: true });
516+ if (!opened.ok) return opened;
517+ const at = now();
518+ this.defer(
519+ this.db
520+ .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")
521+ .bind(opened.value.row.id, ctx.viewer.id, at, at)
522+ .run(),
523+ );
524+ const [folio] = await this.toFolios(ctx, [opened.value.row], { roles: new Map([[opened.value.row.id, opened.value.role]]), found: opened.value.found });
525+ return ok(folio!);
526+ }
527+
528+ // ── Making and changing ─────────────────────────────────────────────────
529+
530+ /** Where a new folio may go for this person: a parent doc they can edit, a space they can edit, or their Private. */
531+ private async placeFor(ctx: Ctx, input: { space_id?: string | null; parent_id?: string | null }): Promise<Result<{ space_id: string | null; parent: FolioRow | null }>> {
532+ if (input.parent_id) {
533+ const parent = await this.open(ctx, input.parent_id, "edit");
534+ 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.");
535+ if (parent.value.row.kind !== "doc") return fail("invalid", "Only a doc can hold other artifacts.");
536+ if (depthOf(parent.value.row.path) >= MAX_DEPTH) return fail("invalid", `Artifacts go at most ${MAX_DEPTH} deep.`);
537+ return ok({ space_id: parent.value.row.space_id, parent: parent.value.row });
538+ }
539+ if (input.space_id) {
540+ const space = ctx.spaceById.get(input.space_id);
541+ if (!space?.role || space.row.archived_at) return fail("not_found", "No such space.");
542+ if (!atLeast(space.role, "edit")) return fail("forbidden", `You can read ${space.row.name} but not add to it.`);
543+ return ok({ space_id: space.row.id, parent: null });
544+ }
545+ return ok({ space_id: null, parent: null });
546+ }
547+
548+ /** Where a new folio starts: a template's or the given content, and its title and icon. */
549+ 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 }>> {
550+ let text = "";
551+ let spec: unknown = undefined;
552+ let title = cleanTitle(input.title);
553+ let icon = cleanIcon(input.icon);
554+ if (input.template_id) {
555+ const template = builtinFolioTemplate(input.template_id) ?? (await this.savedTemplate(ctx.workspace, input.template_id));
556+ if (!template) return fail("not_found", "No such template.");
557+ if (template.kind !== kind) return fail("invalid", `That template is for ${kindLabel(template.kind)}, not ${kindLabel(kind)}.`);
558+ if (kind === "doc" || kind === "slides") text = template.body;
559+ else spec = parseJson(template.body, null);
560+ if (!title) title = template.name;
561+ if (!icon) icon = template.icon;
562+ } else if (input.content) {
563+ if ("markdown" in input.content) text = String(input.content.markdown ?? "").slice(0, MAX_TEXT);
564+ else spec = input.content.spec;
565+ }
566+ return ok({ text, spec, title, icon });
567+ }
568+
569+ /** Whether a member key may be shared with: a member, an agent or a team of this workspace. */
570+ private async principalExists(ctx: Ctx, principal: string): Promise<boolean> {
571+ const p = parsePrincipalKey(principal);
572+ if (principal.startsWith("team:")) {
573+ const slug = principal.slice(5).toLowerCase();
574+ return [...(await this.who.teamsOf(ctx.workspace)).values()].some((set) => set.has(slug));
575+ }
576+ if (!p) return false;
577+ if (p.kind === "agent") {
578+ const agent = (await this.who.agentsById([p.id])).get(p.id);
579+ return !!agent && agent.workspace_id === ctx.workspace.id && !agent.archived_at;
580+ }
581+ await this.who.nameUsers([p.id]);
582+ const username = this.who.usernames.get(p.id);
583+ return !!username && (await this.who.members(ctx.workspace)).has(username.toLowerCase());
584+ }
585+
586+ /** Inserts a folio and fills its room. */
587+ private async insertFolio(
588+ ctx: Ctx,
589+ input: {
590+ kind: FolioKind;
591+ owner: string;
592+ created_by: string;
593+ space_id: string | null;
594+ parent: FolioRow | null;
595+ title: string;
596+ icon: string | null;
597+ text: string;
598+ spec?: unknown;
599+ state?: Uint8Array | null;
600+ inherit?: boolean;
601+ source?: { title: string; href: string } | null;
602+ grants?: { principal: string; role: DocRole }[];
603+ position?: number;
604+ },
605+ ): Promise<FolioRow> {
606+ const id = newId("fol");
607+ const at = now();
608+ const siblings = input.parent
609+ ? await this.db.prepare("SELECT MAX(position) AS p FROM folios WHERE parent_id = ?").bind(input.parent.id).first<{ p: number | null }>()
610+ : input.space_id
611+ ? 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 }>()
612+ : 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 }>();
613+ const position = input.position ?? (siblings?.p ?? 0) + 1024;
614+ const inherit = input.inherit ?? true;
615+ const aclRoot = !inherit || !input.parent ? id : input.parent.acl_root;
616+ const path = input.parent ? `${input.parent.path}${id}/` : `/${id}/`;
617+ const row: FolioRow = {
618+ id,
619+ workspace_id: ctx.workspace.id,
620+ kind: input.kind,
621+ title: input.title,
622+ icon: input.icon,
623+ cover: null,
624+ owner: input.owner,
625+ space_id: input.space_id,
626+ parent_id: input.parent?.id ?? null,
627+ position,
628+ inherit: inherit ? 1 : 0,
629+ acl_root: aclRoot,
630+ path,
631+ general_access: "none",
632+ general_role: null,
633+ agent_mode: null,
634+ text: input.text,
635+ excerpt: excerpt(input.text),
636+ preview: null,
637+ source: input.source ? JSON.stringify(input.source) : null,
638+ mentioned: "[]",
639+ created_by: input.created_by,
640+ created_at: at,
641+ updated_by: input.created_by,
642+ updated_at: at,
643+ edited_by: input.created_by,
644+ edited_at: at,
645+ trashed_at: null,
646+ trashed_by: null,
647+ };
648+ const grants = (input.grants ?? []).filter((g) => g.principal !== input.owner);
649+ await this.db.batch([
650+ this.db
651+ .prepare(
652+ `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)
653+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
654+ )
655+ .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),
656+ this.db.prepare("INSERT INTO folios_fts (folio_id, kind, title, body) VALUES (?, ?, ?, ?)").bind(id, row.kind, row.title, searchText(row.text)),
657+ 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),
658+ ...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)),
659+ ]);
660+ await rebuildSubtree(this.db, id);
661+ const room = this.room(id);
662+ await room.ensure({ folio_id: id, kind: row.kind, workspace_slug: ctx.workspace.slug, text: input.text, spec: input.spec, state: input.state ?? null });
663+ // The rendition the room makes of it, its card, links and citations, now.
664+ await room.flush();
665+ const open = await workspaceReadable(this.db, id).catch(() => false);
666+ this.defer(
667+ 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),
668+ );
669+ this.defer(indexFolio(this.env, id));
670+ return (await foliosById(this.db, [id])).get(id) ?? row;
671+ }
672+
673+ /** Grants asked for at creation: people, agents and teams of this workspace, never above `edit` for teams' sake of sense. */
674+ private async cleanShares(ctx: Ctx, share: { principal: string; role: DocRole }[] | null | undefined): Promise<Result<{ principal: string; role: DocRole }[]>> {
675+ const out: { principal: string; role: DocRole }[] = [];
676+ for (const s of (share ?? []).slice(0, FOLIO_MAX_SHARE)) {
677+ const principal = s.principal.startsWith("team:") ? `team:${s.principal.slice(5).toLowerCase()}` : s.principal;
678+ if (!(await this.principalExists(ctx, principal))) return fail("invalid", `${s.principal} isn't a member, agent or team of this workspace.`);
679+ out.push({ principal, role: s.role });
680+ }
681+ return ok(out);
682+ }
683+
684+ async create(a: Args & { input: Parameters<typeof newFolioError>[0] }): Promise<Result<Folio>> {
685+ const input = a.input ?? ({ kind: "doc" } as Parameters<typeof newFolioError>[0]);
686+ const invalid = newFolioError(input);
687+ if (invalid) return fail("invalid", invalid);
688+ if (!kindModel(input.kind)) return fail("invalid", `${kindLabel(input.kind)} aren't here yet.`);
689+ const found = await this.ctx(a.workspace, a.viewer);
690+ if (!found.ok) return found;
691+ const ctx = found.value;
692+ const place = await this.placeFor(ctx, input);
693+ if (!place.ok) return place;
694+ const start = await this.startingPoint(ctx, input.kind, input);
695+ if (!start.ok) return start;
696+ const shares = await this.cleanShares(ctx, input.share_with);
697+ if (!shares.ok) return shares;
698+ const row = await this.insertFolio(ctx, {
699+ kind: input.kind,
700+ owner: ctx.key,
701+ created_by: ctx.key,
702+ space_id: place.value.space_id,
703+ parent: place.value.parent,
704+ title: start.value.title,
705+ icon: start.value.icon,
706+ text: start.value.text,
707+ spec: start.value.spec,
708+ source: cleanSource(input.source),
709+ grants: shares.value,
710+ });
711+ return ok(await this.folioOf(ctx, row));
712+ }
713+
714+ async update(a: Args & { folio_id: string; change: FolioChange }): Promise<Result<Folio>> {
715+ const found = await this.ctx(a.workspace, a.viewer);
716+ if (!found.ok) return found;
717+ const ctx = found.value;
718+ const opened = await this.open(ctx, a.folio_id, "edit");
719+ if (!opened.ok) return opened;
720+ const { row } = opened.value;
721+ const c = a.change ?? {};
722+ const sets: string[] = [];
723+ const values: unknown[] = [];
724+ const statements: D1PreparedStatement[] = [];
725+ if (c.title !== undefined) {
726+ sets.push("title = ?");
727+ values.push(cleanTitle(c.title));
728+ statements.push(this.db.prepare("UPDATE folios_fts SET title = ? WHERE folio_id = ?").bind(cleanTitle(c.title), row.id));
729+ }
730+ if (c.icon !== undefined) {
731+ sets.push("icon = ?");
732+ values.push(cleanIcon(c.icon));
733+ }
734+ if (c.cover !== undefined) {
735+ sets.push("cover = ?");
736+ values.push(cleanCover(c.cover));
737+ }
738+ if (sets.length) {
739+ sets.push("updated_at = ?", "updated_by = ?");
740+ values.push(now(), ctx.key);
741+ statements.unshift(this.db.prepare(`UPDATE folios SET ${sets.join(", ")} WHERE id = ?`).bind(...values, row.id));
742+ }
743+ if (c.projects !== undefined) {
744+ statements.push(this.db.prepare("DELETE FROM folio_projects WHERE folio_id = ?").bind(row.id));
745+ const projects = [...new Set((Array.isArray(c.projects) ? c.projects : []).map((p) => projectRef(String(p))).filter((p): p is string => !!p))].slice(0, 20);
746+ for (const repo of projects) statements.push(this.db.prepare("INSERT INTO folio_projects (folio_id, repo) VALUES (?, ?)").bind(row.id, repo));
747+ }
748+ if (statements.length) await this.db.batch(statements);
749+ const folio = await this.folioOf(ctx, row);
750+ this.tell(row.id, { type: "folio.updated", folio });
751+ if (c.title !== undefined && cleanTitle(c.title) !== row.title) this.defer(indexFolio(this.env, row.id));
752+ return ok(folio);
753+ }
754+
755+ async move(a: Args & { folio_id: string; move: FolioMove }): Promise<Result<Folio>> {
756+ const found = await this.ctx(a.workspace, a.viewer);
757+ if (!found.ok) return found;
758+ const ctx = found.value;
759+ const opened = await this.open(ctx, a.folio_id, "edit");
760+ if (!opened.ok) return opened;
761+ const { row } = opened.value;
762+ const move = a.move ?? ({ space_id: null, parent_id: null } as FolioMove);
763+ let spaceId: string | null;
764+ let parent: FolioRow | null = null;
765+ if (move.parent_id) {
766+ const target = await this.open(ctx, move.parent_id, "edit");
767+ 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.");
768+ parent = target.value.row;
769+ if (parent.kind !== "doc") return fail("invalid", "Only a doc can hold other artifacts.");
770+ if (parent.path.startsWith(row.path)) return fail("invalid", "An artifact can't go inside itself.");
771+ spaceId = parent.space_id;
772+ } else if (move.space_id) {
773+ const space = ctx.spaceById.get(move.space_id);
774+ if (!space?.role || space.row.archived_at) return fail("not_found", "No such space.");
775+ if (!atLeast(space.role, "edit")) return fail("forbidden", `You can't add to ${space.row.name}.`);
776+ spaceId = space.row.id;
777+ } else {
778+ // Private is its owner's: only they put something at its top.
779+ if (row.owner !== ctx.key) return fail("forbidden", "Only its owner can move it to their Private section.");
780+ spaceId = null;
781+ }
782+ const below = await subtree(this.db, row);
783+ if (depthOf(parent?.path ?? "") + 1 + subtreeHeight(row, below) > MAX_DEPTH) return fail("invalid", `Artifacts go at most ${MAX_DEPTH} deep.`);
784+ const siblings = (
785+ parent
786+ ? 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 }>()
787+ : spaceId
788+ ? 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 }>()
789+ : await this.db
790+ .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")
791+ .bind(ctx.workspace.id, row.owner)
792+ .all<{ id: string; parent_id: string | null; position: number }>()
793+ ).results;
794+ const placed = placeBefore(siblings, row.id, parent?.id ?? null, move.before_id ?? null);
795+ const statements: D1PreparedStatement[] = [
796+ 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),
797+ ];
798+ for (const [id, position] of placed.renumber) statements.push(this.db.prepare("UPDATE folios SET position = ? WHERE id = ?").bind(position, id));
799+ await this.db.batch(statements);
800+ await this.afterAccessChange(ctx, row.id, below.length);
801+ const folio = await this.folioOf(ctx, row);
802+ this.tell(row.id, { type: "folio.updated", folio });
803+ return ok(folio);
804+ }
805+
806+ /**
807+ * After a move or a sharing change: the subtree's places and
808+ * `folio_access` rebuilt, open rooms told of their people's new roles,
809+ * and passages filed under their new scope. A subtree past
810+ * FOLIO_INLINE_REACL goes to the queue (`folios.reacl`).
811+ */
812+ private async afterAccessChange(ctx: Ctx | null, rootId: string, size: number): Promise<void> {
813+ if (size > FOLIO_INLINE_REACL && this.env.JOBS) {
814+ await this.env.JOBS.send({ type: "folios.reacl", folio_id: rootId });
815+ return;
816+ }
817+ const ids = await rebuildSubtree(this.db, rootId);
818+ this.defer(this.followAccess(ctx?.workspace ?? null, ids));
819+ }
820+
821+ /** Open rooms in these folios re-check each socket's person; the index files their passages under their scope now. */
822+ async followAccess(workspace: Workspace | null, ids: string[]): Promise<void> {
823+ try {
824+ const rows = [...(await foliosById(this.db, ids)).values()];
825+ if (!rows.length) return;
826+ const ws = workspace ?? (await this.workspaceById(rows[0]!.workspace_id));
827+ if (ws) {
828+ for (const row of rows.slice(0, INLINE_ROOMS)) {
829+ const room = this.room(row.id);
830+ const members = await room.members().catch(() => [] as { key: string; name: string }[]);
831+ if (members.length) {
832+ for (const m of members) {
833+ const role = await this.roleOfPerson(ws, row, m.key, m.name);
834+ await room.setRole(m.key, role).catch(() => undefined);
835+ }
836+ await room.notice({ type: "folio.access" }).catch(() => undefined);
837+ }
838+ }
839+ }
840+ for (const row of rows.slice(0, 2000)) await indexFolio(this.env, row.id);
841+ } catch (error) {
842+ console.error("folios could not follow an access change", String(error));
843+ }
844+ }
845+
846+ private async workspaceById(id: string): Promise<Workspace | null> {
847+ const names = await identityClient(this.env.IDENTITY)
848+ .usernames([id])
849+ .catch(() => ({}) as Record<string, string>);
850+ return names[id] ? this.who.workspace(names[id]!) : null;
851+ }
852+
853+ /** Someone's role on a folio, by their member key and username (for open sockets and mentions). */
854+ private async roleOfPerson(workspace: Workspace, row: FolioRow, key: string, username: string): Promise<DocRole | null> {
855+ if (!key.startsWith("user:")) return null;
856+ const userId = key.slice(5);
857+ const member = (await this.who.members(workspace)).get(username.toLowerCase());
858+ if (!member) return null;
859+ const person = await this.who.personOf(workspace, { id: userId, username }, member.role === "owner");
860+ const spaces = await this.who.spacesFor(workspace, person);
861+ const byId = new Map(spaces.map((s) => [s.row.id, s]));
862+ const [found, visits] = await Promise.all([ancestry(this.db, [row]), visitsOf(this.db, userId, [row])]);
863+ return rolesFrom(found, [row], { person, spaceRole: (id) => byId.get(id)?.role ?? null, visits }).get(row.id) ?? null;
864+ }
865+
866+ async duplicate(a: Args & { folio_id: string }): Promise<Result<Folio>> {
867+ const found = await this.ctx(a.workspace, a.viewer);
868+ if (!found.ok) return found;
869+ const ctx = found.value;
870+ const opened = await this.open(ctx, a.folio_id, "view");
871+ if (!opened.ok) return opened;
872+ const { row } = opened.value;
873+ if (!kindModel(row.kind)) return fail("invalid", `${kindLabel(row.kind)} aren't here yet.`);
874+ // Beside the original where they may add, else in their Private. Never shared wider than the original: no grants, no general access.
875+ let space: string | null = null;
876+ let parent: FolioRow | null = null;
877+ if (row.parent_id) {
878+ const p = await this.open(ctx, row.parent_id, "edit");
879+ if (p.ok) {
880+ parent = p.value.row;
881+ space = parent.space_id;
882+ }
883+ } else if (row.space_id && atLeast(ctx.spaceById.get(row.space_id)?.role, "edit")) space = row.space_id;
884+ const besides = !!parent || !!space;
885+ const room = await this.ready(ctx.workspace, row);
886+ const state = await room.state();
887+ const text = await room.text();
888+ const copy = await this.insertFolio(ctx, {
889+ kind: row.kind,
890+ owner: ctx.key,
891+ created_by: ctx.key,
892+ space_id: space,
893+ parent,
894+ title: cleanTitle(`${row.title || "Untitled"} (copy)`),
895+ icon: row.icon,
896+ text,
897+ state,
898+ inherit: besides ? !!row.inherit : true,
899+ position: besides ? row.position + 0.5 : undefined,
900+ });
901+ return ok(await this.folioOf(ctx, copy));
902+ }
903+
904+ async trash(a: Args & { folio_id: string }): Promise<Result<Folio>> {
905+ const found = await this.ctx(a.workspace, a.viewer);
906+ if (!found.ok) return found;
907+ const ctx = found.value;
908+ const opened = await this.open(ctx, a.folio_id, "edit");
909+ if (!opened.ok) return opened;
910+ const { row } = opened.value;
911+ const ids = (await subtree(this.db, row)).filter((r) => !r.trashed_at).map((r) => r.id);
912+ const at = now();
913+ await runBatches(
914+ this.db,
915+ 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)),
916+ );
917+ for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).closeAll("Moved to the trash").catch(() => undefined));
918+ this.defer(forgetFolios(this.env, ids));
919+ const open = await workspaceReadable(this.db, row.id).catch(() => false);
920+ 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));
921+ const [folio] = await this.toFolios(ctx, [{ ...row, trashed_at: at, trashed_by: ctx.key }]);
922+ return ok(folio!);
923+ }
924+
925+ async restore(a: Args & { folio_id: string }): Promise<Result<Folio>> {
926+ const found = await this.ctx(a.workspace, a.viewer);
927+ if (!found.ok) return found;
928+ const ctx = found.value;
929+ const opened = await this.open(ctx, a.folio_id, "edit", { trashed: true });
930+ if (!opened.ok) return opened;
931+ const { row } = opened.value;
932+ if (!row.trashed_at) return fail("invalid", "That artifact isn't in the trash.");
933+ const below = await subtree(this.db, row);
934+ const ids = below.filter((r) => r.trashed_at === row.trashed_at).map((r) => r.id);
935+ const parent = row.parent_id ? (await foliosById(this.db, [row.parent_id])).get(row.parent_id) : null;
936+ const statements = ids.map((id) => this.db.prepare("UPDATE folios SET trashed_at = NULL, trashed_by = NULL WHERE id = ?").bind(id));
937+ // Its parent is gone or still in the trash: it comes back at the top of where it was.
938+ const detach = !!row.parent_id && (!parent || !!parent.trashed_at);
939+ if (detach) statements.push(this.db.prepare("UPDATE folios SET parent_id = NULL WHERE id = ?").bind(row.id));
940+ await runBatches(this.db, statements);
941+ if (detach) await this.afterAccessChange(ctx, row.id, below.length);
942+ else this.defer((async () => { for (const id of ids.slice(0, 2000)) await indexFolio(this.env, id); })());
943+ const open = await workspaceReadable(this.db, row.id).catch(() => false);
944+ this.defer(publishFolioEvent(this.env.EVENTS, "folio.restored", { 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));
945+ return ok(await this.folioOf(ctx, row));
946+ }
947+
948+ async delete(a: Args & { folio_id: string }): Promise<Result<boolean>> {
949+ const found = await this.ctx(a.workspace, a.viewer);
950+ if (!found.ok) return found;
951+ const ctx = found.value;
952+ const opened = await this.open(ctx, a.folio_id, "manage", { trashed: true });
953+ if (!opened.ok) return opened;
954+ const { row } = opened.value;
955+ if (!row.trashed_at) return fail("invalid", "Move it to the trash first.");
956+ // Deepest first, so no parent goes before its children.
957+ const ids = (await subtree(this.db, row)).sort((x, y) => y.path.length - x.path.length).map((r) => r.id);
958+ await forgetFolios(this.env, ids);
959+ await runBatches(
960+ this.db,
961+ ids.flatMap((id) => [this.db.prepare("DELETE FROM folios_fts WHERE folio_id = ?").bind(id), this.db.prepare("DELETE FROM folios WHERE id = ?").bind(id)]),
962+ );
963+ for (const id of ids.slice(0, INLINE_ROOMS)) this.defer(this.room(id).destroy().catch(() => undefined));
964+ return ok(true);
965+ }
966+
967+ /** Trashed folios the viewer may restore: the tops of what went to the trash together. */
968+ private async trashedFor(ctx: Ctx, limit: number): Promise<FolioRow[]> {
969+ const filter = this.filterOf(ctx);
970+ const rows = (
971+ await this.db
972+ .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 ?`)
973+ .bind(ctx.workspace.id, ...filter.binds, limit * 2)
974+ .all<FolioRow>()
975+ ).results;
976+ const { roles } = await this.roles(ctx, rows);
977+ const byId = new Map(rows.map((r) => [r.id, r]));
978+ return rows.filter((r) => atLeast(roles.get(r.id), "edit") && !(r.parent_id && byId.get(r.parent_id)?.trashed_at === r.trashed_at)).slice(0, limit);
979+ }
980+
981+ async trashed(a: Args): Promise<Result<Folio[]>> {
982+ const found = await this.ctx(a.workspace, a.viewer);
983+ if (!found.ok) return found;
984+ return ok(await this.toFolios(found.value, await this.trashedFor(found.value, 200)));
985+ }
986+
987+ async favorite(a: Args & { folio_id: string; on: boolean }): Promise<Result<boolean>> {
988+ const found = await this.ctx(a.workspace, a.viewer);
989+ if (!found.ok) return found;
990+ const ctx = found.value;
991+ const opened = await this.open(ctx, a.folio_id, "view");
992+ if (!opened.ok) return opened;
993+ if (a.on) {
994+ await this.db
995+ .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 = ?), ?)")
996+ .bind(ctx.viewer.id, opened.value.row.id, ctx.viewer.id, now())
997+ .run();
998+ } else {
999+ await this.db.prepare("DELETE FROM folio_favorites WHERE user_id = ? AND folio_id = ?").bind(ctx.viewer.id, opened.value.row.id).run();
1000+ }
1001+ return ok(!!a.on);
1002+ }
1003+
1004+ // ── Content in the agent form, for a person or their token ──────────────
1005+
1006+ private spaceOf(ctx: Ctx, row: FolioRow): FolioAgentRead["space"] {
1007+ const space = row.space_id ? ctx.spaceById.get(row.space_id) : undefined;
1008+ return space ? { id: space.row.id, slug: space.row.slug, name: space.row.name, agent_mode: space.row.agent_mode } : null;
1009+ }
1010+
1011+ async content(a: Args & { folio_id: string }): Promise<Result<FolioAgentRead>> {
1012+ const found = await this.ctx(a.workspace, a.viewer);
1013+ if (!found.ok) return found;
1014+ const ctx = found.value;
1015+ const opened = await this.open(ctx, a.folio_id, "view");
1016+ if (!opened.ok) return opened;
1017+ const { row, role } = opened.value;
1018+ if (!kindModel(row.kind)) return fail("invalid", `${kindLabel(row.kind)} aren't here yet.`);
1019+ const read = await (await this.ready(ctx.workspace, row)).read();
1020+ return ok({
1021+ folio: { ...this.ref(ctx.workspace.slug, row), edited_at: row.edited_at },
1022+ space: this.spaceOf(ctx, row),
1023+ content: read.content,
1024+ ...(read.blocks ? { blocks: read.blocks } : {}),
1025+ can: { read: true, suggest: atLeast(role, "comment"), edit: atLeast(role, "edit") },
1026+ audience_can_read: true,
1027+ });
1028+ }
1029+
1030+ /** What is wrong with an edit for this folio, or null. */
1031+ private editError(row: FolioRow, edit: unknown): string | null {
1032+ const invalid = folioAgentEditError(edit);
1033+ if (invalid) return invalid;
1034+ const e = edit as FolioAgentEdit;
1035+ if (e.kind !== row.kind) return `This is ${kindLabel(row.kind)}, and the edit is for ${kindLabel(e.kind)}.`;
1036+ if (e.kind === "doc" && !cleanTarget(e.target)) return "Say what to change: append, document, a section by its heading, or blocks by id.";
1037+ if (e.kind === "doc" && e.markdown.length > MAX_TEXT) return "That edit is too long.";
1038+ if (e.kind === "doc" && e.target.kind === "append" && !e.markdown.trim()) return "Nothing to add.";
1039+ return null;
1040+ }
1041+
1042+ async edit(a: Args & { folio_id: string; edit: FolioAgentEdit }): Promise<Result<FolioAgentEditResult>> {
1043+ const found = await this.ctx(a.workspace, a.viewer);
1044+ if (!found.ok) return found;
1045+ const ctx = found.value;
1046+ const opened = await this.open(ctx, a.folio_id, "comment");
1047+ if (!opened.ok) return opened;
1048+ const { row, role } = opened.value;
1049+ const invalid = this.editError(row, a.edit);
1050+ if (invalid) return fail("invalid", invalid);
1051+ const edit = a.edit;
1052+ const ref = this.ref(ctx.workspace.slug, row);
1053+ if (atLeast(role, "edit") && !edit.suggest_only) {
1054+ const room = await this.ready(ctx.workspace, row);
1055+ const result = await room.edit(edit, { key: ctx.key, kind: "edit", note: cleanNote(edit.note), authors: [ctx.key] });
1056+ if (!result.applied) return fail("not_found", `${result.summary} Read it again and target what is there now.`);
1057+ if (edit.marks_current) await this.clearStale(row.id, ctx.key);
1058+ return ok({ mode: "applied", version_id: result.version_id, folio: ref, summary: result.summary });
1059+ }
1060+ if (edit.kind !== "doc") return fail("forbidden", `Suggesting changes to ${kindLabel(row.kind)} comes with proposals, which aren't here yet.`);
1061+ 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 });
1062+ return suggestion.ok ? ok({ mode: "suggested", suggestion: suggestion.value, folio: ref }) : suggestion;
1063+ }
1064+
1065+ // ── Sharing ─────────────────────────────────────────────────────────────
1066+
1067+ private async accessList(ctx: Ctx, row: FolioRow, role: DocRole, found: Ancestry): Promise<FolioAccessList> {
1068+ const chain = aclChain(row.id, found.nodes);
1069+ const root = chain[chain.length - 1] ?? aclNode(row);
1070+ const entries = explicitAccess(chain, found.grants);
1071+ const keys = [...entries.keys()];
1072+ const people = await this.who.profiles(
1073+ ctx.workspace,
1074+ keys.filter((k) => !k.startsWith("team:")),
1075+ );
1076+ const rows: FolioAccessRow[] = [];
1077+ for (const [principal, entry] of entries) {
1078+ if (principal === row.owner && entry.via === "owner") continue;
1079+ const via = entry.via === row.id ? null : found.rows.get(entry.via);
1080+ 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" };
1081+ const profile: FolioAccessRow["profile"] = principal.startsWith("team:")
1082+ ? { kind: "team", id: principal.slice(5), name: principal.slice(5), display_name: `@${ctx.workspace.slug}/${principal.slice(5)}` }
1083+ : people.get(principal)!;
1084+ rows.push({ principal, profile, role: entry.role, source });
1085+ }
1086+ rows.sort((x, y) => RANK[y.role] - RANK[x.role] || x.profile.display_name.localeCompare(y.profile.display_name));
1087+ const owner = (await this.who.profiles(ctx.workspace, [row.owner])).get(row.owner)!;
1088+ const space = row.space_id ? ctx.spaceById.get(row.space_id) : undefined;
1089+ const parent = row.parent_id ? found.rows.get(row.parent_id) : undefined;
1090+ let inherited: FolioAccessList["inherited_from"] = null;
1091+ if (row.inherit && parent) inherited = { kind: "folio", id: parent.id, name: parent.title || "Untitled" };
1092+ else if (row.inherit && !row.parent_id && space) inherited = { kind: "space", id: space.row.id, name: space.row.name };
1093+ return {
1094+ folio_id: row.id,
1095+ owner,
1096+ rows,
1097+ general_access: root.general_access,
1098+ general_role: root.general_access === "none" ? null : ((root.general_role as FolioAccessList["general_role"]) ?? "view"),
1099+ inherit: !!row.inherit,
1100+ inherited_from: inherited,
1101+ agent_mode: row.agent_mode,
1102+ can_share: canShare(role),
1103+ public_link: "off",
1104+ };
1105+ }
1106+
1107+ async access(a: Args & { folio_id: string }): Promise<Result<FolioAccessList>> {
1108+ const found = await this.ctx(a.workspace, a.viewer);
1109+ if (!found.ok) return found;
1110+ const ctx = found.value;
1111+ const opened = await this.open(ctx, a.folio_id, "view");
1112+ if (!opened.ok) return opened;
1113+ return ok(await this.accessList(ctx, opened.value.row, opened.value.role, opened.value.found));
1114+ }
1115+
1116+ /** After any sharing change: the rows, the rooms, the index, and the share dialog again. */
1117+ private async afterShare(ctx: Ctx, row: FolioRow): Promise<FolioAccessList> {
1118+ const below = await subtree(this.db, row);
1119+ await this.afterAccessChange(ctx, row.id, below.length);
1120+ const again = await this.open(ctx, row.id, "view", { trashed: true });
1121+ if (!again.ok) {
1122+ // They shared themselves out of it.
1123+ 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" };
1124+ }
1125+ return this.accessList(ctx, again.value.row, again.value.role, again.value.found);
1126+ }
1127+
1128+ async setGrant(a: Args & { folio_id: string; change: FolioAccessChange }): Promise<Result<FolioAccessList>> {
1129+ const change = a.change;
1130+ if (!change || (change.op !== "grant" && change.op !== "revoke")) return fail("invalid", "Grants and revokes only; other changes go to set_folio_general_access.");
1131+ const invalid = folioAccessChangeError(change);
1132+ if (invalid) return fail("invalid", invalid);
1133+ const found = await this.ctx(a.workspace, a.viewer);
1134+ if (!found.ok) return found;
1135+ const ctx = found.value;
1136+ const opened = await this.open(ctx, a.folio_id, "view");
1137+ if (!opened.ok) return opened;
1138+ const { row, role } = opened.value;
1139+ if (!canShare(role)) return fail("forbidden", "Only people with full access can share it.");
1140+ const principal = change.principal.startsWith("team:") ? `team:${change.principal.slice(5).toLowerCase()}` : change.principal;
1141+ if (principal === row.owner) return fail("invalid", "Its owner always has full access.");
1142+ if (change.op === "revoke") {
1143+ await this.db.prepare("DELETE FROM folio_grants WHERE folio_id = ? AND principal = ?").bind(row.id, principal).run();
1144+ return ok(await this.afterShare(ctx, row));
1145+ }
1146+ if (!(await this.principalExists(ctx, principal))) return fail("invalid", "Share with a member, an agent or a team of this workspace.");
1147+ await this.db
1148+ .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")
1149+ .bind(row.id, principal, change.role, ctx.key, now())
1150+ .run();
1151+ const list = await this.afterShare(ctx, row);
1152+ const open = await workspaceReadable(this.db, row.id).catch(() => false);
1153+ this.defer(
1154+ publishFolioEvent(
1155+ this.env.EVENTS,
1156+ "folio.shared",
1157+ { 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 },
1158+ ctx.key,
1159+ ),
1160+ );
1161+ if (principal.startsWith("user:")) this.defer(this.notifyShared(ctx, row, principal.slice(5), change.role, cleanNote(change.notify)));
1162+ return ok(list);
1163+ }
1164+
1165+ /** The person shared with hears of it (they can read it now, so its title may go). */
1166+ private async notifyShared(ctx: Ctx, row: FolioRow, userId: string, role: DocRole, message: string | null): Promise<void> {
1167+ if (!this.env.NOTIFY || userId === ctx.viewer.id) return;
1168+ const me = (await this.who.profiles(ctx.workspace, [ctx.key])).get(ctx.key)!;
1169+ const verb = role === "view" ? "view" : role === "comment" ? "comment on" : "edit";
1170+ await notifyClient(this.env.NOTIFY)
1171+ .notify(
1172+ { user_id: userId },
1173+ {
1174+ id: `folio-shared:${row.id}:${userId}:${Date.now()}`,
1175+ kind: "inbox",
1176+ workspace: ctx.workspace.slug,
1177+ title: `${me.display_name} shared ${row.title || "Untitled"} with you`,
1178+ body: message ?? `You can ${verb} it.`,
1179+ href: this.ref(ctx.workspace.slug, row).path,
1180+ actor: { kind: "user", id: ctx.viewer.id, name: me.display_name, avatar: me.avatar, avatar_seed: null },
1181+ created_at: now(),
1182+ },
1183+ )
1184+ .catch(() => undefined);
1185+ }
1186+
1187+ async setGeneralAccess(a: Args & { folio_id: string; change: FolioAccessChange }): Promise<Result<FolioAccessList>> {
1188+ const change = a.change;
1189+ if (!change || change.op === "grant" || change.op === "revoke") return fail("invalid", "Grants and revokes go to set_folio_grant.");
1190+ const invalid = folioAccessChangeError(change);
1191+ if (invalid) return fail("invalid", invalid);
1192+ const found = await this.ctx(a.workspace, a.viewer);
1193+ if (!found.ok) return found;
1194+ const ctx = found.value;
1195+ const opened = await this.open(ctx, a.folio_id, "view");
1196+ if (!opened.ok) return opened;
1197+ const { row, role } = opened.value;
1198+ if (!canShare(role)) return fail("forbidden", "Only people with full access can change who can open it.");
1199+ const at = now();
1200+ if (change.op === "general") {
1201+ if (row.inherit && row.parent_id) {
1202+ const parent = opened.value.found.rows.get(row.parent_id);
1203+ return fail("invalid", `It follows ${parent?.title || "the doc it's in"}. Change it there, or choose "Only people invited" first.`);
1204+ }
1205+ await this.db
1206+ .prepare("UPDATE folios SET general_access = ?, general_role = ?, updated_at = ?, updated_by = ? WHERE id = ?")
1207+ .bind(change.access, change.access === "none" ? null : change.role, at, ctx.key, row.id)
1208+ .run();
1209+ } else if (change.op === "inherit") {
1210+ if (!row.parent_id && !row.space_id) return fail("invalid", "It's in Private, so there is nothing for it to follow.");
1211+ if (change.inherit && !row.inherit) {
1212+ // Following again: its own general access gives way to what it follows.
1213+ 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();
1214+ } else if (!change.inherit && row.inherit) {
1215+ await this.db.prepare("UPDATE folios SET inherit = 0, updated_at = ?, updated_by = ? WHERE id = ?").bind(at, ctx.key, row.id).run();
1216+ }
1217+ } else if (change.op === "agent_mode") {
1218+ 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();
1219+ const again = await this.open(ctx, row.id, "view");
1220+ if (!again.ok) return again;
1221+ this.tell(row.id, { type: "folio.access" });
1222+ return ok(await this.accessList(ctx, again.value.row, again.value.role, again.value.found));
1223+ }
1224+ return ok(await this.afterShare(ctx, row));
1225+ }
1226+
1227+ async requestAccess(a: Args & { folio_id: string; message?: string | null }): Promise<Result<boolean>> {
1228+ const found = await this.ctx(a.workspace, a.viewer);
1229+ if (!found.ok) return found;
1230+ const ctx = found.value;
1231+ 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>();
1232+ if (!row) return fail("not_found", "No such artifact.");
1233+ const { roles } = await this.roles(ctx, [row]);
1234+ if (roles.get(row.id)) return ok(true);
1235+ if (!this.env.NOTIFY) return ok(true);
1236+ // The owner and anyone with full access through a grant hear of it.
1237+ const managers = (
1238+ 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 }>()
1239+ ).results.map((r) => r.principal.slice(5));
1240+ const me = (await this.who.profiles(ctx.workspace, [ctx.key])).get(ctx.key)!;
1241+ const message = cleanNote(a.message);
1242+ const notify = notifyClient(this.env.NOTIFY);
1243+ await Promise.all(
1244+ [...new Set([row.owner.slice(5), ...managers])].slice(0, 20).map((id) =>
1245+ notify
1246+ .notify(
1247+ { user_id: id },
1248+ {
1249+ id: `folio-request:${row.id}:${ctx.viewer.id}:${id}`,
1250+ kind: "inbox",
1251+ workspace: ctx.workspace.slug,
1252+ title: `${me.display_name} asks for access to ${row.title || "Untitled"}`,
1253+ body: message ?? "Open it and choose Share to let them in.",
1254+ href: this.ref(ctx.workspace.slug, row).path,
1255+ actor: { kind: "user", id: ctx.viewer.id, name: me.display_name, avatar: me.avatar, avatar_seed: null },
1256+ created_at: now(),
1257+ },
1258+ )
1259+ .catch(() => undefined),
1260+ ),
1261+ );
1262+ return ok(true);
1263+ }
1264+
1265+ async joinSpace(a: Args & { space_id: string }): Promise<Result<boolean>> {
1266+ const found = await this.ctx(a.workspace, a.viewer);
1267+ if (!found.ok) return found;
1268+ const ctx = found.value;
1269+ const space = ctx.spaceById.get(String(a.space_id ?? ""));
1270+ if (!space?.role || space.row.archived_at) return fail("not_found", "No such space.");
1271+ if (space.row.kind !== "workspace") return fail("invalid", "Only open spaces are joined; you're in team and members-only spaces already.");
1272+ await this.db
1273+ .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 = ?), ?)")
1274+ .bind(space.row.id, ctx.viewer.id, ctx.viewer.id, now())
1275+ .run();
1276+ return ok(true);
1277+ }
1278+
1279+ async leaveSpace(a: Args & { space_id: string }): Promise<Result<boolean>> {
1280+ const found = await this.ctx(a.workspace, a.viewer);
1281+ if (!found.ok) return found;
1282+ await this.db.prepare("DELETE FROM space_joins WHERE space_id = ? AND user_id = ?").bind(String(a.space_id ?? ""), found.value.viewer.id).run();
1283+ return ok(true);
1284+ }
1285+
1286+ // ── Search ──────────────────────────────────────────────────────────────
1287+
1288+ 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[]>> {
1289+ const found = await this.ctx(a.workspace, a.viewer);
1290+ if (!found.ok) return found;
1291+ return ok(await this.searchFor(found.value, a.query ?? { q: "" }));
1292+ }
1293+
1294+ /** Words over titles and text (folios_fts), and by meaning over passages when asked; only folios the viewer can read now. */
1295+ private async searchFor(
1296+ ctx: Ctx,
1297+ query: { q: string; kinds?: FolioKind[] | null; space_id?: string | null; project?: string | null; owner?: string | null; mode?: "words" | "hybrid" | null; limit?: number | null },
1298+ narrow?: (rows: FolioRow[]) => Promise<Set<string>>,
1299+ ): Promise<FolioSearchHit[]> {
1300+ const q = ftsQuery(String(query.q ?? ""));
1301+ const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 50);
1302+ const filter = this.filterOf(ctx);
1303+ const where: string[] = ["f.workspace_id = ?", "f.trashed_at IS NULL", filter.sql];
1304+ const binds: unknown[] = [ctx.workspace.id, ...filter.binds];
1305+ if (query.kinds?.length) {
1306+ where.push("f.kind IN (SELECT value FROM json_each(?))");
1307+ binds.push(json(query.kinds.filter(isFolioKind)));
1308+ }
1309+ if (query.space_id === "private") where.push("f.space_id IS NULL");
1310+ else if (query.space_id) {
1311+ where.push("f.space_id = ?");
1312+ binds.push(query.space_id);
1313+ }
1314+ if (query.owner) {
1315+ where.push("f.owner = ?");
1316+ binds.push(query.owner);
1317+ }
1318+ const project = query.project ? projectRef(query.project) : null;
1319+ if (project) {
1320+ 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 = ?))");
1321+ binds.push(project, project);
1322+ }
1323+ type Hit = FolioRow & { snippet: string };
1324+ const words: Hit[] = q
1325+ ? (
1326+ await this.db
1327+ .prepare(
1328+ `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
1329+ WHERE folios_fts MATCH ? AND ${where.join(" AND ")} ORDER BY bm25(folios_fts, 0, 0, 8.0, 1.0) LIMIT ?`,
1330+ )
1331+ .bind(q, ...binds, limit * 3)
1332+ .all<Hit>()
1333+ ).results
1334+ : (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;
1335+ // Meaning: passages near the query from scopes the viewer may read, each one checked again below.
1336+ let meaning: { folio_id: string; heading: string | null; text: string; score: number }[] = [];
1337+ if (q && query.mode === "hybrid") {
1338+ meaning = await this.meaningPassages(ctx, String(query.q), (await this.allowedScopes(ctx)).scopes).catch((error: unknown) => {
1339+ console.error("folios could not search by meaning", String(error));
1340+ return [];
1341+ });
1342+ meaning = meaning.filter((m) => m.score >= MEANING_FLOOR);
1343+ }
1344+ const extra = meaning.length ? await foliosById(this.db, meaning.map((m) => m.folio_id)) : new Map<string, FolioRow>();
1345+ 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()];
1346+ const { roles } = await this.roles(ctx, candidates);
1347+ let readable = new Set(candidates.filter((r) => roles.get(r.id)).map((r) => r.id));
1348+ if (narrow) {
1349+ const allowed = await narrow(candidates.filter((r) => readable.has(r.id)));
1350+ readable = new Set([...readable].filter((id) => allowed.has(id)));
1351+ }
1352+ // Meaning-only hits still have to match the filters.
1353+ 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);
1354+ const byWords = new Map(words.filter((w) => readable.has(w.id)).map((w) => [w.id, w]));
1355+ const bestMeaning = new Map<string, (typeof meaning)[number]>();
1356+ for (const m of meaning) {
1357+ const r = extra.get(m.folio_id);
1358+ if (!r || !readable.has(r.id) || !fits(r) || (project && !byWords.has(r.id))) continue;
1359+ if ((bestMeaning.get(m.folio_id)?.score ?? -1) < m.score) bestMeaning.set(m.folio_id, m);
1360+ }
1361+ const order = query.mode === "hybrid" ? fuseRanks([...byWords.keys()], [...bestMeaning.values()].sort((x, y) => y.score - x.score).map((m) => m.folio_id)) : [...byWords.keys()];
1362+ const spaceName = (id: string | null) => (id ? (ctx.spaceById.get(id)?.row.name ?? null) : null);
1363+ const out: FolioSearchHit[] = [];
1364+ for (const id of order) {
1365+ if (out.length >= limit) break;
1366+ const w = byWords.get(id);
1367+ const m = bestMeaning.get(id);
1368+ const row = w ?? extra.get(id);
1369+ if (!row) continue;
1370+ out.push({
1371+ ...this.ref(ctx.workspace.slug, row),
1372+ space_name: spaceName(row.space_id),
1373+ snippet: w ? (q ? w.snippet : excerpt(w.snippet, 140)) : excerpt(m!.text, 200),
1374+ edited_at: row.edited_at,
1375+ heading: m?.heading ?? null,
1376+ matched: query.mode === "hybrid" ? (w && m ? "both" : w ? "words" : "meaning") : null,
1377+ });
1378+ }
1379+ return out;
1380+ }
1381+
1382+ /**
1383+ * The scopes the viewer may recall from (src/access.ts `folioScope`):
1384+ * their readable spaces, and the access roots of folios shared with
1385+ * them, open to the workspace, or whose link they opened. Every hit is
1386+ * still checked against the folio itself.
1387+ */
1388+ private async allowedScopes(ctx: Ctx): Promise<{ scopes: string[] }> {
1389+ const keys = personKeys(ctx.person);
1390+ const [shared, general, visited] = await Promise.all([
1391+ this.db
1392+ .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")
1393+ .bind(json(keys), ctx.workspace.id)
1394+ .all<{ id: string }>(),
1395+ 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 }>(),
1396+ this.db
1397+ .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")
1398+ .bind(ctx.viewer.id, ctx.workspace.id)
1399+ .all<{ id: string }>(),
1400+ ]);
1401+ const scopes = new Set<string>(ctx.spaces.filter((s) => s.role).map((s) => `space:${s.row.id}`));
1402+ for (const r of [...shared.results, ...general.results, ...visited.results]) scopes.add(`folio:${r.id}`);
1403+ return { scopes: [...scopes] };
1404+ }
1405+
1406+ private async queryVector(query: string, embedder: { embed(texts: string[]): Promise<number[][]> } | null): Promise<number[] | null> {
1407+ const key = queryKey(query);
1408+ if (!embedder || !key) return null;
1409+ const cached = queryVectors.get(key);
1410+ if (cached) return cached;
1411+ try {
1412+ const [vector] = await embedder.embed([key]);
1413+ if (vector) queryVectors.set(key, vector);
1414+ return vector ?? null;
1415+ } catch (error) {
1416+ console.error("folios could not embed a query; matching words instead", String(error));
1417+ return null;
1418+ }
1419+ }
1420+
1421+ /** Folio passages nearest the query, from these scopes (by the index's filter, or after). Empty without an index. */
1422+ 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 }[]> {
1423+ const { embedder, store } = folioAdapters(this.env);
1424+ const plan = vectorQueryPlan(ctx.workspace.id, scopes);
1425+ if (!store || !plan) return [];
1426+ const vector = await this.queryVector(query, embedder);
1427+ if (!vector) return [];
1428+ let matches: { id: string; score: number }[] = [];
1429+ try {
1430+ matches = await store.query(vector, { topK: plan.topK, filter: { workspace_id: ctx.workspace.id, ...(plan.filter.space_ids ? { scopes: plan.filter.space_ids } : {}) } });
1431+ } catch (error) {
1432+ console.error("folios semantic query failed; matching words instead", String(error));
1433+ return [];
1434+ }
1435+ if (!matches.length) return [];
1436+ const allowed = new Set(scopes);
1437+ const rows = (
1438+ await this.db
1439+ .prepare("SELECT id, folio_id, kind, scope, heading, text FROM folio_chunks WHERE workspace_id = ? AND id IN (SELECT value FROM json_each(?))")
1440+ .bind(
1441+ ctx.workspace.id,
1442+ json(matches.map((m) => m.id)),
1443+ )
1444+ .all<{ id: string; folio_id: string; kind: FolioKind; scope: string; heading: string | null; text: string }>()
1445+ ).results;
1446+ const byId = new Map(rows.map((r) => [r.id, r]));
1447+ return matches
1448+ .map((m) => ({ m, r: byId.get(m.id) }))
1449+ .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)))
1450+ .map(({ m, r }) => ({ id: r.id, folio_id: r.folio_id, heading: r.heading, text: r.text, score: m.score }));
1451+ }
1452+
1453+ // ── History ─────────────────────────────────────────────────────────────
1454+
1455+ private async toVersions(workspace: Workspace, rows: Pick<VersionRow, "id" | "folio_id" | "created_at" | "kind" | "authors" | "note">[]): Promise<FolioVersion[]> {
1456+ const authors = rows.map((r) => parseJson<string[]>(r.authors, []));
1457+ const people = await this.who.profiles(workspace, authors.flat());
1458+ 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) }));
1459+ }
1460+
1461+ async versions(a: Args & { folio_id: string }): Promise<Result<FolioVersion[]>> {
1462+ const found = await this.ctx(a.workspace, a.viewer);
1463+ if (!found.ok) return found;
1464+ const ctx = found.value;
1465+ const opened = await this.open(ctx, a.folio_id, "view");
1466+ if (!opened.ok) return opened;
1467+ await this.room(opened.value.row.id)
1468+ .flush()
1469+ .catch(() => undefined);
1470+ const rows = (
1471+ 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>()
1472+ ).results;
1473+ return ok(await this.toVersions(ctx.workspace, rows));
1474+ }
1475+
1476+ async version(a: Args & { folio_id: string; version_id: string }): Promise<Result<FolioVersionDetail>> {
1477+ const found = await this.ctx(a.workspace, a.viewer);
1478+ if (!found.ok) return found;
1479+ const ctx = found.value;
1480+ const opened = await this.open(ctx, a.folio_id, "view");
1481+ if (!opened.ok) return opened;
1482+ 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>();
1483+ if (!row) return fail("not_found", "No such version.");
1484+ 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 }>();
1485+ const [version] = await this.toVersions(ctx.workspace, [row]);
1486+ return ok({ ...version!, text: row.text, diff: diffLines(before?.text ?? "", row.text) });
1487+ }
1488+
1489+ async restoreVersion(a: Args & { folio_id: string; version_id: string }): Promise<Result<FolioVersion>> {
1490+ const found = await this.ctx(a.workspace, a.viewer);
1491+ if (!found.ok) return found;
1492+ const ctx = found.value;
1493+ const opened = await this.open(ctx, a.folio_id, "edit");
1494+ if (!opened.ok) return opened;
1495+ const { row } = opened.value;
1496+ const version = await this.db.prepare("SELECT * FROM folio_versions WHERE id = ? AND folio_id = ?").bind(String(a.version_id ?? ""), row.id).first<VersionRow>();
1497+ if (!version) return fail("not_found", "No such version.");
1498+ let state: Uint8Array | null = version.state ? new Uint8Array(version.state) : null;
1499+ if (!state && version.state_key) {
1500+ const stored = await fileStore(this.env)
1501+ .get(version.state_key)
1502+ .catch(() => null);
1503+ if (stored) state = new Uint8Array(await new Response(stored.body).arrayBuffer());
1504+ }
1505+ const room = await this.ready(ctx.workspace, row);
1506+ const when = new Date(version.created_at).toISOString().slice(0, 16).replace("T", " ");
1507+ const origin: FolioOrigin = { key: ctx.key, kind: "restore", note: `Restored the version of ${when} UTC` };
1508+ const versionId = await room.restore({ state, text: version.text }, origin);
1509+ 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;
1510+ if (!created) return fail("conflict", "It could not be restored. Try again.");
1511+ const [v] = await this.toVersions(ctx.workspace, [created]);
1512+ this.tell(row.id, { type: "version.created", version: v! });
1513+ return ok(v!);
1514+ }
1515+
1516+ // ── Templates and export ────────────────────────────────────────────────
1517+
1518+ private async savedTemplate(workspace: Workspace, id: string): Promise<FolioTemplate | null> {
1519+ const row = await this.db
1520+ .prepare("SELECT * FROM folio_templates WHERE id = ? AND workspace_id = ?")
1521+ .bind(id, workspace.id)
1522+ .first<{ id: string; kind: FolioKind; name: string; description: string; icon: string | null; body: string; created_by: string }>();
1523+ if (!row) return null;
1524+ const by = (await this.who.profiles(workspace, [row.created_by])).get(row.created_by) ?? null;
1525+ return { id: row.id, kind: row.kind, name: row.name, description: row.description, icon: row.icon, builtin: false, body: row.body, created_by: by };
1526+ }
1527+
1528+ async templates(a: Args & { kind?: FolioKind | null }): Promise<Result<FolioTemplate[]>> {
1529+ const found = await this.ctx(a.workspace, a.viewer);
1530+ if (!found.ok) return found;
1531+ const ctx = found.value;
1532+ const kind = a.kind && isFolioKind(a.kind) ? a.kind : null;
1533+ const rows = (
1534+ await this.db
1535+ .prepare(`SELECT * FROM folio_templates WHERE workspace_id = ? ${kind ? "AND kind = ?" : ""} ORDER BY name COLLATE NOCASE`)
1536+ .bind(ctx.workspace.id, ...(kind ? [kind] : []))
1537+ .all<{ id: string; kind: FolioKind; name: string; description: string; icon: string | null; body: string; created_by: string }>()
1538+ ).results;
1539+ const people = await this.who.profiles(
1540+ ctx.workspace,
1541+ rows.map((r) => r.created_by),
1542+ );
1543+ return ok([
1544+ ...builtinFolioTemplates(kind),
1545+ ...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 })),
1546+ ]);
1547+ }
1548+
1549+ async saveTemplate(a: Args & { input: { folio_id: string; name: string; description?: string | null } }): Promise<Result<FolioTemplate>> {
1550+ const found = await this.ctx(a.workspace, a.viewer);
1551+ if (!found.ok) return found;
1552+ const ctx = found.value;
1553+ const opened = await this.open(ctx, a.input?.folio_id, "view");
1554+ if (!opened.ok) return opened;
1555+ const { row } = opened.value;
1556+ const name = cleanTitle(a.input.name || row.title, 80);
1557+ if (!name) return fail("invalid", "Name the template.");
1558+ const body = await (await this.ready(ctx.workspace, row)).text();
1559+ const id = newId("tpl");
1560+ const description = cleanTitle(a.input.description ?? "", 200);
1561+ await this.db
1562+ .prepare("INSERT INTO folio_templates (id, workspace_id, kind, name, description, icon, body, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
1563+ .bind(id, ctx.workspace.id, row.kind, name, description, row.icon, body, ctx.key, now())
1564+ .run();
1565+ const me = (await this.who.profiles(ctx.workspace, [ctx.key])).get(ctx.key) ?? null;
1566+ return ok({ id, kind: row.kind, name, description, icon: row.icon, builtin: false, body, created_by: me });
1567+ }
1568+
1569+ async deleteTemplate(a: Args & { template_id: string }): Promise<Result<boolean>> {
1570+ const found = await this.ctx(a.workspace, a.viewer);
1571+ if (!found.ok) return found;
1572+ const ctx = found.value;
1573+ 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 }>();
1574+ if (!row) return fail("not_found", "No such template.");
1575+ if (row.created_by !== ctx.key && !ctx.owner) return fail("forbidden", "Only whoever saved a template, or an owner, can delete it.");
1576+ await this.db.prepare("DELETE FROM folio_templates WHERE id = ?").bind(a.template_id).run();
1577+ return ok(true);
1578+ }
1579+
1580+ async export(a: Args & { folio_id: string; format?: "markdown" | "json" | null }): Promise<Result<{ filename: string; content_type: string; body: string }>> {
1581+ const found = await this.ctx(a.workspace, a.viewer);
1582+ if (!found.ok) return found;
1583+ const ctx = found.value;
1584+ const opened = await this.open(ctx, a.folio_id, "view");
1585+ if (!opened.ok) return opened;
1586+ const { row } = opened.value;
1587+ if (!kindModel(row.kind)) return fail("invalid", `${kindLabel(row.kind)} aren't here yet.`);
1588+ const read = await (await this.ready(ctx.workspace, row)).read();
1589+ const title = row.title || "Untitled";
1590+ const base = title.replace(/[\\/:*?"<>|]+/g, " ").trim() || "artifact";
1591+ const markdown = row.kind === "doc" || row.kind === "slides";
1592+ if ((a.format ?? (markdown ? "markdown" : "json")) === "markdown" && markdown) {
1593+ return ok({ filename: `${base}.md`, content_type: "text/markdown; charset=utf-8", body: `# ${title}\n\n${read.content}` });
1594+ }
1595+ return ok({ filename: `${base}.json`, content_type: "application/json", body: JSON.stringify({ kind: row.kind, title, content: read.content }, null, 2) });
1596+ }
1597+
1598+ // ── Suggestions, proposals and comments ─────────────────────────────────
1599+
1600+ private async toSuggestions(workspace: Workspace, rows: SuggestionRow[], blocks: (string[] | null)[] = []): Promise<FolioSuggestion[]> {
1601+ const people = await this.who.profiles(
1602+ workspace,
1603+ rows.flatMap((r) => [r.author, r.asked_by, r.decided_by].filter((k): k is string => !!k)),
1604+ );
1605+ return rows.map((r, i) => ({
1606+ id: r.id,
1607+ folio_id: r.folio_id,
1608+ author: people.get(r.author)!,
1609+ asked_by: r.asked_by ? (people.get(r.asked_by) ?? null) : null,
1610+ target: parseJson<DocEditTarget>(r.target, { kind: "append" }),
1611+ before_markdown: r.before_markdown,
1612+ after_markdown: r.after_markdown,
1613+ note: r.note,
1614+ status: r.status,
1615+ created_at: r.created_at,
1616+ decided_by: r.decided_by ? (people.get(r.decided_by) ?? null) : null,
1617+ decided_at: r.decided_at,
1618+ block_ids: blocks[i] ?? [],
1619+ }));
1620+ }
1621+
1622+ private async openSuggestions(ctx: Ctx, row: FolioRow): Promise<FolioSuggestion[]> {
1623+ 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;
1624+ if (!rows.length) return [];
1625+ let blocks: (string[] | null)[] = rows.map(() => []);
1626+ try {
1627+ blocks = await (await this.ready(ctx.workspace, row)).targets(rows.map((r) => parseJson<DocEditTarget>(r.target, { kind: "append" })));
1628+ } catch (error) {
1629+ console.error("folios could not place suggestions", String(error));
1630+ }
1631+ const gone = rows.filter((_, i) => blocks[i] === null);
1632+ if (gone.length) await this.db.batch(gone.map((r) => this.db.prepare("UPDATE folio_suggestions SET status = 'stale' WHERE id = ?").bind(r.id)));
1633+ const live = rows.map((r, i) => ({ r, b: blocks[i] })).filter((x) => x.b !== null);
1634+ return this.toSuggestions(
1635+ ctx.workspace,
1636+ live.map((x) => x.r),
1637+ live.map((x) => x.b!),
1638+ );
1639+ }
1640+
1641+ /** Files a doc suggestion: someone who can comment (a person, or an agent for one). */
1642+ private async fileSuggestion(
1643+ ctx: Ctx,
1644+ row: FolioRow,
1645+ by: { author: string; asked_by: string | null; agentName: string | null },
1646+ edit: { target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean },
1647+ ): Promise<Result<FolioSuggestion>> {
1648+ const room = await this.ready(ctx.workspace, row);
1649+ const current = await room.target(edit.target);
1650+ if (!current) return fail("not_found", "That part of the doc isn't there. Read it again and target what is there now.");
1651+ const s: SuggestionRow = {
1652+ id: newId("sug"),
1653+ folio_id: row.id,
1654+ author: by.author,
1655+ asked_by: by.asked_by,
1656+ target: JSON.stringify(edit.target),
1657+ before_markdown: current.markdown,
1658+ after_markdown: edit.markdown,
1659+ note: edit.note,
1660+ status: "open",
1661+ created_at: now(),
1662+ decided_by: null,
1663+ decided_at: null,
1664+ marks_current: edit.marks_current ? 1 : 0,
1665+ };
1666+ await this.db
1667+ .prepare("INSERT INTO folio_suggestions (id, folio_id, author, asked_by, target, before_markdown, after_markdown, note, status, created_at, marks_current) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)")
1668+ .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)
1669+ .run();
1670+ const [suggestion] = await this.toSuggestions(ctx.workspace, [s], [current.block_ids]);
1671+ this.tell(row.id, { type: "suggestion.created", suggestion: suggestion! });
1672+ if (by.agentName) this.defer(room.announce(by.author, by.agentName).catch(() => undefined));
1673+ this.defer(this.notifyOwnerOfSuggestion(ctx, row, suggestion!));
1674+ return ok(suggestion!);
1675+ }
1676+
1677+ private async notifyOwnerOfSuggestion(ctx: Ctx, row: FolioRow, suggestion: FolioSuggestion): Promise<void> {
1678+ if (!this.env.NOTIFY || !row.owner.startsWith("user:") || row.owner === suggestion.author.kind + ":" + suggestion.author.id) return;
1679+ const id = row.owner.slice(5);
1680+ await notifyClient(this.env.NOTIFY)
1681+ .notify(
1682+ { user_id: id },
1683+ {
1684+ id: `folio-suggestion:${suggestion.id}:${id}`,
1685+ kind: "inbox",
1686+ workspace: ctx.workspace.slug,
1687+ title: `${suggestion.author.display_name} suggested a change to ${row.title || "Untitled"}`,
1688+ body: suggestion.note ?? excerpt(suggestion.after_markdown, 140),
1689+ href: this.ref(ctx.workspace.slug, row).path,
1690+ 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 },
1691+ created_at: suggestion.created_at,
1692+ },
1693+ )
1694+ .catch(() => undefined);
1695+ }
1696+
1697+ async suggestions(a: Args & { folio_id: string }): Promise<Result<FolioSuggestion[]>> {
1698+ const found = await this.ctx(a.workspace, a.viewer);
1699+ if (!found.ok) return found;
1700+ const ctx = found.value;
1701+ const opened = await this.open(ctx, a.folio_id, "view");
1702+ if (!opened.ok) return opened;
1703+ if (opened.value.row.kind !== "doc") return ok([]);
1704+ return ok(await this.openSuggestions(ctx, opened.value.row));
1705+ }
1706+
1707+ async decideSuggestion(a: Args & { suggestion_id: string; decision: "accept" | "reject" }): Promise<Result<FolioSuggestion>> {
1708+ const s = await this.db.prepare("SELECT * FROM folio_suggestions WHERE id = ?").bind(String(a.suggestion_id ?? "")).first<SuggestionRow>();
1709+ if (!s) return fail("not_found", "No such suggestion.");
1710+ const found = await this.ctx(a.workspace, a.viewer);
1711+ if (!found.ok) return found;
1712+ const ctx = found.value;
1713+ const opened = await this.open(ctx, s.folio_id, "edit");
1714+ if (!opened.ok) return opened.error.code === "forbidden" ? fail("forbidden", "Only people who can edit it can accept or reject a suggestion.") : opened;
1715+ const { row } = opened.value;
1716+ if (s.status !== "open") return fail("conflict", "That suggestion was already decided.");
1717+ let status: DocSuggestion["status"] = a.decision === "accept" ? "accepted" : "rejected";
1718+ if (a.decision === "accept") {
1719+ const people = await this.who.profiles(ctx.workspace, [s.author, ctx.key]);
1720+ const room = await this.ready(ctx.workspace, row);
1721+ const result = await room.edit(
1722+ { kind: "doc", target: parseJson<DocEditTarget>(s.target, { kind: "append" }), markdown: s.after_markdown },
1723+ { 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] },
1724+ );
1725+ if (!result.applied) status = "stale";
1726+ else if (s.marks_current) await this.clearStale(row.id, s.author);
1727+ }
1728+ const at = now();
1729+ await this.db.prepare("UPDATE folio_suggestions SET status = ?, decided_by = ?, decided_at = ? WHERE id = ?").bind(status, ctx.key, at, s.id).run();
1730+ const [after] = await this.toSuggestions(ctx.workspace, [{ ...s, status, decided_by: ctx.key, decided_at: at }]);
1731+ this.tell(row.id, { type: "suggestion.updated", suggestion: after! });
1732+ if (status === "stale") return fail("conflict", "The part this suggestion changes is gone, so it can't be applied.");
1733+ return ok(after!);
1734+ }
1735+
1736+ async proposals(a: Args & { folio_id: string }): Promise<Result<FolioProposal[]>> {
1737+ const found = await this.ctx(a.workspace, a.viewer);
1738+ if (!found.ok) return found;
1739+ const ctx = found.value;
1740+ const opened = await this.open(ctx, a.folio_id, "view");
1741+ if (!opened.ok) return opened;
1742+ const rows = (
1743+ await this.db
1744+ .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")
1745+ .bind(opened.value.row.id)
1746+ .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 }>()
1747+ ).results;
1748+ const people = await this.who.profiles(
1749+ ctx.workspace,
1750+ rows.flatMap((r) => [r.author, r.asked_by, r.decided_by].filter((k): k is string => !!k)),
1751+ );
1752+ return ok(
1753+ rows.map((r) => ({
1754+ id: r.id,
1755+ folio_id: r.folio_id,
1756+ author: people.get(r.author)!,
1757+ asked_by: r.asked_by ? (people.get(r.asked_by) ?? null) : null,
1758+ note: r.note,
1759+ summary: r.summary,
1760+ status: r.status,
1761+ created_at: r.created_at,
1762+ decided_by: r.decided_by ? (people.get(r.decided_by) ?? null) : null,
1763+ decided_at: r.decided_at,
1764+ })),
1765+ );
1766+ }
1767+
1768+ async decideProposal(a: Args & { proposal_id: string; decision: "accept" | "reject" }): Promise<Result<FolioProposal>> {
1769+ const found = await this.ctx(a.workspace, a.viewer);
1770+ if (!found.ok) return found;
1771+ const row = await this.db.prepare("SELECT folio_id FROM folio_proposals WHERE id = ?").bind(String(a.proposal_id ?? "")).first<{ folio_id: string }>();
1772+ if (!row) return fail("not_found", "No such proposal.");
1773+ const opened = await this.open(found.value, row.folio_id, "edit");
1774+ if (!opened.ok) return opened;
1775+ // Proposals arrive with slides, designs and dashboards (Phases 4 to 6).
1776+ return fail("invalid", "Proposals can't be applied yet.");
1777+ }
1778+
1779+ async thread(a: Args & { folio_id: string; action: DocThreadAction }): Promise<Result<unknown>> {
1780+ const found = await this.ctx(a.workspace, a.viewer);
1781+ if (!found.ok) return found;
1782+ const ctx = found.value;
1783+ const opened = await this.open(ctx, a.folio_id, "comment");
1784+ if (!opened.ok) return opened;
1785+ const { row, role } = opened.value;
1786+ const room = await this.ready(ctx.workspace, row);
1787+ const result = (await room.thread(ctx.key, role, a.action)) as ThreadResult;
1788+ if (!result.ok) return fail(result.code, result.message);
1789+ if (result.mentions?.length) {
1790+ const names = result.mentions.filter((k) => k.startsWith("user:")).map((k) => k.slice(5).toLowerCase());
1791+ this.defer(this.notifyMentioned(ctx.workspace, row, names, ctx.key, result.text ?? "", result.thread_id ?? null));
1792+ }
1793+ return ok(result.value);
1794+ }
1795+
1796+ async threads(a: Args & { folio_id: string }): Promise<Result<DocThread[]>> {
1797+ const found = await this.ctx(a.workspace, a.viewer);
1798+ if (!found.ok) return found;
1799+ const ctx = found.value;
1800+ const opened = await this.open(ctx, a.folio_id, "view");
1801+ if (!opened.ok) return opened;
1802+ const threads = await (await this.ready(ctx.workspace, opened.value.row)).threads();
1803+ const people = await this.who.profiles(
1804+ ctx.workspace,
1805+ threads.flatMap((t) => t.comments.map((c) => c.author)),
1806+ );
1807+ return ok(threads.map((t) => ({ ...t, comments: t.comments.map((c) => ({ ...c, author: people.get(c.author)! })) })));
1808+ }
1809+
1810+ /**
1811+ * People mentioned (by username) in a folio or a comment on it hear of
1812+ * it, only when they can read it (leak rule 4); never the person who
1813+ * wrote it. Agents hear of mentions only through their asker.
1814+ */
1815+ async notifyMentioned(workspace: Workspace, row: FolioRow, usernames: string[], author: string | null, text: string, threadId: string | null): Promise<void> {
1816+ if (!this.env.NOTIFY) return;
1817+ const authorName = author?.startsWith("user:") ? ((await this.who.profiles(workspace, [author])).get(author)?.name ?? "").toLowerCase() : "";
1818+ const names = [...new Set(usernames.map((n) => n.toLowerCase()))].filter((n) => n && n !== authorName).slice(0, 50);
1819+ if (!names.length) return;
1820+ const who = author ? (await this.who.profiles(workspace, [author])).get(author) : null;
1821+ const href = `${this.ref(workspace.slug, row).path}${threadId ? `?thread=${encodeURIComponent(threadId)}` : ""}`;
1822+ const notify = notifyClient(this.env.NOTIFY);
1823+ const identity = identityClient(this.env.IDENTITY);
1824+ for (const username of names) {
1825+ const user = await identity.userByUsername(username).catch(() => null);
1826+ if (!user) continue;
1827+ const role = await this.roleOfPerson(workspace, row, userKey(user), username);
1828+ if (!role) continue;
1829+ await notify
1830+ .notify(
1831+ { username },
1832+ {
1833+ id: threadId ? `folio-comment:${row.id}:${threadId}:${username}:${Date.now()}` : `folio-mention:${row.id}:${username}`,
1834+ kind: "mention",
1835+ workspace: workspace.slug,
1836+ title: who ? `${who.display_name} mentioned you in ${row.title || "Untitled"}` : `You were mentioned in ${row.title || "Untitled"}`,
1837+ body: excerpt(text, 140),
1838+ href,
1839+ actor: who ? { kind: who.kind, id: who.id, name: who.display_name, avatar: who.avatar, avatar_seed: who.avatar_seed ?? null } : { kind: "system", id: "g1t", name: "g1t", avatar: null, avatar_seed: null },
1840+ created_at: now(),
1841+ },
1842+ )
1843+ .catch(() => undefined);
1844+ }
1845+ }
1846+
1847+ // ── Dashboards (Phase 5b) ───────────────────────────────────────────────
1848+
1849+ async queryTile(a: Args & { folio_id: string; tile_id: string }): Promise<Result<never>> {
1850+ const found = await this.ctx(a.workspace, a.viewer);
1851+ if (!found.ok) return found;
1852+ const opened = await this.open(found.value, a.folio_id, "view");
1853+ if (!opened.ok) return opened;
1854+ return fail("invalid", "Dashboards aren't here yet.");
1855+ }
1856+
1857+ async queryDataset(a: Args & { query: unknown }): Promise<Result<never>> {
1858+ const found = await this.ctx(a.workspace, a.viewer);
1859+ if (!found.ok) return found;
1860+ return fail("invalid", "Dashboards aren't here yet.");
1861+ }
1862+
1863+ async queryDatasetForAgent(a: AgentArgs): Promise<Result<never>> {
1864+ const found = await this.agentCtx(a);
1865+ if (!found.ok) return found;
1866+ return fail("invalid", "Dashboards aren't here yet.");
1867+ }
1868+
1869+ // ── Staleness ───────────────────────────────────────────────────────────
1870+
1871+ private async clearStale(folioId: string, by: string): Promise<boolean> {
1872+ 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();
1873+ const cleared = (done.meta?.changes ?? 0) > 0;
1874+ if (cleared) this.tell(folioId, { type: "folio.staleness" });
1875+ return cleared;
1876+ }
1877+
1878+ async markCurrent(a: Args & { folio_id: string }): Promise<Result<boolean>> {
1879+ const found = await this.ctx(a.workspace, a.viewer);
1880+ if (!found.ok) return found;
1881+ const opened = await this.open(found.value, a.folio_id, "edit");
1882+ if (!opened.ok) return opened;
1883+ await this.clearStale(opened.value.row.id, found.value.key);
1884+ return ok(true);
1885+ }
1886+
1887+ async reindex(a: Args): Promise<Result<boolean>> {
1888+ const found = await this.ctx(a.workspace, a.viewer);
1889+ if (!found.ok) return found;
1890+ if (!found.value.owner) return fail("forbidden", "Only an owner can index the workspace's artifacts again.");
1891+ return ok(await startBackfill(this.env, found.value.workspace.id, { force: true }));
1892+ }
1893+
1894+ // ── Agents ──────────────────────────────────────────────────────────────
1895+
1896+ private async agentCtx(a: AgentArgs): Promise<Result<AgentCtx>> {
1897+ const found = await this.ctx(a.workspace, a.viewer);
1898+ if (!found.ok) return found;
1899+ const ctx = found.value;
1900+ const agentId = String(a.agent_id ?? "");
1901+ const agent = (await this.who.agentsById([agentId])).get(agentId);
1902+ if (!agent || agent.workspace_id !== ctx.workspace.id || agent.archived_at) return fail("not_found", "No such agent.");
1903+ const rule = audienceRule(a.audience ?? null, ctx.viewer.id);
1904+ const people = rule.kind === "people" ? await this.who.peopleByIds(ctx.workspace, rule.user_ids) : [];
1905+ return ok({ ...ctx, agent, agentKey: principalKey({ kind: "agent", id: agent.id }), rule, people, audienceIds: rule.kind === "people" ? rule.user_ids : [] });
1906+ }
1907+
1908+ /** What the agent may reach in each folio for its asker and audience. */
1909+ private async reach(actx: AgentCtx, rows: FolioRow[]): Promise<Map<string, AgentReach>> {
1910+ const out = new Map<string, AgentReach>();
1911+ if (!rows.length) return out;
1912+ const [found, asker] = await Promise.all([ancestry(this.db, rows), visitsOf(this.db, actx.viewer.id, rows)]);
1913+ let audienceVisits = new Map<string, Set<string>>();
1914+ if (actx.rule.kind === "people") {
1915+ const ids = [...new Set(rows.flatMap((r) => [r.id, r.acl_root]))];
1916+ const found2 = await this.db
1917+ .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(?))")
1918+ .bind(json(actx.audienceIds), json(ids))
1919+ .all<{ folio_id: string; user_id: string }>();
1920+ audienceVisits = new Map();
1921+ for (const v of found2.results) audienceVisits.set(v.user_id, (audienceVisits.get(v.user_id) ?? new Set()).add(v.folio_id));
1922+ }
1923+ const allSpaces = new Map((await this.who.allSpaces(actx.workspace)).map((s) => [s.row.id, s]));
1924+ for (const row of rows) {
1925+ const chain = aclChain(row.id, found.nodes);
1926+ const root = chain[chain.length - 1];
1927+ const s = root?.space_id ? allSpaces.get(root.space_id) : undefined;
1928+ const space: SpaceRules | null = s ? rulesOf(s) : null;
1929+ const seen = (set: Set<string> | undefined) => !!set && (set.has(row.id) || (!!root && set.has(root.id)));
1930+ out.set(
1931+ row.id,
1932+ agentReach({
1933+ chain,
1934+ grants: found.grants,
1935+ space,
1936+ asker: actx.person,
1937+ askerVisited: seen(asker),
1938+ rule: actx.rule,
1939+ people: actx.people,
1940+ visited: (p) => seen(audienceVisits.get(p.user_id)),
1941+ agent_mode: this.agentMode(row, actx),
1942+ }),
1943+ );
1944+ }
1945+ return out;
1946+ }
1947+
1948+ /** A folio the agent may reach for its asker: not found when the asker can't read it. */
1949+ private async agentOpen(actx: AgentCtx, folioId: unknown): Promise<Result<{ row: FolioRow; reach: AgentReach }>> {
1950+ 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>();
1951+ if (!row) return fail("not_found", "No such artifact.");
1952+ const reach = (await this.reach(actx, [row])).get(row.id)!;
1953+ if (!reach.asker_role) return fail("not_found", "No such artifact.");
1954+ return ok({ row, reach });
1955+ }
1956+
1957+ async foliosForAgent(a: AgentArgs & { query: FolioListQuery }): Promise<Result<FolioList>> {
1958+ const found = await this.agentCtx(a);
1959+ if (!found.ok) return found;
1960+ const actx = found.value;
1961+ const query = { ...(a.query ?? { tab: "all" as const }), tab: a.query?.tab ?? "all", limit: Math.min(listLimit(a.query?.limit), 50) };
1962+ const invalid = folioListQueryError(query);
1963+ if (invalid) return fail("invalid", invalid);
1964+ const page = await this.listFor(actx, query);
1965+ const rows = await foliosById(
1966+ this.db,
1967+ page.items.map((f) => f.id),
1968+ );
1969+ const reach = await this.reach(actx, [...rows.values()]);
1970+ 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 } })), next_cursor: page.next_cursor });
1971+ }
1972+
1973+ async readForAgent(a: AgentArgs & { folio_id: string }): Promise<Result<FolioAgentRead>> {
1974+ const found = await this.agentCtx(a);
1975+ if (!found.ok) return found;
1976+ const actx = found.value;
1977+ const opened = await this.agentOpen(actx, a.folio_id);
1978+ if (!opened.ok) return opened;
1979+ const { row, reach } = opened.value;
1980+ if (!kindModel(row.kind)) return fail("invalid", `${kindLabel(row.kind)} aren't here yet.`);
1981+ const read = await (await this.ready(actx.workspace, row)).read();
1982+ return ok({
1983+ folio: { ...this.ref(actx.workspace.slug, row), edited_at: row.edited_at },
1984+ space: this.spaceOf(actx, row),
1985+ content: read.content,
1986+ ...(read.blocks ? { blocks: read.blocks } : {}),
1987+ can: reach.can,
1988+ audience_can_read: reach.audience_can_read,
1989+ });
1990+ }
1991+
1992+ async createAsAgent(
1993+ a: AgentArgs & {
1994+ 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 };
1995+ },
1996+ ): Promise<Result<FolioRef>> {
1997+ const found = await this.agentCtx({ ...a, audience: null });
1998+ if (!found.ok) return found;
1999+ const actx = found.value;
2000+ const input = a.input ?? ({} as typeof a.input);
2001+ const invalid = newFolioError({ kind: input.kind, title: input.title, content: input.content ?? null, template_id: input.template_id ?? null });
2002+ if (invalid) return fail("invalid", invalid);
2003+ if (!kindModel(input.kind)) return fail("invalid", `${kindLabel(input.kind)} aren't here yet.`);
2004+ const title = cleanTitle(input.title);
2005+ if (!title && !input.template_id) return fail("invalid", "Give it a title.");
2006+ const where = input.where ?? "private";
2007+ let place: Result<{ space_id: string | null; parent: FolioRow | null }>;
2008+ let grants: { principal: string; role: DocRole }[] = [{ principal: actx.agentKey, role: "edit" }];
2009+ if (input.parent_id) place = await this.placeFor(actx, { parent_id: input.parent_id });
2010+ else if (typeof where === "object" && "space_id" in where) place = await this.placeFor(actx, { space_id: where.space_id });
2011+ else place = ok({ space_id: null, parent: null });
2012+ if (!place.ok) return place.error.code === "forbidden" ? fail("forbidden", `${actx.viewer.username} can't add there.`) : place;
2013+ if (typeof where === "object" && "conversation" in where) {
2014+ // Private, and the conversation's people may read it.
2015+ const ids = [...new Set((Array.isArray(where.conversation) ? where.conversation : []).map(String))].filter((id) => id && id !== actx.viewer.id).slice(0, FOLIO_MAX_SHARE);
2016+ const people = await this.who.peopleByIds(actx.workspace, ids);
2017+ grants = [...grants, ...people.filter((p) => !p.user_id.startsWith("outside:")).map((p) => ({ principal: `user:${p.user_id}`, role: "view" as DocRole }))];
2018+ }
2019+ const start = await this.startingPoint(actx, input.kind, { title, template_id: input.template_id, content: input.content });
2020+ if (!start.ok) return start;
2021+ const row = await this.insertFolio(actx, {
2022+ kind: input.kind,
2023+ owner: actx.key,
2024+ created_by: actx.agentKey,
2025+ space_id: place.value.space_id,
2026+ parent: place.value.parent,
2027+ title: start.value.title,
2028+ icon: start.value.icon,
2029+ text: start.value.text,
2030+ spec: start.value.spec,
2031+ source: cleanSource(input.source),
2032+ grants,
2033+ });
2034+ return ok(this.ref(actx.workspace.slug, row));
2035+ }
2036+
2037+ async editAsAgent(a: AgentArgs & { folio_id: string; edit: FolioAgentEdit }): Promise<Result<FolioAgentEditResult>> {
2038+ const found = await this.agentCtx({ ...a, audience: null });
2039+ if (!found.ok) return found;
2040+ const actx = found.value;
2041+ const opened = await this.agentOpen(actx, a.folio_id);
2042+ if (!opened.ok) return opened;
2043+ const { row, reach } = opened.value;
2044+ const invalid = this.editError(row, a.edit);
2045+ if (invalid) return fail("invalid", invalid);
2046+ const edit = a.edit;
2047+ const ref = this.ref(actx.workspace.slug, row);
2048+ if (reach.can.edit && !edit.suggest_only) {
2049+ const room = await this.ready(actx.workspace, row);
2050+ const note = cleanNote(edit.note);
2051+ const result = await room.edit(edit, {
2052+ key: actx.agentKey,
2053+ kind: "agent",
2054+ note: note ? `@${actx.agent.handle} for @${actx.viewer.username}: ${note}` : `@${actx.agent.handle} for @${actx.viewer.username}`,
2055+ authors: [actx.agentKey],
2056+ });
2057+ if (!result.applied) return fail("not_found", `${result.summary} Read it again and target what is there now.`);
2058+ if (edit.marks_current) await this.clearStale(row.id, actx.agentKey);
2059+ this.defer(room.announce(actx.agentKey, actx.agent.display_name).catch(() => undefined));
2060+ return ok({ mode: "applied", version_id: result.version_id, folio: ref, summary: result.summary });
2061+ }
2062+ if (!reach.can.suggest) return fail("forbidden", `${actx.viewer.username} can only read this, so it can't be changed for them.`);
2063+ if (edit.kind !== "doc") return fail("forbidden", `Changing ${kindLabel(row.kind)} without edit access comes with proposals, which aren't here yet.`);
2064+ 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 });
2065+ return suggestion.ok ? ok({ mode: "suggested", suggestion: suggestion.value, folio: ref }) : suggestion;
2066+ }
2067+
2068+ async shareAsAgent(a: AgentArgs & { folio_id: string; user_ids: string[]; role: "view" | "comment" }): Promise<Result<FolioAccessList>> {
2069+ 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.");
2070+ const found = await this.agentCtx(a);
2071+ if (!found.ok) return found;
2072+ const actx = found.value;
2073+ 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.");
2074+ const opened = await this.agentOpen(actx, a.folio_id);
2075+ if (!opened.ok) return opened;
2076+ const { row, reach } = opened.value;
2077+ if (!canShare(reach.asker_role)) return fail("forbidden", `${actx.viewer.username} doesn't have full access, so it can't be shared for them.`);
2078+ const inConversation = new Set(actx.rule.user_ids);
2079+ const ids = [...new Set((Array.isArray(a.user_ids) ? a.user_ids : []).map(String))];
2080+ if (!ids.length) return fail("invalid", "Name who to share it with.");
2081+ if (ids.some((id) => !inConversation.has(id))) return fail("forbidden", "An agent shares only with people already in the conversation.");
2082+ const people = (await this.who.peopleByIds(actx.workspace, ids)).filter((p) => !p.user_id.startsWith("outside:"));
2083+ const at = now();
2084+ // Never lowers what someone already has.
2085+ await runBatches(
2086+ this.db,
2087+ people.map((p) =>
2088+ this.db
2089+ .prepare(
2090+ "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",
2091+ )
2092+ .bind(row.id, `user:${p.user_id}`, a.role, actx.agentKey, at),
2093+ ),
2094+ );
2095+ const list = await this.afterShare(actx, row);
2096+ const open = await workspaceReadable(this.db, row.id).catch(() => false);
2097+ this.defer(
2098+ publishFolioEvent(
2099+ this.env.EVENTS,
2100+ "folio.shared",
2101+ { 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 },
2102+ actx.agentKey,
2103+ ),
2104+ );
2105+ return ok(list);
2106+ }
2107+
2108+ /**
2109+ * What the workspace's artifacts (and projects' docs) say about a
2110+ * query, for an agent about to answer: passages by meaning above the
2111+ * floor, then by words, at most two per folio, only from folios its
2112+ * asker and every person in the audience can read, each checked against
2113+ * the folio itself. Projects' docs come from Docs' index (`g1t-docs`)
2114+ * until Phase 7 moves them.
2115+ */
2116+ async recallForAgent(a: AgentArgs & { query: string; limit?: number | null; spaces?: string[] | null; kinds?: FolioKind[] | null }): Promise<Result<FolioPassage[]>> {
2117+ const found = await this.agentCtx(a);
2118+ if (!found.ok) return found;
2119+ const actx = found.value;
2120+ this.defer(ensureIndexed(this.env, actx.workspace.id).catch((error: unknown) => console.error("folios could not start indexing", actx.workspace.id, String(error))));
2121+ const query = String(a.query ?? "").trim().slice(0, 2000);
2122+ if (!query) return ok([]);
2123+ const limit = recallLimit(a.limit);
2124+ const kinds = (a.kinds ?? []).filter(isFolioKind);
2125+ const { scopes } = await this.allowedScopes(actx);
2126+ const required = new Set((Array.isArray(a.spaces) ? a.spaces : []).map((id) => `space:${id}`).filter((s) => scopes.includes(s)));
2127+ const fts = ftsAnyQuery(query);
2128+ const [meaning, words, repo] = await Promise.all([
2129+ this.meaningPassages(actx, query, scopes, kinds).catch(() => []),
2130+ fts
2131+ ? this.db
2132+ .prepare(
2133+ `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
2134+ 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(?))" : ""}
2135+ ORDER BY bm25(folio_chunks_fts, 0, 0, 0, 4.0, 1.0) LIMIT 30`,
2136+ )
2137+ .bind(fts, actx.workspace.id, ...(scopes.length <= 80 ? [json(scopes)] : []), ...(kinds.length ? [json(kinds)] : []))
2138+ .all<{ id: string; folio_id: string; scope: string; heading: string | null; text: string }>()
2139+ .then((r) => r.results)
2140+ .catch((error: unknown) => {
2141+ console.error("folios word recall failed", String(error));
2142+ return [] as { id: string; folio_id: string; scope: string; heading: string | null; text: string }[];
2143+ })
2144+ : Promise.resolve([] as { id: string; folio_id: string; scope: string; heading: string | null; text: string }[]),
2145+ kinds.length && !kinds.includes("doc") ? Promise.resolve([] as FolioPassage[]) : this.recallRepoDocs(actx, query, fts, limit).catch(() => [] as FolioPassage[]),
2146+ ]);
2147+ // Every folio a passage came from, checked as the agent's asker and audience.
2148+ const folioIds = [...new Set([...meaning.map((m) => m.folio_id), ...words.map((w) => w.folio_id)])];
2149+ const rows = [...(await foliosById(this.db, folioIds)).values()].filter((r) => r.workspace_id === actx.workspace.id && !r.trashed_at);
2150+ const reach = await this.reach(actx, rows);
2151+ const may = new Set(rows.filter((r) => agentMayFind(reach.get(r.id)!)).map((r) => r.id));
2152+ const byFolio = new Map(rows.map((r) => [r.id, r]));
2153+ type C = Candidate & { heading: string | null; text: string };
2154+ const scopeOf = (folioId: string) => (required.size && byFolio.get(folioId)?.space_id && required.has(`space:${byFolio.get(folioId)!.space_id}`) ? "required" : "rest");
2155+ const candidates: C[] = [
2156+ ...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 })),
2157+ ...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 })),
2158+ ];
2159+ const picked = pickPassages(candidates, { allowed: new Set(["required", "rest"]), required: required.size ? ["required"] : [], limit });
2160+ const stale = await this.staleIds(picked.map((c) => c.doc_id));
2161+ const passages: FolioPassage[] = picked.map((c) => {
2162+ const row = byFolio.get(c.doc_id)!;
2163+ const space = row.space_id ? actx.spaceById.get(row.space_id) : undefined;
2164+ return {
2165+ folio: this.ref(actx.workspace.slug, row),
2166+ repo_file: null,
2167+ space_name: space?.row.name ?? "Private",
2168+ heading: c.heading,
2169+ text: c.text,
2170+ score: Math.round(c.score * 1000) / 1000,
2171+ updated_at: row.edited_at,
2172+ stale: stale.has(row.id),
2173+ };
2174+ });
2175+ // Projects' docs fill what's left, best first.
2176+ const out = [...passages, ...repo.sort((x, y) => y.score - x.score)].slice(0, limit);
2177+ return ok(out.sort((x, y) => y.score - x.score));
2178+ }
2179+
2180+ /** Projects' docs the agent may recall from: repositories its asker and every person in the audience can read. */
2181+ private async recallRepoDocs(actx: AgentCtx, query: string, fts: string | null, limit: number): Promise<FolioPassage[]> {
2182+ const spaces = await this.repoSpacesForAudience(actx);
2183+ if (!spaces.length) return [];
2184+ const ids = spaces.map((s) => s.row.id);
2185+ const { embedder, store } = adapters(this.env);
2186+ const vector = store ? await this.queryVector(query, embedder) : null;
2187+ const plan = vectorQueryPlan(actx.workspace.id, ids);
2188+ const [meaning, words] = await Promise.all([
2189+ 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 }[]),
2190+ fts
2191+ ? this.db
2192+ .prepare(
2193+ `SELECT doc_chunks_fts.chunk_id AS id FROM doc_chunks_fts JOIN doc_chunks c ON c.id = doc_chunks_fts.chunk_id
2194+ 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(?))
2195+ ORDER BY bm25(doc_chunks_fts, 0, 0, 0, 4.0, 1.0) LIMIT 20`,
2196+ )
2197+ .bind(fts, actx.workspace.id, json(ids))
2198+ .all<{ id: string }>()
2199+ .then((r) => r.results.map((x) => x.id))
2200+ .catch(() => [] as string[])
2201+ : Promise.resolve([] as string[]),
2202+ ]);
2203+ const scores = new Map<string, number>();
2204+ for (const m of meaning) if (m.score >= MEANING_FLOOR) scores.set(m.id, Math.max(scores.get(m.id) ?? 0, m.score));
2205+ for (const id of words) if (!scores.has(id)) scores.set(id, WORDS_SCORE);
2206+ if (!scores.size) return [];
2207+ const rows = (
2208+ await this.db
2209+ .prepare(
2210+ `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
2211+ WHERE c.workspace_id = ? AND c.repo_file_id IS NOT NULL AND c.id IN (SELECT value FROM json_each(?))`,
2212+ )
2213+ .bind(actx.workspace.id, json([...scores.keys()]))
2214+ .all<{ id: string; space_id: string; repo_file_id: string; path: string; heading: string | null; text: string }>()
2215+ ).results;
2216+ const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
2217+ const perFile = new Map<string, number>();
2218+ const out: FolioPassage[] = [];
2219+ for (const r of rows.sort((x, y) => (scores.get(y.id) ?? 0) - (scores.get(x.id) ?? 0))) {
2220+ const s = bySpace.get(r.space_id);
2221+ if (!s) continue;
2222+ const n = perFile.get(r.repo_file_id) ?? 0;
2223+ if (n >= 2) continue;
2224+ perFile.set(r.repo_file_id, n + 1);
2225+ const name = `${s.repo.namespace}/${s.repo.name}`;
2226+ out.push({
2227+ folio: null,
2228+ repo_file: { repo: name, path: r.path, href: `/${actx.workspace.slug}/-/artifacts/repo/${name}/${r.path.split("/").map(encodeURIComponent).join("/")}` },
2229+ space_name: name,
2230+ heading: r.heading,
2231+ text: r.text,
2232+ score: Math.round((scores.get(r.id) ?? 0) * 1000) / 1000,
2233+ updated_at: s.row.indexed_at ?? s.row.added_at,
2234+ stale: false,
2235+ });
2236+ if (out.length >= limit) break;
2237+ }
2238+ return out;
2239+ }
2240+
2241+ async staleForAgent(a: AgentArgs & { repo?: string | null; since?: string | null }): Promise<Result<Folio[]>> {
2242+ const found = await this.agentCtx(a);
2243+ if (!found.ok) return found;
2244+ const actx = found.value;
2245+ const repo = a.repo ? projectRef(a.repo) : null;
2246+ if (a.repo && !repo) return fail("invalid", "Name the repository as owner/name.");
2247+ const since = a.since && !Number.isNaN(Date.parse(a.since)) ? new Date(a.since).toISOString() : null;
2248+ const rows = (
2249+ await this.db
2250+ .prepare(
2251+ `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
2252+ WHERE f.workspace_id = ? AND f.trashed_at IS NULL ${since ? "AND c.flagged >= ?" : ""} ORDER BY c.flagged DESC LIMIT 200`,
2253+ )
2254+ .bind(...(repo ? [repo] : []), actx.workspace.id, ...(since ? [since] : []))
2255+ .all<FolioRow>()
2256+ ).results;
2257+ const reach = await this.reach(actx, rows);
2258+ return ok((await this.toFolios(actx, rows.filter((r) => agentMayFind(reach.get(r.id)!)))).slice(0, 50));
2259+ }
2260+
2261+ // ── Projects' docs ──────────────────────────────────────────────────────
2262+
2263+ private async readableRepoSpaces(workspace: Workspace, viewer: User): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
2264+ const rows = (await this.db.prepare("SELECT * FROM repo_spaces WHERE workspace_id = ? ORDER BY repo").bind(workspace.id).all<RepoSpaceRow>()).results;
2265+ if (!rows.length || !this.env.REPOS) return [];
2266+ const readable = await reposClient(this.env.REPOS).readable(
2267+ rows.map((r) => r.repo_id),
2268+ viewer,
2269+ );
2270+ const byId = new Map(readable.map((r) => [r.id, r]));
2271+ return rows.filter((r) => byId.has(r.repo_id)).map((row) => ({ row, repo: byId.get(row.repo_id)! }));
2272+ }
2273+
2274+ private async repoSpacesFor(ctx: Ctx): Promise<DocRepoSpace[]> {
2275+ const found = await this.readableRepoSpaces(ctx.workspace, ctx.viewer);
2276+ if (!found.length) return [];
2277+ const [files, people] = await Promise.all([
2278+ this.db
2279+ .prepare("SELECT space_id, path, title FROM repo_files WHERE space_id IN (SELECT value FROM json_each(?))")
2280+ .bind(json(found.map((f) => f.row.id)))
2281+ .all<{ space_id: string; path: string; title: string }>(),
2282+ this.who.profiles(
2283+ ctx.workspace,
2284+ found.map((f) => f.row.added_by),
2285+ ),
2286+ ]);
2287+ const readme = (path: string) => (/^readme\./i.test(path) ? 0 : 1);
2288+ return found.map(({ row, repo }) => ({
2289+ id: row.id,
2290+ repo: `${repo.namespace}/${repo.name}`,
2291+ default_branch: repo.defaultBranch,
2292+ commit: row.commit_sha,
2293+ indexed_at: row.indexed_at,
2294+ added_by: people.get(row.added_by)!,
2295+ files: files.results
2296+ .filter((f) => f.space_id === row.id)
2297+ .sort((a, b) => readme(a.path) - readme(b.path) || a.path.localeCompare(b.path))
2298+ .map((f) => ({ path: f.path, title: f.title })),
2299+ can_remove: row.added_by === ctx.key || ctx.owner,
2300+ }));
2301+ }
2302+
2303+ /** Projects' docs an agent may recall from: the asker's, narrowed to every person in the audience (public repositories only for a workspace audience). */
2304+ private async repoSpacesForAudience(actx: AgentCtx): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
2305+ const mine = await this.readableRepoSpaces(actx.workspace, actx.viewer);
2306+ if (!mine.length || actx.rule.kind === "asker") return mine;
2307+ const publicOnly = () => mine.filter((s) => !s.repo.isPrivate);
2308+ if (actx.rule.kind === "workspace" || !this.env.REPOS) return publicOnly();
2309+ const others = await identityClient(this.env.IDENTITY)
2310+ .usersForAudience(actx.rule.user_ids)
2311+ .catch(() => [] as User[]);
2312+ let keep = new Set(mine.map((s) => s.row.repo_id));
2313+ // Someone who isn't a live account reads public repositories only.
2314+ if (others.length < actx.rule.user_ids.length) keep = new Set(publicOnly().map((s) => s.row.repo_id));
2315+ for (const person of others) {
2316+ const readable = await reposClient(this.env.REPOS)
2317+ .readable([...keep], person)
2318+ .catch(() => [] as Repo[]);
2319+ keep = new Set(readable.map((r) => r.id));
2320+ if (!keep.size) break;
2321+ }
2322+ return mine.filter((s) => keep.has(s.row.repo_id));
2323+ }
2324+
2325+ // ── Sockets and files ───────────────────────────────────────────────────
2326+
2327+ private viewerFrom(request: Request): Viewer {
2328+ try {
2329+ return JSON.parse(request.headers.get(DOCS_VIEWER_HEADER) ?? "null") as Viewer;
2330+ } catch {
2331+ return null;
2332+ }
2333+ }
2334+
2335+ /**
2336+ * `GET /live?workspace=<slug>&folio=<id>`, upgraded to a WebSocket. The
2337+ * viewer comes in DOCS_VIEWER_HEADER, set by the site after checking
2338+ * the session; trusted only because this Worker is reachable through
2339+ * service bindings alone. Checked like any read (opening counts for a
2340+ * link folio), then handed to the room with the viewer's role.
2341+ */
2342+ async live(request: Request): Promise<Response> {
2343+ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("Expected a WebSocket upgrade\n", { status: 426 });
2344+ const viewer = this.viewerFrom(request);
2345+ if (!viewer?.id) return new Response("Sign in to use Artifacts\n", { status: 401 });
2346+ const url = new URL(request.url);
2347+ const found = await this.ctx((url.searchParams.get("workspace") ?? "").toLowerCase(), viewer);
2348+ if (!found.ok) return new Response(`${found.error.message}\n`, { status: found.error.code === "forbidden" ? 403 : 404 });
2349+ const ctx = found.value;
2350+ const opened = await this.open(ctx, url.searchParams.get("folio") ?? "", "view", { trashed: true, opening: true });
2351+ if (!opened.ok) return new Response(`${opened.error.message}\n`, { status: opened.error.code === "forbidden" ? 403 : 404 });
2352+ const { row, role } = opened.value;
2353+ if (row.trashed_at) return new Response("That artifact is in the trash\n", { status: 410 });
2354+ if (!kindModel(row.kind)) return new Response("That kind of artifact isn't here yet\n", { status: 409 });
2355+ const room = await this.ready(ctx.workspace, row);
2356+ const member = (await this.who.profiles(ctx.workspace, [ctx.key])).get(ctx.key)!;
2357+ const headers = new Headers(request.headers);
2358+ headers.delete(DOCS_VIEWER_HEADER);
2359+ headers.set(ROOM_MEMBER_HEADER, JSON.stringify({ folio_id: row.id, workspace_slug: ctx.workspace.slug, key: ctx.key, member, role }));
2360+ return room.fetch(new Request(request.url, { method: "GET", headers }));
2361+ }
2362+
2363+ /** `PUT /files?workspace=&folio=&name=`: a file for a folio, from someone who can edit it. */
2364+ async upload(request: Request): Promise<Response> {
2365+ const viewer = this.viewerFrom(request);
2366+ const url = new URL(request.url);
2367+ const found = await this.ctx((url.searchParams.get("workspace") ?? "").toLowerCase(), viewer);
2368+ if (!found.ok) return Response.json(found);
2369+ const ctx = found.value;
2370+ const opened = await this.open(ctx, url.searchParams.get("folio") ?? "", "edit");
2371+ if (!opened.ok) return Response.json(opened);
2372+ const bytes = Number(request.headers.get("content-length") ?? "0");
2373+ if (!bytes || bytes > DOC_MAX_FILE_BYTES) return Response.json(fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`));
2374+ const name = safeName(url.searchParams.get("name") ?? "file");
2375+ const contentType = servedType(request.headers.get("content-type") ?? "");
2376+ const key = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
2377+ const id = newId("fil");
2378+ await fileStore(this.env).put(`docs/${key}`, request.body ?? new Uint8Array(), contentType);
2379+ await this.db
2380+ .prepare("INSERT INTO folio_files (id, workspace_id, folio_id, key, name, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
2381+ .bind(id, ctx.workspace.id, opened.value.row.id, key, name, contentType, bytes, ctx.key, now())
2382+ .run();
2383+ return Response.json(ok({ id, url: `/docs-files/${key}`, name, content_type: contentType, bytes }));
2384+ }
2385+
2386+ /** `GET /files/<key>` for a folio's file, or null when the key isn't a folio's (then Docs' pages are asked). */
2387+ async file(key: string): Promise<Response | null> {
2388+ const row = await this.db.prepare("SELECT name, content_type FROM folio_files WHERE key = ?").bind(key).first<{ name: string; content_type: string }>();
2389+ if (!row) return null;
2390+ const stored = await fileStore(this.env).get(`docs/${key}`);
2391+ if (!stored) return new Response("Not found\n", { status: 404 });
2392+ const inline = row.content_type !== "application/octet-stream";
2393+ return new Response(stored.body, {
2394+ headers: {
2395+ "content-type": row.content_type,
2396+ "content-length": String(stored.bytes),
2397+ etag: stored.etag,
2398+ "content-disposition": `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(row.name)}`,
2399+ "cache-control": "private, max-age=31536000, immutable",
2400+ },
2401+ });
2402+ }
2403+}
2404+
2405+/** A folio's room found people newly mentioned in it: those who can read it hear of it. */
2406+export async function notifyFolioMentions(env: FoliosEnv, slug: string, folioId: string, usernames: string[], last: string | null): Promise<void> {
2407+ const service = new Folios(env);
2408+ const workspace = await service.who.workspace(slug);
2409+ if (!workspace) return;
2410+ 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>();
2411+ if (!row || row.trashed_at) return;
2412+ await service.notifyMentioned(workspace, row, usernames, last, row.text, null);
2413+}
2414+
2415+/** Trashed folios this long ago are deleted for good by the daily cron. */
2416+export const TRASH_DAYS = 30;
2417+
2418+/**
2419+ * The daily cron: folios in the trash for over TRASH_DAYS are deleted for
2420+ * good, deepest first, at most 500 a run (the rest go the next day).
2421+ */
2422+export async function purgeTrash(env: FoliosEnv, at = new Date()): Promise<number> {
2423+ const cutoff = new Date(at.getTime() - TRASH_DAYS * 24 * 60 * 60 * 1000).toISOString();
2424+ 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;
2425+ if (!rows.length) return 0;
2426+ // Children still alive under one being purged go to the top of where they were.
2427+ const ids = rows.map((r) => r.id);
2428+ await env.DB.prepare("UPDATE folios SET parent_id = NULL WHERE parent_id IN (SELECT value FROM json_each(?)) AND id NOT IN (SELECT value FROM json_each(?))").bind(json(ids), json(ids)).run();
2429+ await forgetFolios(env, ids);
2430+ await runBatches(
2431+ env.DB,
2432+ ids.flatMap((id) => [env.DB.prepare("DELETE FROM folios_fts WHERE folio_id = ?").bind(id), env.DB.prepare("DELETE FROM folios WHERE id = ?").bind(id)]),
2433+ );
2434+ if (env.FOLIOS) for (const id of ids) await env.FOLIOS.get(env.FOLIOS.idFromName(id)).destroy().catch(() => undefined);
2435+ return ids.length;
2436+}
2437+
2438+/** The `folios.reacl` job: a large subtree's access, rooms and index brought up to date. */
2439+export async function runReacl(env: FoliosEnv, folioId: string): Promise<void> {
2440+ const service = new Folios(env);
2441+ const ids = await rebuildSubtree(env.DB, folioId);
2442+ await service.followAccess(null, ids);
2443+}
+140−0
1+/**
2+ * Folios that cite code a change touched become possibly out of date, as
3+ * Docs' pages do (src/staleness.ts, which calls this with the same change
4+ * after recording pages). Once per folio and commit; the owner hears of
5+ * it (which change, only if they can read the repository); open rooms
6+ * ask their readers to look again; `folio.stale` goes out with a title
7+ * only when the whole workspace can read the folio.
8+ */
9+import { identityClient, notifyClient, reposClient, type User } from "@g1t/contracts";
10+
11+import { touchedPaths } from "../citations.ts";
12+import type { Change, StaleEnv } from "../staleness.ts";
13+import { slugOf } from "./list.ts";
14+import { workspaceReadable } from "./access-store.ts";
15+import { publishFolioEvent } from "./events.ts";
16+import type { FolioRoom } from "./room.ts";
17+
18+export type FolioStaleEnv = StaleEnv & { FOLIOS?: DurableObjectNamespace<FolioRoom> };
19+
20+/** Whether any live folio cites the repository. */
21+export async function foliosCite(db: D1Database, repo: string): Promise<boolean> {
22+ const row = await db.prepare("SELECT 1 AS yes FROM folio_citations c JOIN folios f ON f.id = c.folio_id WHERE c.repo = ? AND f.trashed_at IS NULL LIMIT 1").bind(repo).first<{ yes: number }>();
23+ return !!row;
24+}
25+
26+/** Records a change against every folio whose citations it touches; returns the folios newly made stale. */
27+export async function recordFolioChanges(env: FolioStaleEnv, change: Change, now = new Date()): Promise<string[]> {
28+ const db = env.DB;
29+ const cited = (
30+ await db
31+ .prepare("SELECT c.folio_id, c.path FROM folio_citations c JOIN folios f ON f.id = c.folio_id WHERE c.repo = ? AND f.trashed_at IS NULL")
32+ .bind(change.repo)
33+ .all<{ folio_id: string; path: string }>()
34+ ).results;
35+ const byFolio = new Map<string, { path: string }[]>();
36+ for (const row of cited) byFolio.set(row.folio_id, [...(byFolio.get(row.folio_id) ?? []), row]);
37+ const fresh: string[] = [];
38+ const hits = new Map<string, string[]>();
39+ for (const [folioId, rows] of byFolio) {
40+ const touched = touchedPaths(rows, change.changed);
41+ if (!touched.length) continue;
42+ hits.set(folioId, touched);
43+ const [inserted] = await db.batch<{ folio_id: string }>([
44+ db
45+ .prepare(
46+ "INSERT INTO folio_changes (folio_id, repo, repo_id, commit_sha, pull_number, pull_title, paths, detected_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (folio_id, repo, commit_sha) DO NOTHING RETURNING folio_id",
47+ )
48+ .bind(folioId, change.repo, change.repo_id, change.commit, change.pull?.number ?? null, change.pull?.title ?? null, JSON.stringify(touched), now.toISOString()),
49+ ...(change.pull
50+ ? [db.prepare("UPDATE folio_changes SET pull_number = ?, pull_title = ? WHERE folio_id = ? AND repo = ? AND commit_sha = ?").bind(change.pull.number, change.pull.title, folioId, change.repo, change.commit)]
51+ : []),
52+ ]);
53+ if (inserted?.results.length) fresh.push(folioId);
54+ }
55+ if (fresh.length) await tellOf(env, change, fresh, hits);
56+ return fresh;
57+}
58+
59+type Row = { id: string; workspace_id: string; kind: "doc" | "slides" | "design" | "dashboard"; space_id: string | null; title: string; owner: string };
60+
61+async function tellOf(env: FolioStaleEnv, change: Change, ids: string[], hits: Map<string, string[]>): Promise<void> {
62+ const db = env.DB;
63+ const rows = (await db.prepare("SELECT id, workspace_id, kind, space_id, title, owner FROM folios WHERE id IN (SELECT value FROM json_each(?))").bind(JSON.stringify(ids)).all<Row>()).results;
64+ const slugs = await identityClient(env.IDENTITY)
65+ .usernames([...new Set(rows.map((r) => r.workspace_id))])
66+ .catch(() => ({}) as Record<string, string>);
67+ const ownerIds = [...new Set(rows.map((r) => r.owner).filter((k) => k.startsWith("user:")).map((k) => k.slice(5)))];
68+ const people = ownerIds.length ? await identityClient(env.IDENTITY).usersForAudience(ownerIds).catch(() => [] as User[]) : [];
69+ const [namespace, name] = change.repo.split("/") as [string, string];
70+ const canRead = new Set<string>();
71+ if (env.REPOS) {
72+ await Promise.all(
73+ people.map(async (person) => {
74+ const found = await reposClient(env.REPOS!)
75+ .get({ namespace, name }, person)
76+ .catch(() => null);
77+ if (found?.ok) canRead.add(person.id);
78+ }),
79+ );
80+ }
81+ const what = change.pull ? `${change.repo}#${change.pull.number}` : `${change.repo}@${change.commit.slice(0, 7)}`;
82+ const notify = env.NOTIFY ? notifyClient(env.NOTIFY) : null;
83+ for (const row of rows) {
84+ const slug = slugs[row.workspace_id];
85+ if (!slug) continue;
86+ const href = `/${slug}/-/artifacts/${slugOf(row.title, row.id)}`;
87+ const paths = hits.get(row.id) ?? [];
88+ const work: Promise<unknown>[] = [];
89+ if (notify && row.owner.startsWith("user:")) {
90+ const id = row.owner.slice(5);
91+ const known = canRead.has(id);
92+ work.push(
93+ notify
94+ .notify(
95+ { user_id: id },
96+ {
97+ id: `folio-stale:${row.id}:${change.commit}:${id}`,
98+ kind: "inbox",
99+ workspace: slug,
100+ title: `${row.title || "Untitled"} may be out of date`,
101+ body: known ? `${what} changed ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ` and ${paths.length - 3} more` : ""}` : "A change to code this cites was merged.",
102+ href,
103+ actor: { kind: "system", id: "g1t", name: "g1t", avatar: null, avatar_seed: null },
104+ created_at: new Date().toISOString(),
105+ },
106+ )
107+ .catch(() => undefined),
108+ );
109+ }
110+ if (env.FOLIOS) {
111+ work.push(
112+ env.FOLIOS.get(env.FOLIOS.idFromName(row.id))
113+ .notice({ type: "folio.staleness" })
114+ .catch(() => undefined),
115+ );
116+ }
117+ const open = await workspaceReadable(db, row.id).catch(() => false);
118+ work.push(
119+ publishFolioEvent(
120+ env.EVENTS,
121+ "folio.stale",
122+ {
123+ workspace: slug,
124+ workspaceId: row.workspace_id,
125+ folioId: row.id,
126+ kind: row.kind,
127+ spaceId: row.space_id,
128+ title: open ? row.title : null,
129+ repo: change.repo,
130+ commit: change.commit,
131+ pull: change.pull?.number ?? null,
132+ paths,
133+ owners: [row.owner],
134+ },
135+ change.actor ? `user:${change.actor}` : null,
136+ ),
137+ );
138+ await Promise.all(work);
139+ }
140+}
+30−0
1+/**
2+ * The templates g1t ships for folios. Docs' page templates
3+ * (src/templates.ts) are the doc kind's, as they are; each later kind
4+ * adds its own here (slides in Phase 4, dashboards in 5b, designs in 6b).
5+ * A workspace's own are in `folio_templates`. Pure.
6+ */
7+import type { FolioKind, FolioTemplate } from "@g1t/contracts";
8+
9+import { BUILTIN_TEMPLATES } from "../templates.ts";
10+
11+/** Built-in ids are `builtin:<kind>:<slug>`; a doc's old `builtin:<slug>` is still found. */
12+export const BUILTIN_FOLIO_TEMPLATES: FolioTemplate[] = BUILTIN_TEMPLATES.map((t) => ({
13+ id: `builtin:doc:${t.id.replace(/^builtin:/, "")}`,
14+ kind: "doc" as const,
15+ name: t.name,
16+ description: t.description,
17+ icon: t.icon,
18+ builtin: true,
19+ body: t.markdown,
20+ created_by: null,
21+}));
22+
23+export function builtinFolioTemplate(id: string): FolioTemplate | null {
24+ const wanted = String(id ?? "");
25+ return BUILTIN_FOLIO_TEMPLATES.find((t) => t.id === wanted || t.id === `builtin:doc:${wanted.replace(/^builtin:/, "")}`) ?? null;
26+}
27+
28+export function builtinFolioTemplates(kind?: FolioKind | null): FolioTemplate[] {
29+ return kind ? BUILTIN_FOLIO_TEMPLATES.filter((t) => t.kind === kind) : BUILTIN_FOLIO_TEMPLATES;
30+}
+41−6
1111 * Each page has a room (src/room.ts), a Durable Object that owns its Yjs
1212 * document. Everything that changes a page's content goes through the
1313 * room; this Worker decides who may ask.
14+ *
15+ * The docs service also hosts folios (Artifacts mode, docs/ARTIFACTS_MODE.md):
16+ * docs, slides, designs and dashboards, in src/folios/ with their own
17+ * room (FolioRoom). `/rpc/<method>` asks the folio table (src/folios/rpc.ts)
18+ * first, then Docs' own switch below; `/live?folio=` and
19+ * `PUT /files?folio=` are a folio's. Docs' pages keep working on their
20+ * tables until Phase 7 retires them.
1421 */
1522
1623 import {
95102 import type { ThreadResult } from "./threads.ts";
96103 import { onEvent } from "./staleness.ts";
97104 import { descendants, exportPaths, lastPosition, placeBefore, wouldCycle, ancestors } from "./tree.ts";
105+import type { FolioRoom } from "./folios/room.ts";
106+import { folioHandler } from "./folios/rpc.ts";
107+import { Folios, purgeTrash, runReacl } from "./folios/service.ts";
98108
99109 export { PageRoom } from "./room.ts";
110+export { FolioRoom } from "./folios/room.ts";
100111
101112 type Env = FileStoreEnv & {
102113 DB: D1Database;
114125 AI?: Ai;
115126 /** The semantic index, Vectorize `g1t-docs` (src/vectors.ts, src/indexer.ts). */
116127 VECTORS?: Vectorize;
117− /** The docs service's own events queue, also carrying its backfill jobs (`docs.index`, src/indexer.ts). */
128+ /** The docs service's own events queue, also carrying its backfill jobs (`docs.index`, src/indexer.ts) and folio access jobs (`folios.reacl`). */
118129 JOBS?: Queue<DocsJob>;
130+ /** One room per folio (Artifacts mode, src/folios/room.ts). */
131+ FOLIOS: DurableObjectNamespace<FolioRoom>;
132+ /** Folios' semantic index, Vectorize `g1t-folios`; optional (words only without it). */
133+ FOLIO_VECTORS?: Vectorize;
119134 };
120135
121136 /** Queries' embeddings, a minute per isolate (src/recall.ts). */
26982713 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
26992714 const url = new URL(request.url);
27002715 const defer = (work: Promise<unknown>) => ctx.waitUntil(work);
2701− if (request.method === "GET" && url.pathname === "/live") return new Docs(env, defer).live(request);
2702− if (request.method === "PUT" && url.pathname === "/files") return new Docs(env, defer).upload(request);
2716+ if (request.method === "GET" && url.pathname === "/live") return url.searchParams.has("folio") ? new Folios(env, defer).live(request) : new Docs(env, defer).live(request);
2717+ if (request.method === "PUT" && url.pathname === "/files") return url.searchParams.has("folio") ? new Folios(env, defer).upload(request) : new Docs(env, defer).upload(request);
27032718 const file = /^\/files\/([0-9a-f]{64})$/.exec(url.pathname);
2704− if ((request.method === "GET" || request.method === "HEAD") && file) return new Docs(env, defer).file(file[1]!);
2719+ if ((request.method === "GET" || request.method === "HEAD") && file) return (await new Folios(env, defer).file(file[1]!)) ?? new Docs(env, defer).file(file[1]!);
27052720 const match = url.pathname.match(/^\/rpc\/([a-z_]+)$/);
27062721 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
27072722 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
27082723 const opened = openD1(env.DB, request);
2709− const service = new Docs(Object.create(env, { DB: { value: opened.db } }) as Env, defer);
2724+ const scoped = Object.create(env, { DB: { value: opened.db } }) as Env;
27102725 const args = (await request.json().catch(() => ({}))) as any;
27112726 try {
2727+ // Folios first (src/folios/rpc.ts), then Docs' pages.
2728+ const folio = folioHandler(match[1]!);
2729+ if (folio) return opened.finish(Response.json(await folio(new Folios(scoped, defer), args)));
2730+ const service = new Docs(scoped, defer);
27122731 return opened.finish(await answer(service, match[1]!, args));
27132732 } catch (error) {
27142733 console.error("docs:", match[1], error);
27312750 try {
27322751 const body = message.body;
27332752 if (body.type === "docs.index") {
2734− await runBackfill(env, (body as DocsJob).workspace_id);
2753+ await runBackfill(env, (body as Extract<DocsJob, { type: "docs.index" }>).workspace_id);
2754+ message.ack();
2755+ continue;
2756+ }
2757+ if (body.type === "folios.reacl") {
2758+ await runReacl(env, (body as Extract<DocsJob, { type: "folios.reacl" }>).folio_id);
27352759 message.ack();
27362760 continue;
27372761 }
27482772 }
27492773 }
27502774 },
2775+
2776+ /** Daily (wrangler.jsonc `triggers`): folios in the trash for over 30 days are deleted for good. */
2777+ async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
2778+ ctx.waitUntil(
2779+ purgeTrash(env)
2780+ .then((n) => {
2781+ if (n) console.log("folios purged from the trash", n);
2782+ })
2783+ .catch((error: unknown) => console.error("folios could not purge the trash", String(error))),
2784+ );
2785+ },
27512786 } satisfies ExportedHandler<Env, G1tEvent | DocsJob>;
+195−8
1616 * on the queue (`docs.index` jobs, on the docs service's own events queue).
1717 */
1818 import { chunkId, chunkMarkdown, embedText, repoFileId, textHash } from "./chunks.ts";
19+import { scopesOf, type FolioRow, FOLIO_COLUMNS } from "./folios/access-store.ts";
20+import { kindModel } from "./kinds/index.ts";
1921 import { cloudflareEmbedder, cloudflareVectors, type Embedder, type VectorMetadata, type VectorStore } from "./vectors.ts";
2022
2123 /** Passages embedded per workspace per hour at most. Logged when reached. */
2729 /** How long a run waits when the hour's cap is reached. */
2830 const CAP_DELAY_SECONDS = 3600;
2931
30−/** A job on the queue: index more of a workspace's docs. */
31−export type DocsJob = { type: "docs.index"; workspace_id: string };
32+/**
33+ * A job on the queue: index more of a workspace's docs and folios; or
34+ * bring a large folio subtree's access, rooms and index scope up to date
35+ * after a move or a sharing change (src/folios/service.ts).
36+ */
37+export type DocsJob = { type: "docs.index"; workspace_id: string } | { type: "folios.reacl"; folio_id: string };
3238
33−export type IndexEnv = { DB: D1Database; AI?: Ai; VECTORS?: Vectorize; JOBS?: Queue<DocsJob> };
39+export type IndexEnv = {
40+ DB: D1Database;
41+ AI?: Ai;
42+ VECTORS?: Vectorize;
43+ /** Folios' semantic index, Vectorize `g1t-folios`. Optional: without it folios match words only. */
44+ FOLIO_VECTORS?: Vectorize;
45+ JOBS?: Queue<DocsJob>;
46+};
3447
3548 /** The embedder and store this deployment has; null without them (words only). */
3649 export function adapters(env: IndexEnv): { embedder: Embedder | null; store: VectorStore | null } {
333346 */
334347 export async function ensureIndexed(env: IndexEnv, workspaceId: string): Promise<void> {
335348 const row = await env.DB.prepare(
336− "SELECT (SELECT 1 FROM doc_index_runs WHERE workspace_id = ?1) AS ran, (SELECT 1 FROM pages WHERE workspace_id = ?1 AND archived_at IS NULL LIMIT 1) AS page, (SELECT 1 FROM repo_spaces WHERE workspace_id = ?1 LIMIT 1) AS repo",
349+ "SELECT (SELECT 1 FROM doc_index_runs WHERE workspace_id = ?1) AS ran, (SELECT 1 FROM pages WHERE workspace_id = ?1 AND archived_at IS NULL LIMIT 1) AS page, (SELECT 1 FROM folios WHERE workspace_id = ?1 AND trashed_at IS NULL LIMIT 1) AS folio, (SELECT 1 FROM repo_spaces WHERE workspace_id = ?1 LIMIT 1) AS repo",
337350 )
338351 .bind(workspaceId)
339− .first<{ ran: number | null; page: number | null; repo: number | null }>();
340− if (!row || row.ran || (!row.page && !row.repo)) return;
352+ .first<{ ran: number | null; page: number | null; folio: number | null; repo: number | null }>();
353+ if (!row || row.ran || (!row.page && !row.folio && !row.repo)) return;
341354 await startBackfill(env, workspaceId);
342355 }
343356
355368 let pages = 0;
356369 let files = 0;
357370 let capped = false;
358− if (cursor.startsWith("p:")) {
371+ if (cursor.startsWith("o:")) {
372+ // Folios, after pages and before projects' docs.
359373 const rows = (
374+ await env.DB.prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE workspace_id = ? AND trashed_at IS NULL AND id > ? ORDER BY id LIMIT ?`)
375+ .bind(workspaceId, cursor.slice(2), BACKFILL_BATCH)
376+ .all<FolioRow>()
377+ ).results;
378+ for (const row of rows) {
379+ const result = await indexFolio(env, row.id);
380+ pages++;
381+ if (result.capped) {
382+ capped = true;
383+ break;
384+ }
385+ next = `o:${row.id}`;
386+ }
387+ if (!capped && rows.length < BACKFILL_BATCH) next = "f:";
388+ } else if (cursor.startsWith("p:")) {
389+ const rows = (
360390 await env.DB.prepare("SELECT id, workspace_id, space_id, title, markdown, archived_at FROM pages WHERE workspace_id = ? AND archived_at IS NULL AND id > ? ORDER BY id LIMIT ?")
361391 .bind(workspaceId, cursor.slice(2), BACKFILL_BATCH)
362392 .all<PageForIndex>()
371401 }
372402 next = `p:${page.id}`;
373403 }
374− if (!capped && rows.length < BACKFILL_BATCH) next = "f:";
404+ if (!capped && rows.length < BACKFILL_BATCH) next = "o:";
375405 } else {
376406 const [space, path] = splitFileCursor(cursor.slice(2));
377407 const rows = (
406436 // Empty: from the start.
407437 return at < 0 ? ["", ""] : [cursor.slice(0, at), cursor.slice(at + 1)];
408438 }
439+
440+// ── Folios (Artifacts mode) ─────────────────────────────────────────────
441+
442+let saidNoFolioIndex = false;
443+
444+/**
445+ * The embedder and folio index this deployment has. Without Workers AI or
446+ * the `g1t-folios` index (FOLIO_VECTORS, which is made by hand before a
447+ * deploy that uses it), folios keep their passages in D1 and recall and
448+ * search match words; that is said once per isolate.
449+ */
450+export function folioAdapters(env: IndexEnv): { embedder: Embedder | null; store: VectorStore | null } {
451+ if (env.AI && env.FOLIO_VECTORS) return { embedder: cloudflareEmbedder(env.AI), store: cloudflareVectors(env.FOLIO_VECTORS) };
452+ if (!saidNoFolioIndex) {
453+ saidNoFolioIndex = true;
454+ console.log(`folios: no ${env.AI ? "FOLIO_VECTORS (Vectorize g1t-folios)" : "AI"} binding, so artifacts are searched and recalled by their words only`);
455+ }
456+ return { embedder: null, store: null };
457+}
458+
459+type FolioChunkRow = { id: string; seq: number; scope: string; hash: string; vector_hash: string | null };
460+
461+/**
462+ * Brings one folio's passages up to date, as `indexDoc` does for pages:
463+ * rows and full text in D1, under the folio's index scope, and vectors
464+ * for passages whose text or scope changed (a passage whose text the
465+ * index already holds keeps its vector; only its metadata is written
466+ * again). Forgets it when it is gone or in the trash. Never throws.
467+ */
468+export async function indexFolio(env: IndexEnv, folioId: string, now = new Date()): Promise<{ capped: boolean }> {
469+ try {
470+ const db = env.DB;
471+ const row = await db.prepare(`SELECT ${FOLIO_COLUMNS.replace("'' AS text", "text")} FROM folios WHERE id = ?`).bind(folioId).first<FolioRow>();
472+ if (!row || row.trashed_at) {
473+ await forgetFolios(env, [folioId]);
474+ return { capped: false };
475+ }
476+ const scope = (await scopesOf(db, [row])).get(row.id) ?? `folio:${row.acl_root}`;
477+ const model = kindModel(row.kind);
478+ const at = now.toISOString();
479+ const chunks = (model ? model.chunks(row.text, row.title) : chunkMarkdown(row.text, row.title)).map((c) => {
480+ const embed = embedText(row.title, c);
481+ return { ...c, id: chunkId(row.id, c.seq), embed, hash: textHash(embed) };
482+ });
483+ const old = (await db.prepare("SELECT id, seq, scope, hash, vector_hash FROM folio_chunks WHERE folio_id = ?").bind(row.id).all<FolioChunkRow>()).results;
484+ const byId = new Map(old.map((r) => [r.id, r]));
485+ const statements: D1PreparedStatement[] = [];
486+ for (const c of chunks) {
487+ const was = byId.get(c.id);
488+ if (was && was.hash === c.hash && was.scope === scope) continue;
489+ statements.push(
490+ db
491+ .prepare(
492+ `INSERT INTO folio_chunks (id, workspace_id, folio_id, kind, scope, seq, heading, text, hash, vector_hash, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)
493+ ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, scope = excluded.scope, heading = excluded.heading, text = excluded.text, hash = excluded.hash, updated_at = excluded.updated_at`,
494+ )
495+ .bind(c.id, row.workspace_id, row.id, row.kind, scope, c.seq, c.heading, c.text, c.hash, at),
496+ db.prepare("DELETE FROM folio_chunks_fts WHERE chunk_id = ?").bind(c.id),
497+ db.prepare("INSERT INTO folio_chunks_fts (chunk_id, scope, folio_id, heading, text) VALUES (?, ?, ?, ?, ?)").bind(c.id, scope, row.id, c.heading ?? "", c.text),
498+ );
499+ }
500+ const keep = new Set(chunks.map((c) => c.id));
501+ const gone = old.filter((r) => !keep.has(r.id));
502+ for (const r of gone) statements.push(db.prepare("DELETE FROM folio_chunks WHERE id = ?").bind(r.id), db.prepare("DELETE FROM folio_chunks_fts WHERE chunk_id = ?").bind(r.id));
503+ for (let i = 0; i < statements.length; i += 60) await db.batch(statements.slice(i, i + 60));
504+
505+ const { embedder, store } = folioAdapters(env);
506+ if (!embedder || !store) return { capped: false };
507+ // What needs a vector written: text the index doesn't hold under this id, or a new scope.
508+ const stale = chunks.filter((c) => {
509+ const was = byId.get(c.id);
510+ return !was || was.vector_hash !== c.hash || was.scope !== scope;
511+ });
512+ let capped = false;
513+ if (stale.length) {
514+ const held = new Map<string, string>();
515+ for (const r of old) if (r.vector_hash) held.set(r.vector_hash, r.id);
516+ const reuse = stale.filter((c) => held.has(c.hash));
517+ const fresh = stale.filter((c) => !held.has(c.hash));
518+ const budget = fresh.length ? await allowance(db, row.workspace_id, now) : 0;
519+ const embedNow = fresh.slice(0, budget);
520+ capped = embedNow.length < fresh.length;
521+ if (capped) console.log("folios embedding cap reached", row.workspace_id, `${fresh.length - embedNow.length} passages wait for the next hour`);
522+ const metadata: VectorMetadata = { workspace_id: row.workspace_id, scope, kind: row.kind, folio_id: row.id };
523+ const done: { id: string; hash: string }[] = [];
524+ try {
525+ const vectors: { id: string; values: number[]; metadata: VectorMetadata }[] = [];
526+ if (reuse.length) {
527+ const values = new Map((await store.get([...new Set(reuse.map((c) => held.get(c.hash)!))])).map((v) => [v.id, v.values]));
528+ for (const c of reuse) {
529+ const v = values.get(held.get(c.hash)!);
530+ if (v) vectors.push({ id: c.id, values: v, metadata });
531+ }
532+ }
533+ if (embedNow.length) {
534+ const embedded = await embedder.embed(embedNow.map((c) => c.embed));
535+ embedNow.forEach((c, i) => vectors.push({ id: c.id, values: embedded[i]!, metadata }));
536+ await meter(
537+ db,
538+ row.workspace_id,
539+ embedNow.length,
540+ embedNow.reduce((n, c) => n + c.embed.length, 0),
541+ now,
542+ );
543+ }
544+ if (vectors.length) await store.upsert(vectors);
545+ const hashOf = new Map(chunks.map((c) => [c.id, c.hash]));
546+ for (const v of vectors) done.push({ id: v.id, hash: hashOf.get(v.id)! });
547+ } catch (error) {
548+ console.error("folios could not embed passages; the next save tries again", row.id, String(error));
549+ }
550+ if (done.length) {
551+ const updates = done.map((d) => db.prepare("UPDATE folio_chunks SET vector_hash = ? WHERE id = ?").bind(d.hash, d.id));
552+ for (let i = 0; i < updates.length; i += 60) await db.batch(updates.slice(i, i + 60));
553+ }
554+ }
555+ try {
556+ if (gone.some((r) => r.vector_hash)) await store.delete(gone.filter((r) => r.vector_hash).map((r) => r.id));
557+ } catch (error) {
558+ console.error("folios could not drop old passages from the index", row.id, String(error));
559+ }
560+ if (capped) await startBackfill(env, row.workspace_id, { delaySeconds: CAP_DELAY_SECONDS });
561+ return { capped };
562+ } catch (error) {
563+ console.error("folios could not index", folioId, String(error));
564+ return { capped: false };
565+ }
566+}
567+
568+/** Takes folios' passages out of D1 and the index. Never throws. */
569+export async function forgetFolios(env: IndexEnv, ids: readonly string[]): Promise<void> {
570+ if (!ids.length) return;
571+ const db = env.DB;
572+ try {
573+ const found: string[] = [];
574+ for (let i = 0; i < ids.length; i += 500) {
575+ const rows = await db
576+ .prepare("SELECT id FROM folio_chunks WHERE folio_id IN (SELECT value FROM json_each(?))")
577+ .bind(JSON.stringify(ids.slice(i, i + 500)))
578+ .all<{ id: string }>();
579+ found.push(...rows.results.map((r) => r.id));
580+ }
581+ if (!found.length) return;
582+ const { store } = folioAdapters(env);
583+ if (store) {
584+ try {
585+ await store.delete(found);
586+ } catch (error) {
587+ console.error("folios could not drop passages from the index", found.length, String(error));
588+ }
589+ }
590+ const statements = found.flatMap((id) => [db.prepare("DELETE FROM folio_chunks WHERE id = ?").bind(id), db.prepare("DELETE FROM folio_chunks_fts WHERE chunk_id = ?").bind(id)]);
591+ for (let i = 0; i < statements.length; i += 80) await db.batch(statements.slice(i, i + 80));
592+ } catch (error) {
593+ console.error("folios could not forget passages", String(error));
594+ }
595+}
+70−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import * as Y from "yjs";
5+
6+import { KINDS, kindModel } from "../index.ts";
7+import { doc, docTarget, docTargets, linkedFolioIds, previewLines } from "./index.ts";
8+
9+const origin = { key: "user:ana", kind: "edit" as const, note: null };
10+
11+test("the registry has the doc kind and nothing not built yet", () => {
12+ assert.equal(kindModel("doc"), doc);
13+ assert.equal(kindModel("slides"), null);
14+ assert.equal(kindModel("nope"), null);
15+ assert.deepEqual(Object.keys(KINDS), ["doc"]);
16+});
17+
18+test("a doc seeds from Markdown and renders it back, with its card", () => {
19+ const d = new Y.Doc();
20+ assert.equal(doc.isEmpty(d), true);
21+ doc.seed(d, { text: "# Plan\n\nShip it on Tuesday.\n\n- one\n- two\n" });
22+ assert.equal(doc.isEmpty(d), false);
23+ const r = doc.render(d);
24+ assert.match(r.text, /^# Plan/);
25+ assert.match(r.text, /Ship it on Tuesday\./);
26+ assert.deepEqual(r.preview, { kind: "doc", lines: ["Plan", "Ship it on Tuesday.", "one", "two"] });
27+ const read = doc.read(d);
28+ assert.equal(read.content, r.text);
29+ assert.ok((read.blocks ?? []).length >= 3);
30+ assert.ok(doc.chunks(r.text, "Plan").length >= 1);
31+ assert.equal(doc.validate(d), null);
32+});
33+
34+test("an agent's edit applies to its target, and a missing one doesn't", () => {
35+ const d = new Y.Doc();
36+ doc.seed(d, { text: "# Notes\n\nFirst.\n\n## Risks\n\nNone yet.\n" });
37+ const appended = doc.applyAgentEdit(d, { kind: "doc", target: { kind: "append" }, markdown: "Added by an agent." }, origin);
38+ assert.deepEqual(appended, { applied: true, summary: "Added to the end" });
39+ assert.match(doc.render(d).text, /Added by an agent\./);
40+ const section = doc.applyAgentEdit(d, { kind: "doc", target: { kind: "section", heading: "Risks" }, markdown: "## Risks\n\nThe date." }, origin);
41+ assert.equal(section.applied, true);
42+ assert.match(doc.render(d).text, /The date\./);
43+ assert.doesNotMatch(doc.render(d).text, /None yet\./);
44+ const gone = doc.applyAgentEdit(d, { kind: "doc", target: { kind: "section", heading: "Nowhere" }, markdown: "x" }, origin);
45+ assert.equal(gone.applied, false);
46+ assert.ok(docTarget(d, { kind: "section", heading: "Risks" }));
47+ assert.equal(docTarget(d, { kind: "section", heading: "Nowhere" }), null);
48+ assert.deepEqual(docTargets(d, [{ kind: "section", heading: "Nowhere" }]), [null]);
49+ // Another kind's edit is never applied to a doc.
50+ assert.equal(doc.applyAgentEdit(d, { kind: "slides", ops: [] } as never, origin).applied, false);
51+});
52+
53+test("a doc restores from an old state and from old text", () => {
54+ const d = new Y.Doc();
55+ doc.seed(d, { text: "Version one.\n" });
56+ const old = new Y.Doc();
57+ Y.applyUpdate(old, Y.encodeStateAsUpdate(d));
58+ doc.applyAgentEdit(d, { kind: "doc", target: { kind: "document" }, markdown: "Version two." }, origin);
59+ assert.match(doc.render(d).text, /Version two/);
60+ doc.restore(d, old, { ...origin, kind: "restore" });
61+ assert.match(doc.render(d).text, /Version one/);
62+ doc.restoreText(d, "Version three.\n", { ...origin, kind: "restore" });
63+ assert.match(doc.render(d).text, /Version three/);
64+});
65+
66+test("links to other folios and preview lines", () => {
67+ const a = "fol_01jb2k7x9hfq0b3zj0f5s2m8ra";
68+ assert.deepEqual(linkedFolioIds(`see /acme/-/artifacts/plan-${a} and ${a}`), [a]);
69+ assert.deepEqual(previewLines("# A\n\n**B** text\n\n```\ncode\n```\n", 2), ["A", "B text"]);
70+});
+110−0
1+/**
2+ * The doc kind: BlockNote on `XmlFragment("document-store")`, comments in
3+ * the `threads` map, Markdown as its text rendition. The same document,
4+ * code and behaviour as Docs' pages (src/blocks.ts, src/markdown.ts,
5+ * src/edits.ts, src/citations.ts, src/threads.ts), which PageRoom still
6+ * uses until Phase 7 retires it; those modules move here then. Pure.
7+ */
8+import type { DocEditTarget } from "@g1t/contracts";
9+import * as Y from "yjs";
10+
11+import { seed as seedBlocks } from "../../blocks.ts";
12+import { chunkMarkdown } from "../../chunks.ts";
13+import { bodyCitations } from "../../citations.ts";
14+import { applyEdit, findTarget, rangeIds, rangeMarkdown, restoreFrom } from "../../edits.ts";
15+import { citationNodes, documentMarkdown, mentionedIds, outline, searchText } from "../../markdown.ts";
16+import type { FolioOrigin, KindModel, Rendition } from "../types.ts";
17+
18+/** Every folio id a text links to, for backlinks (as `linkedFolioIds` in @g1t/contracts; kept here so this module stays pure for Node's tests). */
19+export function linkedFolioIds(text: string): string[] {
20+ return [...new Set(text.match(/fol_[0-9a-hjkmnp-tv-z]{26}/g) ?? [])];
21+}
22+
23+/** Where BlockNote keeps the document. */
24+export const DOC_FRAGMENT = "document-store";
25+/** The most a doc's Markdown may be. */
26+export const DOC_MAX_TEXT = 512 * 1024;
27+/** Lines a card shows. */
28+const PREVIEW_LINES = 4;
29+
30+export function docFragment(doc: Y.Doc): Y.XmlFragment {
31+ return doc.getXmlFragment(DOC_FRAGMENT);
32+}
33+
34+/** The first lines of a doc, as plain text, for its card. */
35+export function previewLines(markdown: string, max = PREVIEW_LINES): string[] {
36+ return searchText(markdown)
37+ .split("\n")
38+ .map((l) => l.replace(/\s+/g, " ").trim())
39+ .filter(Boolean)
40+ .slice(0, max)
41+ .map((l) => (l.length > 120 ? `${l.slice(0, 119)}…` : l));
42+}
43+
44+/** A target's current Markdown and blocks, or null when it is gone. */
45+export function docTarget(doc: Y.Doc, target: DocEditTarget): { markdown: string; block_ids: string[] } | null {
46+ const fragment = docFragment(doc);
47+ const range = findTarget(fragment, target);
48+ if (!range) return null;
49+ return { markdown: rangeMarkdown(fragment, range), block_ids: rangeIds(fragment, range) };
50+}
51+
52+/** Where each target is now, for marking open suggestions in the editor (null: gone). */
53+export function docTargets(doc: Y.Doc, targets: DocEditTarget[]): (string[] | null)[] {
54+ const fragment = docFragment(doc);
55+ return targets.map((t) => {
56+ const range = findTarget(fragment, t);
57+ return range ? rangeIds(fragment, range) : null;
58+ });
59+}
60+
61+function describe(target: DocEditTarget): string {
62+ switch (target.kind) {
63+ case "append":
64+ return "Added to the end";
65+ case "document":
66+ return "Rewrote the doc";
67+ case "section":
68+ return `Changed the section "${target.heading}"`;
69+ case "blocks":
70+ return "Changed some blocks";
71+ }
72+}
73+
74+export const doc: KindModel = {
75+ kind: "doc",
76+ isEmpty: (d) => docFragment(d).length === 0,
77+ seed(d, init) {
78+ seedBlocks(d, docFragment(d), String(init.text ?? "").slice(0, DOC_MAX_TEXT));
79+ },
80+ render(d): Rendition {
81+ const fragment = docFragment(d);
82+ const text = documentMarkdown(fragment);
83+ return {
84+ text,
85+ mentions: mentionedIds(fragment).users,
86+ links: linkedFolioIds(text),
87+ citations: bodyCitations(citationNodes(fragment), text),
88+ preview: { kind: "doc", lines: previewLines(text) },
89+ };
90+ },
91+ chunks: (text, title) => chunkMarkdown(text, title),
92+ read(d) {
93+ const fragment = docFragment(d);
94+ return { content: documentMarkdown(fragment), blocks: outline(fragment) };
95+ },
96+ applyAgentEdit(d, edit, origin: FolioOrigin) {
97+ if (edit.kind !== "doc") return { applied: false, summary: "That edit is for another kind of artifact." };
98+ const applied = applyEdit(d, docFragment(d), edit.target, String(edit.markdown ?? "").slice(0, DOC_MAX_TEXT), origin);
99+ return { applied, summary: applied ? describe(edit.target) : "That part of the doc isn't there." };
100+ },
101+ restore(d, old, origin) {
102+ restoreFrom(d, docFragment(d), docFragment(old), origin);
103+ },
104+ restoreText(d, text, origin) {
105+ applyEdit(d, docFragment(d), { kind: "document" }, text, origin);
106+ },
107+ validate(d) {
108+ return documentMarkdown(docFragment(d)).length > DOC_MAX_TEXT * 2 ? "This doc is too long." : null;
109+ },
110+};
+19−0
1+/**
2+ * The kinds the room knows, one line each (docs/ARTIFACTS_MODE.md,
3+ * section 3). A kind without a line here can't be made yet: the service
4+ * says it is coming. Append only; never reformat (parallel phases each
5+ * add their line).
6+ */
7+import type { FolioKind } from "@g1t/contracts";
8+
9+import { doc } from "./doc/index.ts";
10+import type { KindModel } from "./types.ts";
11+
12+export const KINDS: Partial<Record<FolioKind, KindModel>> = {
13+ doc,
14+};
15+
16+/** The kind's model, or null when it isn't built yet. */
17+export function kindModel(kind: string | null | undefined): KindModel | null {
18+ return (KINDS as Record<string, KindModel | undefined>)[String(kind ?? "")] ?? null;
19+}
+51−0
1+/**
2+ * What every kind of folio says about its own content, so one room
3+ * (FolioRoom, src/folios/room.ts) serves docs, slides, designs and
4+ * dashboards alike (docs/ARTIFACTS_MODE.md, section 3). Each kind is one
5+ * module under src/kinds/ and one line in src/kinds/index.ts. Pure (yjs
6+ * only): nothing here reads D1 or the network.
7+ */
8+import type { DocBlockOutline, DocCitation, FolioAgentEdit, FolioKind, FolioPreview, FolioVersionKind } from "@g1t/contracts";
9+import type * as Y from "yjs";
10+
11+import type { Chunk } from "../chunks.ts";
12+
13+/** Who made a change on the server, for history. */
14+export type FolioOrigin = { key: string; kind: FolioVersionKind; note: string | null; authors?: string[] };
15+
16+/** What the room saves after a burst of edits: everything derived from the content. */
17+export type Rendition = {
18+ /** The text rendition: search, recall, the read view, export. Never data values. */
19+ text: string;
20+ /** People mentioned (usernames, lowercased). */
21+ mentions: string[];
22+ /** Folio ids it links to. */
23+ links: string[];
24+ /** Code it cites. */
25+ citations: DocCitation[];
26+ /** What a card draws. */
27+ preview: FolioPreview | null;
28+};
29+
30+/** The folio in the form an agent reads and writes. */
31+export type AgentForm = { content: string; blocks?: DocBlockOutline[] };
32+
33+export interface KindModel {
34+ kind: FolioKind;
35+ /** Whether the document has nothing in it yet (so `seed` may fill it). */
36+ isEmpty(doc: Y.Doc): boolean;
37+ /** Fills an empty document: from a template's or an agent's text (Markdown) or spec, or blank. */
38+ seed(doc: Y.Doc, init: { text?: string | null; spec?: unknown }): void;
39+ render(doc: Y.Doc): Rendition;
40+ /** The text rendition as passages for the index (src/indexer.ts). */
41+ chunks(text: string, title: string): Chunk[];
42+ read(doc: Y.Doc): AgentForm;
43+ /** Applies an agent's (or a token's) edit in the kind's own terms. Not applied: what it targets is gone. */
44+ applyAgentEdit(doc: Y.Doc, edit: FolioAgentEdit, origin: FolioOrigin): { applied: boolean; summary: string };
45+ /** Makes the document what an old one was, as one change. */
46+ restore(doc: Y.Doc, old: Y.Doc, origin: FolioOrigin): void;
47+ /** Makes the document what an old text rendition was, when no state was kept. */
48+ restoreText(doc: Y.Doc, text: string, origin: FolioOrigin): void;
49+ /** What is wrong with the document's size or shape (node counts, sizes), or null. */
50+ validate(doc: Y.Doc): string | null;
51+}
+124−1
44 * newly mentioned in the page. Run by the room (src/room.ts), which owns
55 * the live document; nothing here reads the document itself.
66 */
7−import { newId, notifyClient, type DocCitation, type DocVersionKind, type FeedNotification, type ServiceBinding } from "@g1t/contracts";
7+import { newId, notifyClient, type DocCitation, type DocVersionKind, type FeedNotification, type FolioKind, type FolioVersionKind, type ServiceBinding } from "@g1t/contracts";
88
99 import { publishDocEvent } from "./events.ts";
10+import { fileStore, type FileStoreEnv } from "./files.ts";
11+import { workspaceReadable } from "./folios/access-store.ts";
12+import { publishFolioEvent } from "./folios/events.ts";
13+import type { Rendition } from "./kinds/types.ts";
1014 import { excerpt, searchText } from "./markdown.ts";
1115 import { linkedPageIds, pageSlug } from "./slugs.ts";
1216
148152 }
149153 return { version_id: versionId, changed };
150154 }
155+
156+// ── Folios (Artifacts mode) ─────────────────────────────────────────────
157+
158+export type SaveFolioEnv = SaveEnv & FileStoreEnv;
159+
160+export type SaveFolio = {
161+ folio_id: string;
162+ /** What the kind rendered from the document (src/kinds/types.ts). */
163+ rendition: Rendition;
164+ /** The editors since the last save, member keys, last one last. */
165+ editors: string[];
166+ /** The usernames of the people who made these edits, lowercased. */
167+ editor_names: string[];
168+ state: Uint8Array;
169+ version: { kind: FolioVersionKind; note: string | null; authors: string[] } | null;
170+ pending_authors: string[];
171+ last_version_at: number;
172+ workspace_slug: string | null;
173+};
174+
175+type FolioSaveRow = { id: string; workspace_id: string; kind: FolioKind; title: string; text: string; preview: string | null; mentioned: string; space_id: string | null; trashed_at: string | null };
176+
177+/**
178+ * What a folio's room saves to D1 after a burst of edits: its text
179+ * rendition, card, search text, links, citations and history. Returns the
180+ * version recorded (if one was), whether the text changed (so the room
181+ * indexes it again), and the people newly mentioned, whom the room tells
182+ * only if they can read the folio (src/folios/notify.ts).
183+ */
184+export async function saveFolio(env: SaveFolioEnv, input: SaveFolio, now = new Date()): Promise<{ version_id: string | null; changed: boolean; mentioned: string[]; last: string | null }> {
185+ const at = now.toISOString();
186+ const db = env.DB;
187+ const folio = await db.prepare("SELECT id, workspace_id, kind, title, text, preview, mentioned, space_id, trashed_at FROM folios WHERE id = ?").bind(input.folio_id).first<FolioSaveRow>();
188+ if (!folio) return { version_id: null, changed: false, mentioned: [], last: null };
189+ const r = input.rendition;
190+ const changed = folio.text !== r.text;
191+ const last = input.editors[input.editors.length - 1] ?? null;
192+ const statements: D1PreparedStatement[] = [];
193+ const preview = r.preview ? JSON.stringify(r.preview) : null;
194+ // A folio made from text that already reads as the kind renders it still needs its card.
195+ if (!changed && preview !== folio.preview) statements.push(db.prepare("UPDATE folios SET preview = ? WHERE id = ?").bind(preview, folio.id));
196+ if (changed) {
197+ statements.push(
198+ db
199+ .prepare("UPDATE folios SET text = ?, excerpt = ?, preview = ?, edited_at = ?, edited_by = COALESCE(?, edited_by), updated_at = ? WHERE id = ?")
200+ .bind(r.text, excerpt(r.text), preview, at, last, at, folio.id),
201+ db.prepare("DELETE FROM folios_fts WHERE folio_id = ?").bind(folio.id),
202+ db.prepare("INSERT INTO folios_fts (folio_id, kind, title, body) VALUES (?, ?, ?, ?)").bind(folio.id, folio.kind, folio.title, searchText(r.text)),
203+ db.prepare("DELETE FROM folio_links WHERE from_folio = ?").bind(folio.id),
204+ db.prepare("DELETE FROM folio_citations WHERE folio_id = ? AND source = 'body'").bind(folio.id),
205+ );
206+ for (const to of r.links.filter((id) => id !== folio.id).slice(0, 200)) {
207+ statements.push(db.prepare("INSERT OR IGNORE INTO folio_links (from_folio, to_folio) VALUES (?, ?)").bind(folio.id, to));
208+ }
209+ for (const c of r.citations) {
210+ statements.push(
211+ db.prepare("INSERT OR IGNORE INTO folio_citations (folio_id, repo, path, kind, label, ref, source) VALUES (?, ?, ?, ?, ?, ?, 'body')").bind(folio.id, c.repo, c.path, c.kind, c.label ?? "", c.ref),
212+ );
213+ }
214+ }
215+ // A version: asked for, or the first save with changes after enough time.
216+ let versionId: string | null = null;
217+ const timed = input.pending_authors.length > 0 && now.getTime() - input.last_version_at >= VERSION_EVERY_MS;
218+ if (input.version || (timed && changed)) {
219+ versionId = newId("ver", now.getTime());
220+ const authors = input.version?.authors.length ? input.version.authors : input.pending_authors;
221+ let state: Uint8Array | null = input.state.byteLength <= MAX_VERSION_STATE ? input.state : null;
222+ let stateKey: string | null = null;
223+ if (!state) {
224+ // Too large for a row: the file store keeps it.
225+ try {
226+ stateKey = `docs/versions/${folio.id}/${versionId}`;
227+ await fileStore(env).put(stateKey, input.state, "application/octet-stream");
228+ } catch (error) {
229+ console.error("folios could not keep a large version's state; its text is kept", folio.id, String(error));
230+ stateKey = null;
231+ state = null;
232+ }
233+ }
234+ statements.push(
235+ db
236+ .prepare("INSERT INTO folio_versions (id, folio_id, created_at, kind, authors, note, text, state, state_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
237+ .bind(versionId, folio.id, at, input.version?.kind ?? "edit", JSON.stringify([...new Set(authors)]), input.version?.note ?? null, r.text, state, stateKey),
238+ );
239+ }
240+ let told: string[] = [];
241+ try {
242+ told = JSON.parse(folio.mentioned) as string[];
243+ } catch {
244+ told = [];
245+ }
246+ const editors = new Set(input.editor_names);
247+ const fresh = r.mentions.filter((name) => !told.includes(name) && !editors.has(name));
248+ if (fresh.length || r.mentions.length !== told.length) {
249+ statements.push(db.prepare("UPDATE folios SET mentioned = ? WHERE id = ?").bind(JSON.stringify([...new Set([...told.filter((n) => r.mentions.includes(n)), ...fresh])]), folio.id));
250+ }
251+ if (statements.length) await db.batch(statements);
252+ const kind = input.version?.kind ?? "edit";
253+ if (versionId && kind !== "created" && input.workspace_slug && !folio.trashed_at) {
254+ const open = await workspaceReadable(db, folio.id).catch(() => false);
255+ await publishFolioEvent(
256+ env.EVENTS,
257+ "folio.updated",
258+ {
259+ workspace: input.workspace_slug,
260+ workspaceId: folio.workspace_id,
261+ folioId: folio.id,
262+ kind: folio.kind,
263+ spaceId: folio.space_id,
264+ title: open ? folio.title : null,
265+ versionId,
266+ versionKind: kind,
267+ authors: [...new Set(input.version?.authors.length ? input.version.authors : input.pending_authors)],
268+ },
269+ last,
270+ );
271+ }
272+ return { version_id: versionId, changed, mentioned: folio.trashed_at ? [] : fresh, last };
273+}
+15−3
3232 } from "@g1t/contracts";
3333
3434 import { touchedPaths } from "./citations.ts";
35+import { foliosCite, recordFolioChanges } from "./folios/staleness.ts";
36+import type { FolioRoom } from "./folios/room.ts";
3537 import type { PageRoom } from "./room.ts";
3638 import { publishDocEvent } from "./events.ts";
3739 import { pageSlug } from "./slugs.ts";
4446 NOTIFY?: ServiceBinding;
4547 EVENTS?: ServiceBinding;
4648 PAGES?: DurableObjectNamespace<PageRoom>;
49+ /** Folios' rooms (Artifacts mode): folios cite code too (src/folios/staleness.ts). */
50+ FOLIOS?: DurableObjectNamespace<FolioRoom>;
4751 };
4852
4953 /** What a change touched, as this module records it. */
7781 db.prepare("SELECT 1 AS yes FROM citations c JOIN pages p ON p.id = c.page_id WHERE c.repo = ? AND p.archived_at IS NULL LIMIT 1").bind(repo),
7882 db.prepare("SELECT 1 AS yes FROM repo_spaces WHERE repo_id = ? LIMIT 1").bind(repoId),
7983 ]);
80− return { cited: !!cited?.results.length, spaces: !!spaces?.results.length };
84+ const folios = cited?.results.length ? false : await foliosCite(db, repo).catch(() => false);
85+ return { cited: !!cited?.results.length || folios, spaces: !!spaces?.results.length };
8186 }
8287
8388 /** What a push to the default branch changed: the files between where it was and where it is. */
256261 db.prepare("UPDATE OR IGNORE citations SET repo = ? WHERE repo = ?").bind(current, old),
257262 db.prepare("UPDATE OR IGNORE page_changes SET repo = ? WHERE repo = ?").bind(current, old),
258263 db.prepare("UPDATE OR IGNORE page_projects SET repo = ? WHERE repo = ?").bind(current, old),
264+ db.prepare("UPDATE OR IGNORE folio_citations SET repo = ? WHERE repo = ?").bind(current, old),
265+ db.prepare("UPDATE OR IGNORE folio_changes SET repo = ? WHERE repo = ?").bind(current, old),
266+ db.prepare("UPDATE OR IGNORE folio_projects SET repo = ? WHERE repo = ?").bind(current, old),
259267 db.prepare("UPDATE OR IGNORE space_projects SET repo = ? WHERE repo = ?").bind(current, old),
260268 db.prepare("UPDATE repo_spaces SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
261269 );
284292 if (!wants.cited) return;
285293 const changed = await pushChange(env, path, repoId, event.data.before, event.data.after);
286294 if (!changed?.length) return;
287− await record(env, { repo, repo_id: repoId, commit: event.data.after, pull: null, changed, actor: event.actor });
295+ const change: Change = { repo, repo_id: repoId, commit: event.data.after, pull: null, changed, actor: event.actor };
296+ await record(env, change);
297+ await recordFolioChanges(env, change);
288298 return;
289299 }
290300 if (!wants.cited) return;
291301 const pulled = await pullChange(env, path, repoId, event.data.number);
292302 if (!pulled?.changed.length) return;
293− await record(env, { repo, repo_id: repoId, commit: event.data.commit, pull: { number: event.data.number, title: pulled.title }, changed: pulled.changed, actor: event.actor });
303+ const change: Change = { repo, repo_id: repoId, commit: event.data.commit, pull: { number: event.data.number, title: pulled.title }, changed: pulled.changed, actor: event.actor };
304+ await record(env, change);
305+ await recordFolioChanges(env, change);
294306 }
+13−2
77 * Vectorize (the `g1t-docs` index, cosine, metadata indexes on
88 * `workspace_id` and `space_id`). Without them (no AI or VECTORS
99 * binding), Docs keeps its passages in D1 and recall matches words only.
10+ *
11+ * Folios (Artifacts mode) have an index of their own, `g1t-folios`
12+ * (binding FOLIO_VECTORS), filtered by `workspace_id`, `scope` and `kind`:
13+ * the same two adapters, another index.
1014 */
1115
1216 /**
2529
2630 export type VectorMetadata = {
2731 workspace_id: string;
28− space_id: string;
29− kind: "page" | "repo_file";
32+ /** Docs' pages and projects' docs (index `g1t-docs`). */
33+ space_id?: string;
34+ /** Folios (index `g1t-folios`): `space:<id>` or `folio:<access root>` (src/access.ts `folioScope`). */
35+ scope?: string;
36+ kind: "page" | "repo_file" | "doc" | "slides" | "design" | "dashboard";
3037 page_id?: string;
3138 repo_file_id?: string;
3239 repo_id?: string;
40+ folio_id?: string;
3341 };
3442
3543 export type VectorFilter = {
3644 workspace_id: string;
3745 /** Only these spaces; absent for every space (then the caller filters what comes back). */
3846 space_ids?: string[];
47+ /** Folios: only these scopes; absent for every scope (then the caller filters what comes back). */
48+ scopes?: string[];
3949 };
4050
4151 export type VectorMatch = { id: string; score: number };
91101 async query(vector, options) {
92102 const filter: Record<string, unknown> = { workspace_id: options.filter.workspace_id };
93103 if (options.filter.space_ids) filter.space_id = { $in: options.filter.space_ids };
104+ if (options.filter.scopes) filter.scope = { $in: options.filter.scopes };
94105 const found = await index.query(vector, { topK: options.topK, returnMetadata: "none", returnValues: false, filter: filter as VectorizeVectorMetadataFilter });
95106 return found.matches.map((m) => ({ id: m.id, score: m.score }));
96107 },
+282−0
1+/**
2+ * Who and where, for the docs service's folio code: the workspace by
3+ * slug, its people, agents and teams, how member keys show, and the
4+ * spaces with a person's role in each. Cached per request (one instance
5+ * per request). Docs' page code (src/index.ts, `Docs`) keeps its own copy
6+ * of these until Phase 7 of docs/ARTIFACTS_MODE.md removes it.
7+ */
8+import {
9+ fail,
10+ identityClient,
11+ newId,
12+ ok,
13+ parsePrincipalKey,
14+ principalKey,
15+ workspaceAgentsClient,
16+ type DocAgentMode,
17+ type DocRole,
18+ type DocSpace,
19+ type DocSpaceKind,
20+ type Member,
21+ type MemberProfile,
22+ type Principal,
23+ type Result,
24+ type ServiceBinding,
25+ type User,
26+ type Viewer,
27+ type Workspace,
28+ type WorkspaceAgent,
29+} from "@g1t/contracts";
30+
31+import { roleOf, type Person, type SpaceRules } from "./access.ts";
32+import { freeSlug } from "./slugs.ts";
33+
34+export type WhoEnv = { DB: D1Database; IDENTITY: ServiceBinding; AGENTS: ServiceBinding };
35+
36+export type SpaceRow = {
37+ id: string;
38+ workspace_id: string;
39+ slug: string;
40+ name: string;
41+ description: string | null;
42+ icon: string | null;
43+ kind: DocSpaceKind;
44+ team: string | null;
45+ default_role: DocRole | null;
46+ agent_mode: DocAgentMode;
47+ is_default: number;
48+ created_by: string;
49+ created_at: string;
50+ archived_at: string | null;
51+};
52+
53+/** A space, with who is in it and the viewer's role (null: they can't read it). */
54+export type Space = { row: SpaceRow; members: { principal: string; role: DocRole }[]; projects: string[]; role: DocRole | null };
55+
56+export const now = () => new Date().toISOString();
57+
58+export function rulesOf(space: Pick<Space, "row" | "members">): SpaceRules {
59+ return { kind: space.row.kind, team: space.row.team, default_role: space.row.default_role, members: space.members };
60+}
61+
62+export function isMember(viewer: Viewer, workspace: string): boolean {
63+ return !!viewer?.workspaces?.some((m) => m.slug === String(workspace ?? "").toLowerCase());
64+}
65+
66+export function userKey(viewer: Pick<User, "id">): string {
67+ return principalKey({ kind: "user", id: viewer.id });
68+}
69+
70+export class Who {
71+ private readonly workspaces = new Map<string, Promise<Workspace | null>>();
72+ private readonly people = new Map<string, Promise<Map<string, Member>>>();
73+ private readonly teams = new Map<string, Promise<Map<string, Set<string>>>>();
74+ private readonly spaces = new Map<string, Promise<Omit<Space, "role">[]>>();
75+ readonly usernames = new Map<string, string>();
76+ private readonly agents = new Map<string, WorkspaceAgent | null>();
77+
78+ constructor(private readonly env: WhoEnv) {}
79+
80+ workspace(slug: string): Promise<Workspace | null> {
81+ const key = String(slug ?? "").toLowerCase();
82+ let found = this.workspaces.get(key);
83+ if (!found) {
84+ found = identityClient(this.env.IDENTITY).getWorkspace(key).catch(() => null);
85+ this.workspaces.set(key, found);
86+ }
87+ return found;
88+ }
89+
90+ /** The workspace acting for itself: how this service asks identity about its members. */
91+ actor(workspace: Workspace): User {
92+ return { id: workspace.id, username: workspace.slug, kind: "workspace", verified: true, workspaces: [{ slug: workspace.slug, role: "member" }] };
93+ }
94+
95+ /** The workspace's people by username (lowercased). */
96+ members(workspace: Workspace): Promise<Map<string, Member>> {
97+ let found = this.people.get(workspace.id);
98+ if (!found) {
99+ found = identityClient(this.env.IDENTITY)
100+ .listMembers(workspace.slug, this.actor(workspace))
101+ .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), m]) : []))
102+ .catch(() => new Map<string, Member>());
103+ this.people.set(workspace.id, found);
104+ }
105+ return found;
106+ }
107+
108+ /** Each member's teams (slugs, lowercased), by username. */
109+ teamsOf(workspace: Workspace): Promise<Map<string, Set<string>>> {
110+ let found = this.teams.get(workspace.id);
111+ if (!found) {
112+ found = identityClient(this.env.IDENTITY)
113+ .teamMemberships(this.actor(workspace), workspace.slug)
114+ .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), new Set(m.teams.map((t) => t.slug.toLowerCase()))]) : []))
115+ .catch(() => new Map<string, Set<string>>());
116+ this.teams.set(workspace.id, found);
117+ }
118+ return found;
119+ }
120+
121+ async nameUsers(ids: string[]): Promise<void> {
122+ const unnamed = [...new Set(ids)].filter((id) => !this.usernames.has(id));
123+ if (!unnamed.length) return;
124+ const named = await identityClient(this.env.IDENTITY)
125+ .usernames(unnamed)
126+ .catch(() => ({}) as Record<string, string>);
127+ for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
128+ }
129+
130+ async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> {
131+ const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id));
132+ if (wanted.length) {
133+ let found: WorkspaceAgent[] = [];
134+ try {
135+ found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted);
136+ } catch (error) {
137+ console.error("folios could not resolve agents", error);
138+ }
139+ for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null);
140+ }
141+ return new Map(ids.map((id) => [id, this.agents.get(id) ?? null]));
142+ }
143+
144+ /** How member keys show. Anything that isn't a person or agent shows as g1t. */
145+ async profiles(workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
146+ const principals = [...new Set(keys)].map((k) => parsePrincipalKey(k)).filter((p): p is Principal => !!p);
147+ const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
148+ const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
149+ const [, people, agents] = await Promise.all([this.nameUsers(userIds), userIds.length ? this.members(workspace) : new Map<string, Member>(), this.agentsById(agentIds)]);
150+ const out = new Map<string, MemberProfile>();
151+ for (const p of principals) {
152+ if (p.kind === "user") {
153+ const username = this.usernames.get(p.id) ?? null;
154+ const person = username ? people.get(username.toLowerCase()) : undefined;
155+ out.set(principalKey(p), { ...p, name: username ?? "ghost", display_name: person?.name || username || "Former member", avatar: person?.avatar ?? null, role: null, title: null, avatar_seed: null });
156+ } else {
157+ const agent = agents.get(p.id) ?? null;
158+ out.set(principalKey(p), {
159+ ...p,
160+ name: agent?.handle ?? p.id,
161+ display_name: agent?.display_name ?? "Former agent",
162+ avatar: agent?.avatar ?? null,
163+ role: agent?.role ?? null,
164+ title: agent?.title || null,
165+ avatar_seed: agent?.avatar_seed ?? null,
166+ });
167+ }
168+ }
169+ for (const key of keys) {
170+ if (!out.has(key)) out.set(key, { kind: "user", id: key, name: "g1t", display_name: "g1t", avatar: null, role: null, title: null, avatar_seed: null });
171+ }
172+ return out;
173+ }
174+
175+ async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> {
176+ if (!viewer?.id) return fail("unauthenticated", "Sign in to use Artifacts.");
177+ if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its Artifacts.");
178+ const workspace = await this.workspace(slug);
179+ return workspace ? ok(workspace) : fail("not_found", "No such workspace.");
180+ }
181+
182+ viewerOwner(viewer: User, slug: string): boolean {
183+ return !!viewer.workspaces?.some((m) => m.slug === slug.toLowerCase() && m.role === "owner");
184+ }
185+
186+ /** A person as access sees them: their teams, and whether they own the workspace. */
187+ async personOf(workspace: Workspace, user: Pick<User, "id" | "username">, owner: boolean): Promise<Person> {
188+ const teams = (await this.teamsOf(workspace)).get(String(user.username ?? "").toLowerCase()) ?? new Set<string>();
189+ return { user_id: user.id, owner, teams };
190+ }
191+
192+ /** The viewer as access sees them. */
193+ viewerPerson(workspace: Workspace, viewer: User): Promise<Person> {
194+ return this.personOf(workspace, viewer, this.viewerOwner(viewer, workspace.slug));
195+ }
196+
197+ /** People by user id as access sees them: members' teams and ownership; anyone else reads nothing. */
198+ async peopleByIds(workspace: Workspace, ids: string[]): Promise<Person[]> {
199+ const unique = [...new Set(ids.map(String))].slice(0, 200);
200+ await this.nameUsers(unique);
201+ const [members, teams] = await Promise.all([this.members(workspace), this.teamsOf(workspace)]);
202+ return unique.map((id) => {
203+ const username = this.usernames.get(id)?.toLowerCase() ?? "";
204+ const member = members.get(username);
205+ return { user_id: member ? id : `outside:${id}`, owner: member?.role === "owner", teams: member ? (teams.get(username) ?? new Set()) : new Set() };
206+ });
207+ }
208+
209+ /** Every space in the workspace (archived too), with members and projects. */
210+ allSpaces(workspace: Workspace): Promise<Omit<Space, "role">[]> {
211+ let found = this.spaces.get(workspace.id);
212+ if (!found) {
213+ found = (async () => {
214+ const db = this.env.DB;
215+ const [spaces, members, projects] = await Promise.all([
216+ db.prepare("SELECT * FROM spaces WHERE workspace_id = ? ORDER BY is_default DESC, name COLLATE NOCASE").bind(workspace.id).all<SpaceRow>(),
217+ db
218+ .prepare("SELECT m.space_id, m.principal, m.role FROM space_members m JOIN spaces s ON s.id = m.space_id WHERE s.workspace_id = ?")
219+ .bind(workspace.id)
220+ .all<{ space_id: string; principal: string; role: DocRole }>(),
221+ db.prepare("SELECT p.space_id, p.repo FROM space_projects p JOIN spaces s ON s.id = p.space_id WHERE s.workspace_id = ?").bind(workspace.id).all<{ space_id: string; repo: string }>(),
222+ ]);
223+ return spaces.results.map((row) => ({
224+ row,
225+ members: members.results.filter((m) => m.space_id === row.id).map((m) => ({ principal: m.principal, role: m.role })),
226+ projects: projects.results.filter((p) => p.space_id === row.id).map((p) => p.repo),
227+ }));
228+ })();
229+ this.spaces.set(workspace.id, found);
230+ }
231+ return found;
232+ }
233+
234+ /** Forget cached spaces after one changed. */
235+ forgetSpaces(): void {
236+ this.spaces.clear();
237+ }
238+
239+ /** The spaces with `person`'s role in each (null: they can't read it). Archived spaces too. */
240+ async spacesFor(workspace: Workspace, person: Person): Promise<Space[]> {
241+ const spaces = await this.allSpaces(workspace);
242+ return spaces.map((s) => ({ ...s, role: roleOf(rulesOf(s), person) }));
243+ }
244+
245+ /** Makes the workspace's General space, once. */
246+ async ensureDefault(workspace: Workspace, viewer: User): Promise<void> {
247+ const db = this.env.DB;
248+ const found = await db.prepare("SELECT id FROM spaces WHERE workspace_id = ? AND is_default = 1").bind(workspace.id).first<{ id: string }>();
249+ if (found) return;
250+ const taken = new Set((await db.prepare("SELECT slug FROM spaces WHERE workspace_id = ?").bind(workspace.id).all<{ slug: string }>()).results.map((r) => r.slug));
251+ await db
252+ .prepare(
253+ "INSERT OR IGNORE INTO spaces (id, workspace_id, slug, name, description, icon, kind, team, default_role, agent_mode, is_default, created_by, created_at) VALUES (?, ?, ?, 'General', 'Everything the whole workspace should know.', '📚', 'workspace', NULL, 'edit', 'suggest', 1, ?, ?)",
254+ )
255+ .bind(newId("spc"), workspace.id, freeSlug("general", taken), userKey(viewer), now())
256+ .run();
257+ this.forgetSpaces();
258+ }
259+
260+ toSpace(space: Space, pageCount = 0): DocSpace {
261+ const created = parsePrincipalKey(space.row.created_by) ?? { kind: "user" as const, id: space.row.created_by };
262+ return {
263+ id: space.row.id,
264+ workspace_id: space.row.workspace_id,
265+ slug: space.row.slug,
266+ name: space.row.name,
267+ description: space.row.description,
268+ icon: space.row.icon,
269+ kind: space.row.kind,
270+ team: space.row.team,
271+ default_role: space.row.kind === "private" ? null : space.row.default_role,
272+ agent_mode: space.row.agent_mode,
273+ is_default: !!space.row.is_default,
274+ projects: space.projects,
275+ created_by: created,
276+ created_at: space.row.created_at,
277+ archived_at: space.row.archived_at,
278+ viewer_role: space.role ?? "view",
279+ page_count: pageCount,
280+ };
281+ }
282+}
+26−4
3434 // npx wrangler vectorize create-metadata-index g1t-docs --property-name=workspace_id --type=string
3535 // npx wrangler vectorize create-metadata-index g1t-docs --property-name=space_id --type=string
3636 // Without them Docs still keeps passages in D1 and recall matches words.
37+ //
38+ // Folios (Artifacts mode, docs/ARTIFACTS_MODE.md section 5) have an
39+ // index of their own, filtered by workspace, scope and kind. Create it
40+ // once, before the first deploy that has this binding:
41+ // npx wrangler vectorize create g1t-folios --dimensions=768 --metric=cosine
42+ // npx wrangler vectorize create-metadata-index g1t-folios --property-name=workspace_id --type=string
43+ // npx wrangler vectorize create-metadata-index g1t-folios --property-name=scope --type=string
44+ // npx wrangler vectorize create-metadata-index g1t-folios --property-name=kind --type=string
45+ // Without FOLIO_VECTORS, artifacts are searched and recalled by words.
3746 "ai": { "binding": "AI" },
38− "vectorize": [{ "binding": "VECTORS", "index_name": "g1t-docs" }],
47+ "vectorize": [
48+ { "binding": "VECTORS", "index_name": "g1t-docs" },
49+ { "binding": "FOLIO_VECTORS", "index_name": "g1t-folios" }
50+ ],
3951 "services": [
4052 // Workspaces by slug, people's names and avatars, and teams.
4153 { "binding": "IDENTITY", "service": "g1t-identity" },
6375 "consumers": [{ "queue": "g1t-events-docs", "max_batch_size": 20, "max_batch_timeout": 2, "max_retries": 3, "dead_letter_queue": "g1t-events-dlq" }]
6476 },
6577 // One room per page: it holds the page's Yjs document and open sockets,
66− // hibernating while nobody types (src/room.ts).
78+ // hibernating while nobody types (src/room.ts). And one per folio
79+ // (Artifacts mode, src/folios/room.ts): PageRoom stays until Phase 7
80+ // deletes it (`deleted_classes` in a v3 migration).
6781 "durable_objects": {
68− "bindings": [{ "name": "PAGES", "class_name": "PageRoom" }]
82+ "bindings": [
83+ { "name": "PAGES", "class_name": "PageRoom" },
84+ { "name": "FOLIOS", "class_name": "FolioRoom" }
85+ ]
6986 },
70− "migrations": [{ "tag": "v1", "new_sqlite_classes": ["PageRoom"] }],
87+ "migrations": [
88+ { "tag": "v1", "new_sqlite_classes": ["PageRoom"] },
89+ { "tag": "v2", "new_sqlite_classes": ["FolioRoom"] }
90+ ],
91+ // Daily: folios in the trash for over 30 days are deleted for good.
92+ "triggers": { "crons": ["17 3 * * *"] },
7193 "observability": { "enabled": true, "head_sampling_rate": 0.1 }
7294 }