Skip to content
275 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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.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 */
8import type { DocRole, FolioGeneralAccess } from "@g1t/contracts";
9
10import { 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). */
13export 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. */
46export 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. */
50export 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. */
57export const json = (values: readonly string[]) => JSON.stringify([...new Set(values)]);
58
59/** How many statements one batch carries. */
60const BATCH = 80;
61
62export 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
66export 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. */
80export 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. */
87export 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. */
101export 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. */
115export type Ancestry = { rows: Map<string, FolioRow>; nodes: Map<string, FolioAclNode>; grants: Map<string, FolioGrant[]> };
116
117export 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. */
126export 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). */
135export 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). */
146export 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 */
164export 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). */
175export 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`). */
190export 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 */
200export 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 */
216export 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). */
254export 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}