Skip to content
277 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 /** The saved Yjs state (migration 0006), only when a read asks for it. */
44 state?: ArrayBuffer | number[] | null;
45};
46
47/** Every column but the text, as lists read them. */
48export const FOLIO_COLUMNS =
49 "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";
50
51/** The same, prefixed by a table alias. */
52export function folioColumns(alias: string): string {
53 return FOLIO_COLUMNS.split(", ")
54 .map((c) => (c.startsWith("'") ? c : `${alias}.${c}`))
55 .join(", ");
56}
57
58/** D1 binds at most 100 parameters; lists go in as one JSON parameter instead. */
59export const json = (values: readonly string[]) => JSON.stringify([...new Set(values)]);
60
61/** How many statements one batch carries. */
62const BATCH = 80;
63
64export async function runBatches(db: D1Database, statements: D1PreparedStatement[]): Promise<void> {
65 for (let i = 0; i < statements.length; i += BATCH) await db.batch(statements.slice(i, i + BATCH));
66}
67
68export function aclNode(row: Pick<FolioRow, "id" | "owner" | "parent_id" | "space_id" | "inherit" | "general_access" | "general_role" | "created_at">): FolioAclNode {
69 return {
70 id: row.id,
71 owner: row.owner,
72 parent_id: row.parent_id,
73 space_id: row.space_id,
74 inherit: !!row.inherit,
75 general_access: row.general_access,
76 general_role: row.general_role,
77 created_at: row.created_at,
78 };
79}
80
81/** The ids in a path, top first. */
82export function pathIds(path: string): string[] {
83 return String(path ?? "")
84 .split("/")
85 .filter(Boolean);
86}
87
88/** Folio rows by id (any workspace's; callers check), without their text. */
89export async function foliosById(db: D1Database, ids: readonly string[]): Promise<Map<string, FolioRow>> {
90 const out = new Map<string, FolioRow>();
91 const unique = [...new Set(ids)];
92 for (let i = 0; i < unique.length; i += 500) {
93 const rows = await db
94 .prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE id IN (SELECT value FROM json_each(?))`)
95 .bind(json(unique.slice(i, i + 500)))
96 .all<FolioRow>();
97 for (const r of rows.results) out.set(r.id, r);
98 }
99 return out;
100}
101
102/** Grants on these folios, by folio. */
103export async function grantsOf(db: D1Database, ids: readonly string[]): Promise<Map<string, FolioGrant[]>> {
104 const out = new Map<string, FolioGrant[]>();
105 const unique = [...new Set(ids)];
106 for (let i = 0; i < unique.length; i += 500) {
107 const rows = await db
108 .prepare("SELECT folio_id, principal, role, granted_at FROM folio_grants WHERE folio_id IN (SELECT value FROM json_each(?))")
109 .bind(json(unique.slice(i, i + 500)))
110 .all<{ folio_id: string; principal: string; role: DocRole; granted_at: string }>();
111 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 }]);
112 }
113 return out;
114}
115
116/** Everything access needs for these folios: them and their ancestors as nodes, and every grant on them. */
117export type Ancestry = { rows: Map<string, FolioRow>; nodes: Map<string, FolioAclNode>; grants: Map<string, FolioGrant[]> };
118
119export async function ancestry(db: D1Database, rows: readonly FolioRow[]): Promise<Ancestry> {
120 const all = new Map(rows.map((r) => [r.id, r]));
121 const missing = [...new Set(rows.flatMap((r) => pathIds(r.path)))].filter((id) => !all.has(id));
122 if (missing.length) for (const [id, row] of await foliosById(db, missing)) all.set(id, row);
123 const grants = await grantsOf(db, [...all.keys()]);
124 return { rows: all, nodes: new Map([...all.values()].map((r) => [r.id, aclNode(r)])), grants };
125}
126
127/** What a reader's role depends on beyond the folio: their space roles and the links they opened. */
128export type ReaderContext = {
129 person: Person;
130 /** Their role in each space (null: none). */
131 spaceRole: (spaceId: string) => DocRole | null;
132 /** Folio ids they opened. */
133 visits: ReadonlySet<string>;
134};
135
136/** Of these folios, the ones whose link the person opened (or whose access root's). */
137export async function visitsOf(db: D1Database, userId: string, rows: readonly Pick<FolioRow, "id" | "acl_root">[]): Promise<Set<string>> {
138 const ids = [...new Set(rows.flatMap((r) => [r.id, r.acl_root]))];
139 if (!ids.length) return new Set();
140 const found = await db
141 .prepare("SELECT folio_id FROM folio_visits WHERE user_id = ? AND folio_id IN (SELECT value FROM json_each(?))")
142 .bind(userId, json(ids))
143 .all<{ folio_id: string }>();
144 return new Set(found.results.map((r) => r.folio_id));
145}
146
147/** A reader's role on each folio, from the full chain (defence in depth behind the list SQL). */
148export function rolesFrom(found: Ancestry, rows: readonly FolioRow[], reader: ReaderContext): Map<string, DocRole | null> {
149 const out = new Map<string, DocRole | null>();
150 for (const row of rows) {
151 const chain = aclChain(row.id, found.nodes);
152 const root = chain[chain.length - 1];
153 const visited = reader.visits.has(row.id) || (!!root && reader.visits.has(root.id));
154 out.set(row.id, effectiveRole(chain, found.grants, root?.space_id ? reader.spaceRole(root.space_id) : null, reader.person, { visited }));
155 }
156 return out;
157}
158
159/**
160 * The list filter (plan section 2.3): a folio is readable when one of the
161 * reader's keys has a `folio_access` row on it; or its access root is at
162 * the top of a space they can read and inherits it; or its access root
163 * is open to the workspace; or it is a link folio they opened. `f` is the
164 * folio and `r` its access root (`JOIN folios r ON r.id = f.acl_root`).
165 */
166export function readableWhere(keys: readonly string[], spaceIds: readonly string[], userId: string): { sql: string; binds: unknown[] } {
167 return {
168 sql: `(f.id IN (SELECT folio_id FROM folio_access WHERE principal IN (SELECT value FROM json_each(?)))
169 OR (r.parent_id IS NULL AND r.inherit = 1 AND r.space_id IN (SELECT value FROM json_each(?)))
170 OR r.general_access = 'workspace'
171 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))))`,
172 binds: [json(keys), json(spaceIds), userId],
173 };
174}
175
176/** Whether every member of the workspace can read a folio (events and the inbox carry its title only then). */
177export async function workspaceReadable(db: D1Database, folioId: string): Promise<boolean> {
178 const row = (await foliosById(db, [folioId])).get(folioId);
179 if (!row) return false;
180 const found = await ancestry(db, [row]);
181 const chain = aclChain(row.id, found.nodes);
182 const root = chain[chain.length - 1];
183 let space = null;
184 if (root?.space_id) {
185 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 }>();
186 if (s) space = { ...s, members: [] };
187 }
188 return folioReadableByWorkspace(chain, space);
189}
190
191/** The index scope of each of these folios (src/access.ts `folioScope`). */
192export async function scopesOf(db: D1Database, rows: readonly FolioRow[]): Promise<Map<string, string>> {
193 const found = await ancestry(db, rows);
194 return new Map(rows.map((r) => [r.id, folioScope(aclChain(r.id, found.nodes), found.grants)]));
195}
196
197/**
198 * A subtree: the folio and everything under it, by its path. (A prefix
199 * compare, not LIKE: D1 refuses LIKE patterns over 50 bytes, which a
200 * path two deep already is.)
201 */
202export async function subtree(db: D1Database, root: Pick<FolioRow, "path" | "workspace_id">): Promise<FolioRow[]> {
203 return (
204 await db
205 .prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE workspace_id = ? AND substr(path, 1, ?) = ? ORDER BY length(path)`)
206 .bind(root.workspace_id, root.path.length, root.path)
207 .all<FolioRow>()
208 ).results;
209}
210
211/**
212 * Brings a subtree up to date after a move, a restriction, a grant or an
213 * ownership change: each folio's space (its top's), `acl_root` and `path`
214 * from its parent down, then its `folio_access` rows. `rootId`'s own
215 * parent (and space, when it has a parent) must already be set. Returns
216 * the subtree's ids, top first.
217 */
218export async function rebuildSubtree(db: D1Database, rootId: string): Promise<string[]> {
219 const root = (await foliosById(db, [rootId])).get(rootId);
220 if (!root) return [];
221 const below = await subtree(db, root);
222 const parent = root.parent_id ? ((await foliosById(db, [root.parent_id])).get(root.parent_id) ?? null) : null;
223 // Top down: each child's place follows its parent's new one.
224 const next = new Map<string, { space_id: string | null; acl_root: string; path: string }>();
225 const placeOf = (row: FolioRow, up: { space_id: string | null; acl_root: string; path: string } | null) => ({
226 space_id: up ? up.space_id : row.space_id,
227 acl_root: aclRootOf({ id: row.id, inherit: !!row.inherit, parent_id: row.parent_id }, up?.acl_root ?? null),
228 path: folioPathOf(row.id, up?.path ?? null),
229 });
230 const byParent = new Map<string, FolioRow[]>();
231 for (const r of below) if (r.id !== root.id && r.parent_id) byParent.set(r.parent_id, [...(byParent.get(r.parent_id) ?? []), r]);
232 const queue: FolioRow[] = [root];
233 next.set(root.id, placeOf(root, parent ? { space_id: parent.space_id, acl_root: parent.acl_root, path: parent.path } : null));
234 for (let i = 0; i < queue.length; i++) {
235 const at = queue[i]!;
236 for (const child of byParent.get(at.id) ?? []) {
237 next.set(child.id, placeOf(child, next.get(at.id)!));
238 queue.push(child);
239 }
240 }
241 const ids = queue.map((r) => r.id);
242 const statements: D1PreparedStatement[] = [];
243 for (const row of queue) {
244 const place = next.get(row.id)!;
245 if (place.space_id !== row.space_id || place.acl_root !== row.acl_root || place.path !== row.path) {
246 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));
247 Object.assign(row, place);
248 }
249 }
250 await runBatches(db, statements);
251 await rematerialize(db, queue);
252 return ids;
253}
254
255/** Rewrites `folio_access` for these folios (their places already right). */
256export async function rematerialize(db: D1Database, rows: FolioRow[]): Promise<void> {
257 if (!rows.length) return;
258 const found = await ancestry(db, rows);
259 // The rows given win over what was read: they hold the places just written.
260 for (const r of rows) {
261 found.rows.set(r.id, r);
262 found.nodes.set(r.id, aclNode(r));
263 }
264 const access = materialize(
265 rows.map((r) => r.id),
266 found.nodes,
267 found.grants,
268 );
269 const statements: D1PreparedStatement[] = [];
270 for (let i = 0; i < rows.length; i += 500) {
271 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))));
272 }
273 for (const a of access) {
274 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));
275 }
276 await runBatches(db, statements);
277}