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 artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook. | 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 | } |