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 | */ | |
| 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 | } |