studio.ts1181 lines · main
| 1 | import { Hono } from 'hono'; |
| 2 | |
| 3 | import { projectRateLimit } from '../middleware/rate-limit.js'; |
| 4 | import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js'; |
| 5 | import { requireServiceProduct } from '../middleware/service-product.js'; |
| 6 | import { audit, hashIp } from '../services/audit.js'; |
| 7 | import { exportProjectSchema } from '../services/schema-export.js'; |
| 8 | import { |
| 9 | addColumn, |
| 10 | alterColumn, |
| 11 | createIndex, |
| 12 | createTable, |
| 13 | deleteRow, |
| 14 | dropColumn, |
| 15 | dropIndex, |
| 16 | dropTable, |
| 17 | executeQuery, |
| 18 | exportSchemaAsDsl, |
| 19 | getFullSchema, |
| 20 | getTableColumns, |
| 21 | getTableRows, |
| 22 | insertRow, |
| 23 | insertRows, |
| 24 | listIndexes, |
| 25 | listProjectTables, |
| 26 | listRelationships, |
| 27 | renameColumn, |
| 28 | renameTable, |
| 29 | truncateTable, |
| 30 | STUDIO_COLUMN_TYPES, |
| 31 | FILTER_OPS, |
| 32 | updateCell, |
| 33 | updateRow, |
| 34 | type FilterOp, |
| 35 | type PrimaryKeyValue, |
| 36 | type StudioColumnReference, |
| 37 | type StudioColumnSpec, |
| 38 | type StudioColumnType, |
| 39 | } from '../services/studio.js'; |
| 40 | import { seedTemplate } from '../services/templates.js'; |
| 41 | import { |
| 42 | createSnapshot, |
| 43 | deleteSnapshot, |
| 44 | diffSnapshot, |
| 45 | listSnapshots, |
| 46 | restoreSnapshot, |
| 47 | SNAP_ID_RE, |
| 48 | } from '../services/snapshots.js'; |
| 49 | import { |
| 50 | getAutoSnapshotSettings, |
| 51 | upsertAutoSnapshotSettings, |
| 52 | } from '../services/auto-snapshots.js'; |
| 53 | import { autoSnapshotFrequency, type AutoSnapshotFrequency } from '../db/schema.js'; |
| 54 | import { applyPlan, planDatabase } from '../services/assistant.js'; |
| 55 | import { assistantConfigured } from '../services/ollama.js'; |
| 56 | import { assertWithinStorageLimit } from '../services/storage-admin.js'; |
| 57 | import { ValidationError } from '@briven/shared'; |
| 58 | |
| 59 | /** |
| 60 | * Shape-validate the `primaryKey` array a client sent. Returns the typed |
| 61 | * array on success, null on any malformed input — the route then returns |
| 62 | * a 400 with a clear example. The service layer additionally checks that |
| 63 | * the column SET matches the table's actual PK; that's not done here so |
| 64 | * the route doesn't need a db roundtrip to reject obvious garbage. |
| 65 | */ |
| 66 | function parsePrimaryKey(raw: unknown): ReadonlyArray<PrimaryKeyValue> | null { |
| 67 | if (!Array.isArray(raw) || raw.length === 0) return null; |
| 68 | const out: PrimaryKeyValue[] = []; |
| 69 | for (const entry of raw) { |
| 70 | if (!entry || typeof entry !== 'object') return null; |
| 71 | const e = entry as { column?: unknown; value?: unknown }; |
| 72 | if (typeof e.column !== 'string') return null; |
| 73 | if (typeof e.value !== 'string' && typeof e.value !== 'number') return null; |
| 74 | out.push({ column: e.column, value: e.value }); |
| 75 | } |
| 76 | return out; |
| 77 | } |
| 78 | |
| 79 | const FK_ON_DELETE = ['cascade', 'restrict', 'setNull', 'noAction'] as const; |
| 80 | import type { ProjectAppEnv as AppEnv } from '../types/app-env.js'; |
| 81 | |
| 82 | /** |
| 83 | * Studio routes — read-mode only. Admin-tier (developer is not enough): |
| 84 | * the data view surfaces full row contents which could include customer |
| 85 | * secrets or PII. |
| 86 | */ |
| 87 | export const studioRouter = new Hono<AppEnv>(); |
| 88 | |
| 89 | // Studio is the Doltgres wall — service badges must be product=db. |
| 90 | studioRouter.use( |
| 91 | '/v1/projects/:id/studio/*', |
| 92 | requireProjectAuth(), |
| 93 | requireServiceProduct('db'), |
| 94 | ); |
| 95 | |
| 96 | studioRouter.get( |
| 97 | '/v1/projects/:id/studio/tables', |
| 98 | projectRateLimit('read'), |
| 99 | requireProjectRole('admin'), |
| 100 | async (c) => { |
| 101 | const tables = await listProjectTables(c.req.param('id')); |
| 102 | return c.json({ tables }); |
| 103 | }, |
| 104 | ); |
| 105 | |
| 106 | /** |
| 107 | * AI assistant — "describe it and Briven builds it". Powered by flndrn's |
| 108 | * self-hosted Ollama. Two steps so the user is never surprised: |
| 109 | * POST .../assistant/plan → JSON build plan (writes nothing) |
| 110 | * POST .../assistant/apply → runs the reviewed plan through createTable/insertRow |
| 111 | * Admin-tier only (it creates tables + data). |
| 112 | */ |
| 113 | studioRouter.post( |
| 114 | '/v1/projects/:id/studio/assistant/plan', |
| 115 | projectRateLimit('mutate'), |
| 116 | requireProjectRole('admin'), |
| 117 | async (c) => { |
| 118 | if (!assistantConfigured()) { |
| 119 | return c.json({ code: 'assistant_unconfigured', message: 'the assistant is resting — try again soon' }, 503); |
| 120 | } |
| 121 | const body = (await c.req.json().catch(() => null)) as { prompt?: string } | null; |
| 122 | if (!body || typeof body.prompt !== 'string' || body.prompt.trim() === '') { |
| 123 | return c.json({ code: 'validation_failed', message: 'expected { prompt: string }' }, 400); |
| 124 | } |
| 125 | const plan = await planDatabase(c.req.param('id'), body.prompt.slice(0, 2000)); |
| 126 | return c.json({ plan }); |
| 127 | }, |
| 128 | ); |
| 129 | |
| 130 | studioRouter.post( |
| 131 | '/v1/projects/:id/studio/assistant/apply', |
| 132 | projectRateLimit('mutate'), |
| 133 | requireProjectRole('admin'), |
| 134 | async (c) => { |
| 135 | const projectId = c.req.param('id'); |
| 136 | const body = (await c.req.json().catch(() => null)) as { plan?: unknown } | null; |
| 137 | if (!body || !body.plan) { |
| 138 | return c.json({ code: 'validation_failed', message: 'expected { plan }' }, 400); |
| 139 | } |
| 140 | const result = await applyPlan(projectId, body.plan); |
| 141 | const user = c.get('user'); |
| 142 | await audit({ |
| 143 | actorId: user?.id ?? null, |
| 144 | projectId, |
| 145 | action: 'studio.assistant.apply', |
| 146 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 147 | userAgent: c.req.header('user-agent') ?? null, |
| 148 | metadata: { created: result.created.map((x) => x.table), skipped: result.skipped }, |
| 149 | }); |
| 150 | return c.json(result); |
| 151 | }, |
| 152 | ); |
| 153 | |
| 154 | /** |
| 155 | * Run arbitrary SQL against the project's schema, scoped via SET LOCAL ROLE |
| 156 | * to the project-owner role so cross-schema access is impossible. Audit- |
| 157 | * logged so an admin can later replay what was run. Sql text is truncated |
| 158 | * to 1KB in the audit to keep the table size bounded. |
| 159 | */ |
| 160 | studioRouter.post( |
| 161 | '/v1/projects/:id/studio/query', |
| 162 | projectRateLimit('mutate'), |
| 163 | requireProjectRole('admin'), |
| 164 | async (c) => { |
| 165 | const projectId = c.req.param('id'); |
| 166 | const body = (await c.req.json().catch(() => null)) as { sql?: string } | null; |
| 167 | if (!body || typeof body.sql !== 'string' || body.sql.trim() === '') { |
| 168 | return c.json({ code: 'validation_failed', message: 'expected { sql: string }' }, 400); |
| 169 | } |
| 170 | try { |
| 171 | const result = await executeQuery(projectId, body.sql); |
| 172 | const user = c.get('user'); |
| 173 | await audit({ |
| 174 | actorId: user?.id ?? null, |
| 175 | projectId, |
| 176 | action: 'studio.query.run', |
| 177 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 178 | userAgent: c.req.header('user-agent') ?? null, |
| 179 | metadata: { |
| 180 | sqlPreview: body.sql.slice(0, 1024), |
| 181 | command: result.command, |
| 182 | rowCount: result.rowCount, |
| 183 | elapsedMs: result.elapsedMs, |
| 184 | }, |
| 185 | }); |
| 186 | return c.json(result); |
| 187 | } catch (err) { |
| 188 | const message = err instanceof Error ? err.message : 'query failed'; |
| 189 | return c.json({ code: 'query_failed', message }, 400); |
| 190 | } |
| 191 | }, |
| 192 | ); |
| 193 | |
| 194 | /** |
| 195 | * Full one-shot schema: every table with its columns + every FK edge. |
| 196 | * Drives the studio schema overview page. |
| 197 | */ |
| 198 | studioRouter.get( |
| 199 | '/v1/projects/:id/studio/schema', |
| 200 | projectRateLimit('read'), |
| 201 | requireProjectRole('admin'), |
| 202 | async (c) => { |
| 203 | const projectId = c.req.param('id'); |
| 204 | const schema = await getFullSchema(projectId); |
| 205 | return c.json(schema); |
| 206 | }, |
| 207 | ); |
| 208 | |
| 209 | /** |
| 210 | * Every FK edge in the schema. Drives the relationships panel on the |
| 211 | * studio overview. |
| 212 | */ |
| 213 | studioRouter.get( |
| 214 | '/v1/projects/:id/studio/relationships', |
| 215 | projectRateLimit('read'), |
| 216 | requireProjectRole('admin'), |
| 217 | async (c) => { |
| 218 | const projectId = c.req.param('id'); |
| 219 | const edges = await listRelationships(projectId); |
| 220 | return c.json({ edges }); |
| 221 | }, |
| 222 | ); |
| 223 | |
| 224 | /** |
| 225 | * JSON-wrapped schema export — returns `{ schemaTs }`. Used by the CLI |
| 226 | * (`briven pull`) to materialise a local `briven/schema.ts` from the |
| 227 | * server's current schema. Distinct from the text/plain `schema.ts` |
| 228 | * endpoint below, which the browser studio uses for direct download. |
| 229 | */ |
| 230 | studioRouter.get( |
| 231 | '/v1/projects/:id/studio/schema-export', |
| 232 | projectRateLimit('read'), |
| 233 | requireProjectRole('admin'), |
| 234 | async (c) => { |
| 235 | const projectId = c.req.param('id'); |
| 236 | const schemaTs = await exportProjectSchema(projectId); |
| 237 | return c.json({ schemaTs }); |
| 238 | }, |
| 239 | ); |
| 240 | |
| 241 | /** |
| 242 | * Generate an equivalent `briven/schema.ts` for the project — for users |
| 243 | * who started in studio and want to graduate to git-tracked CLI deploys. |
| 244 | */ |
| 245 | studioRouter.get( |
| 246 | '/v1/projects/:id/studio/schema.ts', |
| 247 | projectRateLimit('read'), |
| 248 | requireProjectRole('admin'), |
| 249 | async (c) => { |
| 250 | const projectId = c.req.param('id'); |
| 251 | const body = await exportSchemaAsDsl(projectId); |
| 252 | return new Response(body, { |
| 253 | status: 200, |
| 254 | headers: { 'content-type': 'text/plain; charset=utf-8' }, |
| 255 | }); |
| 256 | }, |
| 257 | ); |
| 258 | |
| 259 | studioRouter.get( |
| 260 | '/v1/projects/:id/studio/tables/:table/columns', |
| 261 | projectRateLimit('read'), |
| 262 | requireProjectRole('admin'), |
| 263 | async (c) => { |
| 264 | const projectId = c.req.param('id'); |
| 265 | const tableName = c.req.param('table'); |
| 266 | if (!projectId || !tableName) { |
| 267 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 268 | } |
| 269 | const columns = await getTableColumns(projectId, tableName); |
| 270 | return c.json({ columns }); |
| 271 | }, |
| 272 | ); |
| 273 | |
| 274 | studioRouter.get( |
| 275 | '/v1/projects/:id/studio/tables/:table/rows', |
| 276 | projectRateLimit('read'), |
| 277 | requireProjectRole('admin'), |
| 278 | async (c) => { |
| 279 | const projectId = c.req.param('id'); |
| 280 | const tableName = c.req.param('table'); |
| 281 | if (!projectId || !tableName) { |
| 282 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 283 | } |
| 284 | const limit = Number(c.req.query('limit') ?? '50'); |
| 285 | const offset = Number(c.req.query('offset') ?? '0'); |
| 286 | // orderBy: `?orderBy=column&dir=asc|desc`. Both optional; dir defaults |
| 287 | // to asc. Validated against the actual column set inside the service. |
| 288 | const orderByCol = c.req.query('orderBy'); |
| 289 | const orderByDir = c.req.query('dir') === 'desc' ? 'desc' : 'asc'; |
| 290 | // Query params: |
| 291 | // ?limit / ?offset / ?cursor → pagination (reserved; never a filter) |
| 292 | // ?orderBy / ?dir → sort (reserved; never a filter) |
| 293 | // ?<col>__<op>=value → operator filter (op ∈ FILTER_OPS: eq, |
| 294 | // contains, gt, lt, gte, lte) |
| 295 | // ?<col>=value → shorthand equality filter (same as |
| 296 | // ?<col>__eq=value); any real column. |
| 297 | // Multiple filter params are AND-ed together. Every filter column is |
| 298 | // validated against the table's real columns inside the service (via |
| 299 | // getTableColumns + COLUMN_NAME_RE); unknown columns raise a 400. Values |
| 300 | // are parameter-bound in buildFilterClauses — never interpolated. |
| 301 | const RESERVED_PARAMS = new Set(['limit', 'offset', 'cursor', 'orderBy', 'dir']); |
| 302 | const filters: Array<{ column: string; op: FilterOp; value: string }> = []; |
| 303 | for (const [k, v] of Object.entries(c.req.queries())) { |
| 304 | if (RESERVED_PARAMS.has(k)) continue; |
| 305 | if (!Array.isArray(v) || v[0] === undefined) continue; |
| 306 | const sepAt = k.lastIndexOf('__'); |
| 307 | if (sepAt > 0) { |
| 308 | // Operator form: `?col__op=value`. Unknown ops are dropped here; the |
| 309 | // service validates the column and raises 400 on unknown columns. |
| 310 | const col = k.slice(0, sepAt); |
| 311 | const op = k.slice(sepAt + 2); |
| 312 | if (!(FILTER_OPS as readonly string[]).includes(op)) continue; |
| 313 | filters.push({ column: col, op: op as FilterOp, value: v[0] }); |
| 314 | } else { |
| 315 | // Shorthand equality form: `?col=value` → `col = value`. |
| 316 | filters.push({ column: k, op: 'eq', value: v[0] }); |
| 317 | } |
| 318 | } |
| 319 | const result = await getTableRows(projectId, tableName, { |
| 320 | limit: Number.isFinite(limit) ? limit : undefined, |
| 321 | offset: Number.isFinite(offset) ? offset : undefined, |
| 322 | orderBy: orderByCol ? { column: orderByCol, direction: orderByDir } : null, |
| 323 | filters: filters.length > 0 ? filters : undefined, |
| 324 | }); |
| 325 | return c.json(result); |
| 326 | }, |
| 327 | ); |
| 328 | |
| 329 | /** |
| 330 | * Inline cell update — write mode for studio. Admin-tier; tier-aware |
| 331 | * mutate rate limit; every successful write lands an audit-log row |
| 332 | * recording (table, column, primary-key column, affected count) but |
| 333 | * never the value itself, per CLAUDE.md §5.1. |
| 334 | */ |
| 335 | studioRouter.patch( |
| 336 | '/v1/projects/:id/studio/tables/:table/rows', |
| 337 | projectRateLimit('mutate'), |
| 338 | requireProjectRole('admin'), |
| 339 | async (c) => { |
| 340 | const projectId = c.req.param('id'); |
| 341 | const tableName = c.req.param('table'); |
| 342 | if (!projectId || !tableName) { |
| 343 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 344 | } |
| 345 | // Two body shapes (branch on which is present): |
| 346 | // Single column (backward compatible): |
| 347 | // { primaryKey: [{column, value}, ...], column, value } |
| 348 | // Multi column (one UPDATE SETs all columns): |
| 349 | // { primaryKey: [{column, value}, ...], values: { col1: v1, col2: v2, ... } } |
| 350 | const body = (await c.req.json().catch(() => null)) as { |
| 351 | primaryKey?: Array<{ column?: unknown; value?: unknown }>; |
| 352 | column?: string; |
| 353 | value?: unknown; |
| 354 | values?: Record<string, unknown>; |
| 355 | } | null; |
| 356 | const primaryKey = parsePrimaryKey(body?.primaryKey); |
| 357 | if (!body || !primaryKey) { |
| 358 | return c.json( |
| 359 | { |
| 360 | code: 'validation_failed', |
| 361 | message: |
| 362 | 'expected { primaryKey: [{column, value}, ...], column, value } (single column) or { primaryKey: [{column, value}, ...], values: { col: value, ... } } (multi column) — primaryKey must be a non-empty array of {column: string, value: string | number}', |
| 363 | }, |
| 364 | 400, |
| 365 | ); |
| 366 | } |
| 367 | |
| 368 | // Multi-column path: `values` is a plain column→value object → one UPDATE |
| 369 | // that SETs every column. (Arrays are rejected so `values` is a map, not a |
| 370 | // row list.) |
| 371 | if (body.values && typeof body.values === 'object' && !Array.isArray(body.values)) { |
| 372 | const columns = Object.keys(body.values); |
| 373 | if (columns.length === 0) { |
| 374 | return c.json( |
| 375 | { code: 'validation_failed', message: 'values must contain at least one column' }, |
| 376 | 400, |
| 377 | ); |
| 378 | } |
| 379 | const result = await updateRow({ |
| 380 | projectId, |
| 381 | tableName, |
| 382 | primaryKey, |
| 383 | values: body.values, |
| 384 | }); |
| 385 | const user = c.get('user'); |
| 386 | await audit({ |
| 387 | actorId: user?.id ?? null, |
| 388 | projectId, |
| 389 | action: 'studio.cell.update', |
| 390 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 391 | userAgent: c.req.header('user-agent') ?? null, |
| 392 | metadata: { |
| 393 | table: tableName, |
| 394 | // Per CLAUDE.md §5.1 — record the column names touched, never values. |
| 395 | columns, |
| 396 | primaryKeyColumns: primaryKey.map((p) => p.column), |
| 397 | affected: result.affected, |
| 398 | }, |
| 399 | }); |
| 400 | return c.json(result); |
| 401 | } |
| 402 | |
| 403 | // Single-column path (unchanged): `{ column, value }`. |
| 404 | if (typeof body.column !== 'string') { |
| 405 | return c.json( |
| 406 | { |
| 407 | code: 'validation_failed', |
| 408 | message: |
| 409 | 'expected { primaryKey: [{column, value}, ...], column, value } (single column) or { primaryKey: [{column, value}, ...], values: { col: value, ... } } (multi column) — primaryKey must be a non-empty array of {column: string, value: string | number}', |
| 410 | }, |
| 411 | 400, |
| 412 | ); |
| 413 | } |
| 414 | const result = await updateCell({ |
| 415 | projectId, |
| 416 | tableName, |
| 417 | primaryKey, |
| 418 | column: body.column, |
| 419 | value: body.value, |
| 420 | }); |
| 421 | const user = c.get('user'); |
| 422 | await audit({ |
| 423 | actorId: user?.id ?? null, |
| 424 | projectId, |
| 425 | action: 'studio.cell.update', |
| 426 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 427 | userAgent: c.req.header('user-agent') ?? null, |
| 428 | metadata: { |
| 429 | table: tableName, |
| 430 | column: body.column, |
| 431 | primaryKeyColumns: primaryKey.map((p) => p.column), |
| 432 | affected: result.affected, |
| 433 | }, |
| 434 | }); |
| 435 | return c.json(result); |
| 436 | }, |
| 437 | ); |
| 438 | |
| 439 | /** |
| 440 | * Insert one OR many rows. Returns the inserted row(s) including any DB-side |
| 441 | * defaults (server-generated ulids, timestamps, etc.). |
| 442 | * |
| 443 | * - Single row (backward compatible): `{ values: { col: value, ... } }` → |
| 444 | * returns the one inserted row (201). |
| 445 | * - Bulk (one request, one rate-limit count): `{ values: [ {..}, {..}, ... ] }` |
| 446 | * → returns `{ inserted, rows }` (201). Sidesteps the per-minute mutate cap |
| 447 | * so a whole table can be migrated in a handful of requests. |
| 448 | */ |
| 449 | studioRouter.post( |
| 450 | '/v1/projects/:id/studio/tables/:table/rows', |
| 451 | projectRateLimit('mutate'), |
| 452 | requireProjectRole('admin'), |
| 453 | async (c) => { |
| 454 | const projectId = c.req.param('id'); |
| 455 | const tableName = c.req.param('table'); |
| 456 | if (!projectId || !tableName) { |
| 457 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 458 | } |
| 459 | const body = (await c.req.json().catch(() => null)) as { |
| 460 | values?: Record<string, unknown> | Array<Record<string, unknown>>; |
| 461 | } | null; |
| 462 | const user = c.get('user'); |
| 463 | |
| 464 | // Bulk path: `values` is an array of row objects → one multi-row INSERT. |
| 465 | if (body && Array.isArray(body.values)) { |
| 466 | // Storage enforcement: block-mode projects that are (or would go) over |
| 467 | // their row cap are refused; flag-mode is a no-op. Batch size is passed |
| 468 | // so the whole insert is weighed at once. Fails open on any lookup miss. |
| 469 | try { |
| 470 | await assertWithinStorageLimit(projectId, 'row', body.values.length); |
| 471 | } catch (err) { |
| 472 | if (err instanceof ValidationError) { |
| 473 | return c.json({ code: 'storage_limit_reached', message: err.message }, 413); |
| 474 | } |
| 475 | throw err; |
| 476 | } |
| 477 | const { inserted, rows } = await insertRows({ |
| 478 | projectId, |
| 479 | tableName, |
| 480 | rows: body.values, |
| 481 | }); |
| 482 | await audit({ |
| 483 | actorId: user?.id ?? null, |
| 484 | projectId, |
| 485 | action: 'studio.row.insert', |
| 486 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 487 | userAgent: c.req.header('user-agent') ?? null, |
| 488 | metadata: { |
| 489 | table: tableName, |
| 490 | rowsInserted: inserted, |
| 491 | bulk: true, |
| 492 | }, |
| 493 | }); |
| 494 | return c.json({ inserted, rows }, 201); |
| 495 | } |
| 496 | |
| 497 | // Single-row path (unchanged): `values` is a plain column→value object. |
| 498 | // (Arrays already took the bulk branch above; this guard also narrows the |
| 499 | // union so `body.values` is a plain object below.) |
| 500 | if (!body || !body.values || typeof body.values !== 'object' || Array.isArray(body.values)) { |
| 501 | return c.json( |
| 502 | { |
| 503 | code: 'validation_failed', |
| 504 | message: |
| 505 | 'expected { values: { col: value, ... } } or { values: [ { col: value, ... }, ... ] }', |
| 506 | }, |
| 507 | 400, |
| 508 | ); |
| 509 | } |
| 510 | // Storage enforcement (block-mode only; flag-mode no-op, fails open). |
| 511 | try { |
| 512 | await assertWithinStorageLimit(projectId, 'row'); |
| 513 | } catch (err) { |
| 514 | if (err instanceof ValidationError) { |
| 515 | return c.json({ code: 'storage_limit_reached', message: err.message }, 413); |
| 516 | } |
| 517 | throw err; |
| 518 | } |
| 519 | const result = await insertRow({ projectId, tableName, values: body.values }); |
| 520 | await audit({ |
| 521 | actorId: user?.id ?? null, |
| 522 | projectId, |
| 523 | action: 'studio.row.insert', |
| 524 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 525 | userAgent: c.req.header('user-agent') ?? null, |
| 526 | metadata: { |
| 527 | table: tableName, |
| 528 | // Per CLAUDE.md §5.1 — record the column names that were |
| 529 | // populated, not the values themselves. |
| 530 | columns: Object.keys(body.values), |
| 531 | }, |
| 532 | }); |
| 533 | return c.json(result, 201); |
| 534 | }, |
| 535 | ); |
| 536 | |
| 537 | /** |
| 538 | * Delete a row by primary key. Body: `{ primaryKeyColumn, primaryKeyValue }`. |
| 539 | */ |
| 540 | /** |
| 541 | * Create a new table. Body: `{ tableName, columns: [{ name, type, notNull?, |
| 542 | * primaryKey?, defaultExpr? }] }`. Service-side validates the type |
| 543 | * whitelist + identifier shape + at-least-one-pk rule. |
| 544 | */ |
| 545 | function parseColumnSpec(input: unknown): StudioColumnSpec | null { |
| 546 | if (!input || typeof input !== 'object') return null; |
| 547 | const c = input as Record<string, unknown>; |
| 548 | if (typeof c.name !== 'string') return null; |
| 549 | if (typeof c.type !== 'string') return null; |
| 550 | if (!(STUDIO_COLUMN_TYPES as readonly string[]).includes(c.type)) return null; |
| 551 | |
| 552 | let references: StudioColumnReference | null | undefined; |
| 553 | if (c.references === null) { |
| 554 | references = null; |
| 555 | } else if (c.references && typeof c.references === 'object') { |
| 556 | const r = c.references as Record<string, unknown>; |
| 557 | if (typeof r.table !== 'string' || typeof r.column !== 'string') return null; |
| 558 | const onDelete = |
| 559 | typeof r.onDelete === 'string' && (FK_ON_DELETE as readonly string[]).includes(r.onDelete) |
| 560 | ? (r.onDelete as StudioColumnReference['onDelete']) |
| 561 | : 'noAction'; |
| 562 | references = { table: r.table, column: r.column, onDelete }; |
| 563 | } |
| 564 | |
| 565 | return { |
| 566 | name: c.name, |
| 567 | type: c.type as StudioColumnType, |
| 568 | notNull: typeof c.notNull === 'boolean' ? c.notNull : undefined, |
| 569 | primaryKey: typeof c.primaryKey === 'boolean' ? c.primaryKey : undefined, |
| 570 | defaultExpr: |
| 571 | typeof c.defaultExpr === 'string' || c.defaultExpr === null |
| 572 | ? (c.defaultExpr as string | null) |
| 573 | : undefined, |
| 574 | references, |
| 575 | }; |
| 576 | } |
| 577 | |
| 578 | studioRouter.post( |
| 579 | '/v1/projects/:id/studio/tables', |
| 580 | projectRateLimit('mutate'), |
| 581 | requireProjectRole('admin'), |
| 582 | async (c) => { |
| 583 | const projectId = c.req.param('id'); |
| 584 | const body = (await c.req.json().catch(() => null)) as { |
| 585 | tableName?: string; |
| 586 | columns?: unknown[]; |
| 587 | } | null; |
| 588 | if (!body || typeof body.tableName !== 'string' || !Array.isArray(body.columns)) { |
| 589 | return c.json( |
| 590 | { code: 'validation_failed', message: 'expected { tableName, columns: [...] }' }, |
| 591 | 400, |
| 592 | ); |
| 593 | } |
| 594 | const cols: StudioColumnSpec[] = []; |
| 595 | for (const raw of body.columns) { |
| 596 | const spec = parseColumnSpec(raw); |
| 597 | if (!spec) { |
| 598 | return c.json( |
| 599 | { code: 'validation_failed', message: 'each column needs { name, type } at minimum' }, |
| 600 | 400, |
| 601 | ); |
| 602 | } |
| 603 | cols.push(spec); |
| 604 | } |
| 605 | // Storage enforcement (block-mode only; flag-mode no-op, fails open). |
| 606 | try { |
| 607 | await assertWithinStorageLimit(projectId, 'table'); |
| 608 | } catch (err) { |
| 609 | if (err instanceof ValidationError) { |
| 610 | return c.json({ code: 'storage_limit_reached', message: err.message }, 413); |
| 611 | } |
| 612 | throw err; |
| 613 | } |
| 614 | const result = await createTable({ projectId, tableName: body.tableName, columns: cols }); |
| 615 | const user = c.get('user'); |
| 616 | await audit({ |
| 617 | actorId: user?.id ?? null, |
| 618 | projectId, |
| 619 | action: 'studio.table.create', |
| 620 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 621 | userAgent: c.req.header('user-agent') ?? null, |
| 622 | metadata: { table: result.name, columnCount: cols.length }, |
| 623 | }); |
| 624 | return c.json(result, 201); |
| 625 | }, |
| 626 | ); |
| 627 | |
| 628 | /** |
| 629 | * Apply a starter template to a (freshly created, empty) project: creates the |
| 630 | * template's tables in FK order and seeds sample rows, so a non-coder lands on |
| 631 | * a working database instead of a blank screen. Body: `{ templateId }`. |
| 632 | */ |
| 633 | studioRouter.post( |
| 634 | '/v1/projects/:id/studio/apply-template', |
| 635 | projectRateLimit('mutate'), |
| 636 | requireProjectRole('admin'), |
| 637 | async (c) => { |
| 638 | const projectId = c.req.param('id'); |
| 639 | const body = (await c.req.json().catch(() => null)) as { templateId?: string } | null; |
| 640 | if (!body || typeof body.templateId !== 'string') { |
| 641 | return c.json({ code: 'validation_failed', message: 'expected { templateId }' }, 400); |
| 642 | } |
| 643 | const result = await seedTemplate(projectId, body.templateId); |
| 644 | const user = c.get('user'); |
| 645 | await audit({ |
| 646 | actorId: user?.id ?? null, |
| 647 | projectId, |
| 648 | action: 'studio.template.apply', |
| 649 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 650 | userAgent: c.req.header('user-agent') ?? null, |
| 651 | metadata: { |
| 652 | templateId: result.templateId, |
| 653 | tablesCreated: result.tablesCreated, |
| 654 | rowsInserted: result.rowsInserted, |
| 655 | }, |
| 656 | }); |
| 657 | return c.json(result, 201); |
| 658 | }, |
| 659 | ); |
| 660 | |
| 661 | /** |
| 662 | * Snapshots — the non-coder "undo button" (lite git-for-data on Postgres). |
| 663 | * Save / list / restore / delete point-in-time copies of a project's data. |
| 664 | */ |
| 665 | studioRouter.get( |
| 666 | '/v1/projects/:id/studio/snapshots', |
| 667 | projectRateLimit('read'), |
| 668 | requireProjectRole('admin'), |
| 669 | async (c) => { |
| 670 | const snapshots = await listSnapshots(c.req.param('id')); |
| 671 | return c.json({ snapshots }); |
| 672 | }, |
| 673 | ); |
| 674 | |
| 675 | studioRouter.post( |
| 676 | '/v1/projects/:id/studio/snapshots', |
| 677 | projectRateLimit('mutate'), |
| 678 | requireProjectRole('admin'), |
| 679 | async (c) => { |
| 680 | const projectId = c.req.param('id'); |
| 681 | const body = (await c.req.json().catch(() => null)) as { name?: string } | null; |
| 682 | const result = await createSnapshot(projectId, body?.name ?? 'snapshot'); |
| 683 | const user = c.get('user'); |
| 684 | await audit({ |
| 685 | actorId: user?.id ?? null, |
| 686 | projectId, |
| 687 | action: 'studio.snapshot.create', |
| 688 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 689 | userAgent: c.req.header('user-agent') ?? null, |
| 690 | metadata: { snapshotId: result.id, tableCount: result.tableCount }, |
| 691 | }); |
| 692 | return c.json(result, 201); |
| 693 | }, |
| 694 | ); |
| 695 | |
| 696 | /** |
| 697 | * Snapshot diff — "what changed since this save point". Read-only: compares |
| 698 | * the live schema against the snapshot's copy and reports tables/columns |
| 699 | * added or removed plus per-table row deltas (added/removed/changed, matched |
| 700 | * by primary key, capped per table). Drives the dashboard "compare" view. |
| 701 | */ |
| 702 | studioRouter.get( |
| 703 | '/v1/projects/:id/studio/snapshots/:snapId/diff', |
| 704 | projectRateLimit('read'), |
| 705 | requireProjectRole('admin'), |
| 706 | async (c) => { |
| 707 | const projectId = c.req.param('id'); |
| 708 | const snapId = c.req.param('snapId'); |
| 709 | if (!SNAP_ID_RE.test(snapId)) { |
| 710 | return c.json({ code: 'validation_failed', message: 'invalid snapshot id' }, 400); |
| 711 | } |
| 712 | const diff = await diffSnapshot(projectId, snapId); |
| 713 | const user = c.get('user'); |
| 714 | await audit({ |
| 715 | actorId: user?.id ?? null, |
| 716 | projectId, |
| 717 | action: 'studio.snapshot.diff', |
| 718 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 719 | userAgent: c.req.header('user-agent') ?? null, |
| 720 | metadata: { |
| 721 | snapshotId: snapId, |
| 722 | tablesAdded: diff.tablesAdded.length, |
| 723 | tablesRemoved: diff.tablesRemoved.length, |
| 724 | tablesCompared: diff.tables.length, |
| 725 | }, |
| 726 | }); |
| 727 | return c.json(diff); |
| 728 | }, |
| 729 | ); |
| 730 | |
| 731 | studioRouter.post( |
| 732 | '/v1/projects/:id/studio/snapshots/:snapId/restore', |
| 733 | projectRateLimit('mutate'), |
| 734 | requireProjectRole('admin'), |
| 735 | async (c) => { |
| 736 | const projectId = c.req.param('id'); |
| 737 | const snapId = c.req.param('snapId'); |
| 738 | const result = await restoreSnapshot(projectId, snapId); |
| 739 | const user = c.get('user'); |
| 740 | await audit({ |
| 741 | actorId: user?.id ?? null, |
| 742 | projectId, |
| 743 | action: 'studio.snapshot.restore', |
| 744 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 745 | userAgent: c.req.header('user-agent') ?? null, |
| 746 | metadata: { snapshotId: snapId, restored: result.restored }, |
| 747 | }); |
| 748 | return c.json(result); |
| 749 | }, |
| 750 | ); |
| 751 | |
| 752 | studioRouter.delete( |
| 753 | '/v1/projects/:id/studio/snapshots/:snapId', |
| 754 | projectRateLimit('mutate'), |
| 755 | requireProjectRole('admin'), |
| 756 | async (c) => { |
| 757 | const projectId = c.req.param('id'); |
| 758 | const snapId = c.req.param('snapId'); |
| 759 | await deleteSnapshot(projectId, snapId); |
| 760 | const user = c.get('user'); |
| 761 | await audit({ |
| 762 | actorId: user?.id ?? null, |
| 763 | projectId, |
| 764 | action: 'studio.snapshot.delete', |
| 765 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 766 | userAgent: c.req.header('user-agent') ?? null, |
| 767 | metadata: { snapshotId: snapId }, |
| 768 | }); |
| 769 | return c.json({ ok: true }); |
| 770 | }, |
| 771 | ); |
| 772 | |
| 773 | /** |
| 774 | * Automatic snapshots — read + update the per-project schedule that takes |
| 775 | * save-points for the customer on a cadence (daily / twice-daily) and keeps |
| 776 | * the last N. Admin-tier like the rest of studio. The actual runs happen in |
| 777 | * the auto-snapshot worker; these routes only configure it. |
| 778 | */ |
| 779 | studioRouter.get( |
| 780 | '/v1/projects/:id/studio/auto-snapshots', |
| 781 | projectRateLimit('read'), |
| 782 | requireProjectRole('admin'), |
| 783 | async (c) => { |
| 784 | const settings = await getAutoSnapshotSettings(c.req.param('id')); |
| 785 | return c.json(settings); |
| 786 | }, |
| 787 | ); |
| 788 | |
| 789 | studioRouter.put( |
| 790 | '/v1/projects/:id/studio/auto-snapshots', |
| 791 | projectRateLimit('mutate'), |
| 792 | requireProjectRole('admin'), |
| 793 | async (c) => { |
| 794 | const projectId = c.req.param('id'); |
| 795 | const body = (await c.req.json().catch(() => null)) as { |
| 796 | enabled?: unknown; |
| 797 | frequency?: unknown; |
| 798 | retentionCount?: unknown; |
| 799 | } | null; |
| 800 | if (!body) { |
| 801 | return c.json({ code: 'validation_failed', message: 'request body required' }, 400); |
| 802 | } |
| 803 | const frequency = body.frequency; |
| 804 | if (typeof frequency !== 'string' || !autoSnapshotFrequency.includes(frequency as AutoSnapshotFrequency)) { |
| 805 | return c.json( |
| 806 | { code: 'validation_failed', message: `frequency must be one of: ${autoSnapshotFrequency.join(', ')}` }, |
| 807 | 400, |
| 808 | ); |
| 809 | } |
| 810 | const retentionCount = Number(body.retentionCount); |
| 811 | const user = c.get('user'); |
| 812 | const settings = await upsertAutoSnapshotSettings(projectId, { |
| 813 | enabled: body.enabled === true, |
| 814 | frequency: frequency as AutoSnapshotFrequency, |
| 815 | retentionCount, |
| 816 | updatedBy: user?.id ?? null, |
| 817 | }); |
| 818 | await audit({ |
| 819 | actorId: user?.id ?? null, |
| 820 | projectId, |
| 821 | action: 'studio.snapshot.auto.configure', |
| 822 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 823 | userAgent: c.req.header('user-agent') ?? null, |
| 824 | metadata: { |
| 825 | enabled: settings.enabled, |
| 826 | frequency: settings.frequency, |
| 827 | retentionCount: settings.retentionCount, |
| 828 | }, |
| 829 | }); |
| 830 | return c.json(settings); |
| 831 | }, |
| 832 | ); |
| 833 | |
| 834 | studioRouter.patch( |
| 835 | '/v1/projects/:id/studio/tables/:table', |
| 836 | projectRateLimit('mutate'), |
| 837 | requireProjectRole('admin'), |
| 838 | async (c) => { |
| 839 | const projectId = c.req.param('id'); |
| 840 | const tableName = c.req.param('table'); |
| 841 | if (!projectId || !tableName) { |
| 842 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 843 | } |
| 844 | const body = (await c.req.json().catch(() => null)) as { newName?: string } | null; |
| 845 | if (!body || typeof body.newName !== 'string') { |
| 846 | return c.json({ code: 'validation_failed', message: 'expected { newName: string }' }, 400); |
| 847 | } |
| 848 | await renameTable({ projectId, oldName: tableName, newName: body.newName }); |
| 849 | const user = c.get('user'); |
| 850 | await audit({ |
| 851 | actorId: user?.id ?? null, |
| 852 | projectId, |
| 853 | action: 'studio.table.rename', |
| 854 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 855 | userAgent: c.req.header('user-agent') ?? null, |
| 856 | metadata: { oldName: tableName, newName: body.newName }, |
| 857 | }); |
| 858 | return c.json({ renamed: body.newName }); |
| 859 | }, |
| 860 | ); |
| 861 | |
| 862 | studioRouter.patch( |
| 863 | '/v1/projects/:id/studio/tables/:table/columns/:column', |
| 864 | projectRateLimit('mutate'), |
| 865 | requireProjectRole('admin'), |
| 866 | async (c) => { |
| 867 | const projectId = c.req.param('id'); |
| 868 | const tableName = c.req.param('table'); |
| 869 | const column = c.req.param('column'); |
| 870 | if (!projectId || !tableName || !column) { |
| 871 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 872 | } |
| 873 | const body = (await c.req.json().catch(() => null)) as { |
| 874 | newName?: string; |
| 875 | notNull?: boolean; |
| 876 | defaultExpr?: string | null; |
| 877 | } | null; |
| 878 | if (!body) { |
| 879 | return c.json({ code: 'validation_failed', message: 'body required' }, 400); |
| 880 | } |
| 881 | // Two-mode patch: rename (newName) OR alter (notNull, defaultExpr). |
| 882 | // Mutually exclusive so audit metadata stays clean. |
| 883 | if (typeof body.newName === 'string') { |
| 884 | await renameColumn({ |
| 885 | projectId, |
| 886 | tableName, |
| 887 | oldName: column, |
| 888 | newName: body.newName, |
| 889 | }); |
| 890 | const user = c.get('user'); |
| 891 | await audit({ |
| 892 | actorId: user?.id ?? null, |
| 893 | projectId, |
| 894 | action: 'studio.column.rename', |
| 895 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 896 | userAgent: c.req.header('user-agent') ?? null, |
| 897 | metadata: { table: tableName, oldName: column, newName: body.newName }, |
| 898 | }); |
| 899 | return c.json({ renamed: body.newName }); |
| 900 | } |
| 901 | if (typeof body.notNull === 'boolean' || body.defaultExpr !== undefined) { |
| 902 | await alterColumn({ |
| 903 | projectId, |
| 904 | tableName, |
| 905 | column, |
| 906 | notNull: typeof body.notNull === 'boolean' ? body.notNull : undefined, |
| 907 | defaultExpr: |
| 908 | body.defaultExpr === null |
| 909 | ? null |
| 910 | : typeof body.defaultExpr === 'string' |
| 911 | ? body.defaultExpr |
| 912 | : undefined, |
| 913 | }); |
| 914 | const user = c.get('user'); |
| 915 | await audit({ |
| 916 | actorId: user?.id ?? null, |
| 917 | projectId, |
| 918 | action: 'studio.column.alter', |
| 919 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 920 | userAgent: c.req.header('user-agent') ?? null, |
| 921 | metadata: { |
| 922 | table: tableName, |
| 923 | column, |
| 924 | notNull: typeof body.notNull === 'boolean' ? body.notNull : null, |
| 925 | defaultExpr: |
| 926 | body.defaultExpr === null |
| 927 | ? '(dropped)' |
| 928 | : typeof body.defaultExpr === 'string' |
| 929 | ? body.defaultExpr |
| 930 | : null, |
| 931 | }, |
| 932 | }); |
| 933 | return c.json({ altered: column }); |
| 934 | } |
| 935 | return c.json( |
| 936 | { |
| 937 | code: 'validation_failed', |
| 938 | message: 'expected { newName } or { notNull?, defaultExpr? }', |
| 939 | }, |
| 940 | 400, |
| 941 | ); |
| 942 | }, |
| 943 | ); |
| 944 | |
| 945 | studioRouter.post( |
| 946 | '/v1/projects/:id/studio/tables/:table/truncate', |
| 947 | projectRateLimit('mutate'), |
| 948 | requireProjectRole('admin'), |
| 949 | async (c) => { |
| 950 | const projectId = c.req.param('id'); |
| 951 | const tableName = c.req.param('table'); |
| 952 | if (!projectId || !tableName) { |
| 953 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 954 | } |
| 955 | const body = (await c.req.json().catch(() => null)) as { cascade?: boolean } | null; |
| 956 | await truncateTable(projectId, tableName, Boolean(body?.cascade)); |
| 957 | const user = c.get('user'); |
| 958 | await audit({ |
| 959 | actorId: user?.id ?? null, |
| 960 | projectId, |
| 961 | action: 'studio.table.truncate', |
| 962 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 963 | userAgent: c.req.header('user-agent') ?? null, |
| 964 | metadata: { table: tableName, cascade: Boolean(body?.cascade) }, |
| 965 | }); |
| 966 | return c.json({ truncated: tableName }); |
| 967 | }, |
| 968 | ); |
| 969 | |
| 970 | studioRouter.delete( |
| 971 | '/v1/projects/:id/studio/tables/:table', |
| 972 | projectRateLimit('mutate'), |
| 973 | requireProjectRole('admin'), |
| 974 | async (c) => { |
| 975 | const projectId = c.req.param('id'); |
| 976 | const tableName = c.req.param('table'); |
| 977 | if (!projectId || !tableName) { |
| 978 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 979 | } |
| 980 | await dropTable(projectId, tableName); |
| 981 | const user = c.get('user'); |
| 982 | await audit({ |
| 983 | actorId: user?.id ?? null, |
| 984 | projectId, |
| 985 | action: 'studio.table.drop', |
| 986 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 987 | userAgent: c.req.header('user-agent') ?? null, |
| 988 | metadata: { table: tableName }, |
| 989 | }); |
| 990 | return c.json({ dropped: tableName }); |
| 991 | }, |
| 992 | ); |
| 993 | |
| 994 | studioRouter.post( |
| 995 | '/v1/projects/:id/studio/tables/:table/columns', |
| 996 | projectRateLimit('mutate'), |
| 997 | requireProjectRole('admin'), |
| 998 | async (c) => { |
| 999 | const projectId = c.req.param('id'); |
| 1000 | const tableName = c.req.param('table'); |
| 1001 | if (!projectId || !tableName) { |
| 1002 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 1003 | } |
| 1004 | const body = (await c.req.json().catch(() => null)) as { column?: unknown } | null; |
| 1005 | const spec = body ? parseColumnSpec(body.column) : null; |
| 1006 | if (!spec) { |
| 1007 | return c.json( |
| 1008 | { code: 'validation_failed', message: 'expected { column: { name, type, ... } }' }, |
| 1009 | 400, |
| 1010 | ); |
| 1011 | } |
| 1012 | await addColumn({ projectId, tableName, column: spec }); |
| 1013 | const user = c.get('user'); |
| 1014 | await audit({ |
| 1015 | actorId: user?.id ?? null, |
| 1016 | projectId, |
| 1017 | action: 'studio.column.add', |
| 1018 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 1019 | userAgent: c.req.header('user-agent') ?? null, |
| 1020 | metadata: { table: tableName, column: spec.name, type: spec.type }, |
| 1021 | }); |
| 1022 | return c.json({ added: spec.name }, 201); |
| 1023 | }, |
| 1024 | ); |
| 1025 | |
| 1026 | studioRouter.delete( |
| 1027 | '/v1/projects/:id/studio/tables/:table/columns/:column', |
| 1028 | projectRateLimit('mutate'), |
| 1029 | requireProjectRole('admin'), |
| 1030 | async (c) => { |
| 1031 | const projectId = c.req.param('id'); |
| 1032 | const tableName = c.req.param('table'); |
| 1033 | const column = c.req.param('column'); |
| 1034 | if (!projectId || !tableName || !column) { |
| 1035 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 1036 | } |
| 1037 | await dropColumn({ projectId, tableName, column }); |
| 1038 | const user = c.get('user'); |
| 1039 | await audit({ |
| 1040 | actorId: user?.id ?? null, |
| 1041 | projectId, |
| 1042 | action: 'studio.column.drop', |
| 1043 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 1044 | userAgent: c.req.header('user-agent') ?? null, |
| 1045 | metadata: { table: tableName, column }, |
| 1046 | }); |
| 1047 | return c.json({ dropped: column }); |
| 1048 | }, |
| 1049 | ); |
| 1050 | |
| 1051 | /** |
| 1052 | * List, create, and drop indexes on a table. |
| 1053 | */ |
| 1054 | studioRouter.get( |
| 1055 | '/v1/projects/:id/studio/tables/:table/indexes', |
| 1056 | projectRateLimit('read'), |
| 1057 | requireProjectRole('admin'), |
| 1058 | async (c) => { |
| 1059 | const projectId = c.req.param('id'); |
| 1060 | const tableName = c.req.param('table'); |
| 1061 | if (!projectId || !tableName) { |
| 1062 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 1063 | } |
| 1064 | const indexes = await listIndexes(projectId, tableName); |
| 1065 | return c.json({ indexes }); |
| 1066 | }, |
| 1067 | ); |
| 1068 | |
| 1069 | studioRouter.post( |
| 1070 | '/v1/projects/:id/studio/tables/:table/indexes', |
| 1071 | projectRateLimit('mutate'), |
| 1072 | requireProjectRole('admin'), |
| 1073 | async (c) => { |
| 1074 | const projectId = c.req.param('id'); |
| 1075 | const tableName = c.req.param('table'); |
| 1076 | if (!projectId || !tableName) { |
| 1077 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 1078 | } |
| 1079 | const body = (await c.req.json().catch(() => null)) as { |
| 1080 | columns?: string[]; |
| 1081 | unique?: boolean; |
| 1082 | name?: string | null; |
| 1083 | } | null; |
| 1084 | if (!body || !Array.isArray(body.columns) || body.columns.length === 0) { |
| 1085 | return c.json( |
| 1086 | { code: 'validation_failed', message: 'expected { columns: [...], unique?, name? }' }, |
| 1087 | 400, |
| 1088 | ); |
| 1089 | } |
| 1090 | const cols = body.columns.filter((c) => typeof c === 'string'); |
| 1091 | const result = await createIndex({ |
| 1092 | projectId, |
| 1093 | tableName, |
| 1094 | columns: cols, |
| 1095 | unique: Boolean(body.unique), |
| 1096 | name: typeof body.name === 'string' ? body.name : null, |
| 1097 | }); |
| 1098 | const user = c.get('user'); |
| 1099 | await audit({ |
| 1100 | actorId: user?.id ?? null, |
| 1101 | projectId, |
| 1102 | action: 'studio.index.create', |
| 1103 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 1104 | userAgent: c.req.header('user-agent') ?? null, |
| 1105 | metadata: { |
| 1106 | table: tableName, |
| 1107 | index: result.name, |
| 1108 | columns: cols, |
| 1109 | unique: Boolean(body.unique), |
| 1110 | }, |
| 1111 | }); |
| 1112 | return c.json(result, 201); |
| 1113 | }, |
| 1114 | ); |
| 1115 | |
| 1116 | studioRouter.delete( |
| 1117 | '/v1/projects/:id/studio/tables/:table/indexes/:name', |
| 1118 | projectRateLimit('mutate'), |
| 1119 | requireProjectRole('admin'), |
| 1120 | async (c) => { |
| 1121 | const projectId = c.req.param('id'); |
| 1122 | const tableName = c.req.param('table'); |
| 1123 | const indexName = c.req.param('name'); |
| 1124 | if (!projectId || !tableName || !indexName) { |
| 1125 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 1126 | } |
| 1127 | await dropIndex(projectId, tableName, indexName); |
| 1128 | const user = c.get('user'); |
| 1129 | await audit({ |
| 1130 | actorId: user?.id ?? null, |
| 1131 | projectId, |
| 1132 | action: 'studio.index.drop', |
| 1133 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 1134 | userAgent: c.req.header('user-agent') ?? null, |
| 1135 | metadata: { table: tableName, index: indexName }, |
| 1136 | }); |
| 1137 | return c.json({ dropped: indexName }); |
| 1138 | }, |
| 1139 | ); |
| 1140 | |
| 1141 | studioRouter.delete( |
| 1142 | '/v1/projects/:id/studio/tables/:table/rows', |
| 1143 | projectRateLimit('mutate'), |
| 1144 | requireProjectRole('admin'), |
| 1145 | async (c) => { |
| 1146 | const projectId = c.req.param('id'); |
| 1147 | const tableName = c.req.param('table'); |
| 1148 | if (!projectId || !tableName) { |
| 1149 | return c.json({ code: 'validation_failed', message: 'missing path params' }, 400); |
| 1150 | } |
| 1151 | const body = (await c.req.json().catch(() => null)) as { |
| 1152 | primaryKey?: Array<{ column?: unknown; value?: unknown }>; |
| 1153 | } | null; |
| 1154 | const primaryKey = parsePrimaryKey(body?.primaryKey); |
| 1155 | if (!primaryKey) { |
| 1156 | return c.json( |
| 1157 | { |
| 1158 | code: 'validation_failed', |
| 1159 | message: |
| 1160 | 'expected { primaryKey: [{column, value}, ...] } — primaryKey must be a non-empty array of {column: string, value: string | number}', |
| 1161 | }, |
| 1162 | 400, |
| 1163 | ); |
| 1164 | } |
| 1165 | const result = await deleteRow({ projectId, tableName, primaryKey }); |
| 1166 | const user = c.get('user'); |
| 1167 | await audit({ |
| 1168 | actorId: user?.id ?? null, |
| 1169 | projectId, |
| 1170 | action: 'studio.row.delete', |
| 1171 | ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null), |
| 1172 | userAgent: c.req.header('user-agent') ?? null, |
| 1173 | metadata: { |
| 1174 | table: tableName, |
| 1175 | primaryKeyColumns: primaryKey.map((p) => p.column), |
| 1176 | affected: result.affected, |
| 1177 | }, |
| 1178 | }); |
| 1179 | return c.json(result); |
| 1180 | }, |
| 1181 | ); |