Skip to content

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

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