Skip to content
275 linesCodeBlameRaw
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}