studio.ts1526 lines · main
| 1 | import { brivenError, ValidationError } from '@briven/shared'; |
| 2 | |
| 3 | import { runInProjectDatabase } from '../db/data-plane.js'; |
| 4 | |
| 5 | /** |
| 6 | * Studio read-mode services. Phase 2 first slice — table listing only. |
| 7 | * |
| 8 | * Future slices: |
| 9 | * - rows-per-table (paginated) |
| 10 | * - column metadata (types, nullability, defaults) for a tables-detail view |
| 11 | * - inline edit (write mode) — gated on RLS-style guard rails the runtime |
| 12 | * enforces; never exposed without an admin-tier session |
| 13 | */ |
| 14 | |
| 15 | export interface TableSummary { |
| 16 | /** Table name (stripped of schema qualifier — `notes`, not `proj_xxx.notes`). */ |
| 17 | readonly name: string; |
| 18 | /** Approximate row count via `pg_class.reltuples` — fast, slightly stale. */ |
| 19 | readonly approxRowCount: number; |
| 20 | /** Total relation size on disk (bytes), including indexes + toast. */ |
| 21 | readonly bytes: number; |
| 22 | } |
| 23 | |
| 24 | /** |
| 25 | * Lists tables in a project's data-plane schema. Skips the platform-owned |
| 26 | * `_briven_*` tables — those are bookkeeping the customer never edits. |
| 27 | * |
| 28 | * Approx row count comes from pg_class.reltuples which is updated by |
| 29 | * autovacuum; fast (single index lookup) but can lag a recently-bulk- |
| 30 | * inserted table by ~30s. Acceptable for a dashboard summary; if a |
| 31 | * future view needs exact counts it can issue `count(*)` per table. |
| 32 | */ |
| 33 | export async function listProjectTables(projectId: string): Promise<TableSummary[]> { |
| 34 | return runInProjectDatabase(projectId, async (tx) => { |
| 35 | const rows = (await tx.unsafe( |
| 36 | ` |
| 37 | SELECT |
| 38 | c.relname AS table_name, |
| 39 | c.reltuples::bigint AS reltuples, |
| 40 | pg_total_relation_size(c.oid)::bigint AS bytes |
| 41 | FROM pg_class c |
| 42 | JOIN pg_namespace n ON n.oid = c.relnamespace |
| 43 | WHERE n.nspname = 'public' |
| 44 | AND c.relkind = 'r' |
| 45 | -- DoltGres lacks the LIKE ... ESCAPE function (errno 1105 |
| 46 | -- 'not_like_escape'), so the _briven_ housekeeping filter uses a |
| 47 | -- plain prefix compare instead. left()/<> are both supported. |
| 48 | AND left(c.relname, 8) <> '_briven_' |
| 49 | ORDER BY c.relname |
| 50 | `, |
| 51 | )) as Array<{ table_name: string; reltuples: string | number; bytes: string | number }>; |
| 52 | return rows.map((row) => ({ |
| 53 | name: row.table_name, |
| 54 | approxRowCount: Number(row.reltuples) || 0, |
| 55 | bytes: Number(row.bytes) || 0, |
| 56 | })); |
| 57 | }); |
| 58 | } |
| 59 | |
| 60 | export interface ColumnInfo { |
| 61 | readonly name: string; |
| 62 | readonly dataType: string; |
| 63 | readonly nullable: boolean; |
| 64 | readonly defaultExpr: string | null; |
| 65 | readonly ordinalPosition: number; |
| 66 | /** True if this column participates in the table's primary key. */ |
| 67 | readonly isPrimaryKey: boolean; |
| 68 | /** If this column is a foreign key, the target table.column. Null otherwise. */ |
| 69 | readonly references: { table: string; column: string } | null; |
| 70 | } |
| 71 | |
| 72 | export interface TableRows { |
| 73 | readonly columns: readonly ColumnInfo[]; |
| 74 | readonly rows: ReadonlyArray<Record<string, unknown>>; |
| 75 | readonly limit: number; |
| 76 | readonly offset: number; |
| 77 | readonly hasMore: boolean; |
| 78 | } |
| 79 | |
| 80 | const TABLE_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/; |
| 81 | |
| 82 | /** |
| 83 | * Validate that a table name is a real table in the project's schema. |
| 84 | * The regex blocks SQL-injection via the table identifier; the existence |
| 85 | * check is the actual defence against cross-schema reads — only tables |
| 86 | * present in the project's `pg_namespace` will ever be queried. |
| 87 | */ |
| 88 | async function assertTableExists(projectId: string, tableName: string): Promise<void> { |
| 89 | if (!TABLE_NAME_RE.test(tableName)) { |
| 90 | throw new ValidationError('invalid table name', { tableName }); |
| 91 | } |
| 92 | if (tableName.startsWith('_briven_')) { |
| 93 | throw new ValidationError('platform-owned tables are not queryable via studio', { |
| 94 | tableName, |
| 95 | }); |
| 96 | } |
| 97 | const row = await runInProjectDatabase(projectId, async (tx) => { |
| 98 | const [r] = (await tx.unsafe( |
| 99 | ` |
| 100 | SELECT EXISTS( |
| 101 | SELECT 1 FROM pg_class c |
| 102 | JOIN pg_namespace n ON n.oid = c.relnamespace |
| 103 | WHERE n.nspname = 'public' |
| 104 | AND c.relname = $1 |
| 105 | AND c.relkind = 'r' |
| 106 | ) AS exists |
| 107 | `, |
| 108 | [tableName], |
| 109 | )) as Array<{ exists: boolean }>; |
| 110 | return r; |
| 111 | }); |
| 112 | if (!row?.exists) { |
| 113 | throw new brivenError('not_found', `table not found: ${tableName}`, { status: 404 }); |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | export async function getTableColumns( |
| 118 | projectId: string, |
| 119 | tableName: string, |
| 120 | ): Promise<readonly ColumnInfo[]> { |
| 121 | await assertTableExists(projectId, tableName); |
| 122 | // Single query: information_schema.columns LEFT JOINed against the |
| 123 | // table's PK column set, plus a LEFT JOIN against information_schema's |
| 124 | // FK metadata so each column row can carry its (table.column) reference |
| 125 | // if there is one. PK columns come from information_schema.statistics |
| 126 | // (index_name = 'PRIMARY'), NOT pg_index — DoltGres rejects the pg_index |
| 127 | // join ("operator does not exist: smallint = int2vector"); same |
| 128 | // workaround as listIndexes below. Verified live against DoltGres. |
| 129 | const rows = (await runInProjectDatabase(projectId, async (tx) => |
| 130 | tx.unsafe( |
| 131 | ` |
| 132 | WITH pk_cols AS ( |
| 133 | SELECT column_name |
| 134 | FROM information_schema.statistics |
| 135 | WHERE table_schema = 'public' |
| 136 | AND table_name = $1 |
| 137 | AND index_name = 'PRIMARY' |
| 138 | ), |
| 139 | fk_cols AS ( |
| 140 | SELECT |
| 141 | kcu.column_name, |
| 142 | ccu.table_name AS fk_table, |
| 143 | ccu.column_name AS fk_column |
| 144 | FROM information_schema.table_constraints tc |
| 145 | JOIN information_schema.key_column_usage kcu |
| 146 | ON kcu.constraint_name = tc.constraint_name |
| 147 | AND kcu.table_schema = tc.table_schema |
| 148 | JOIN information_schema.constraint_column_usage ccu |
| 149 | ON ccu.constraint_name = tc.constraint_name |
| 150 | AND ccu.table_schema = tc.table_schema |
| 151 | WHERE tc.constraint_type = 'FOREIGN KEY' |
| 152 | AND tc.table_schema = 'public' |
| 153 | AND tc.table_name = $1 |
| 154 | ) |
| 155 | SELECT |
| 156 | c.column_name, |
| 157 | c.data_type, |
| 158 | c.is_nullable, |
| 159 | c.column_default, |
| 160 | c.ordinal_position, |
| 161 | (pk.column_name IS NOT NULL) AS is_primary_key, |
| 162 | fk.fk_table, |
| 163 | fk.fk_column |
| 164 | FROM information_schema.columns c |
| 165 | LEFT JOIN pk_cols pk ON pk.column_name = c.column_name |
| 166 | LEFT JOIN fk_cols fk ON fk.column_name = c.column_name |
| 167 | WHERE c.table_schema = 'public' AND c.table_name = $1 |
| 168 | ORDER BY c.ordinal_position |
| 169 | `, |
| 170 | [tableName], |
| 171 | ), |
| 172 | )) as Array<{ |
| 173 | column_name: string; |
| 174 | data_type: string; |
| 175 | is_nullable: string; |
| 176 | column_default: string | null; |
| 177 | ordinal_position: number; |
| 178 | is_primary_key: boolean; |
| 179 | fk_table: string | null; |
| 180 | fk_column: string | null; |
| 181 | }>; |
| 182 | return rows.map((row) => ({ |
| 183 | name: row.column_name, |
| 184 | dataType: row.data_type, |
| 185 | nullable: row.is_nullable === 'YES', |
| 186 | defaultExpr: row.column_default, |
| 187 | ordinalPosition: row.ordinal_position, |
| 188 | isPrimaryKey: Boolean(row.is_primary_key), |
| 189 | references: |
| 190 | row.fk_table && row.fk_column ? { table: row.fk_table, column: row.fk_column } : null, |
| 191 | })); |
| 192 | } |
| 193 | |
| 194 | const MAX_LIMIT = 200; |
| 195 | const DEFAULT_LIMIT = 50; |
| 196 | |
| 197 | /** Supported filter operators. Allow-list — anything else returns 400. */ |
| 198 | export const FILTER_OPS = ['eq', 'contains', 'gt', 'lt', 'gte', 'lte'] as const; |
| 199 | export type FilterOp = (typeof FILTER_OPS)[number]; |
| 200 | |
| 201 | /** |
| 202 | * Pure WHERE-clause builder. Extracted from `getTableRows` so the SQL |
| 203 | * shape, identifier validation, operator allow-list, and value-as-param |
| 204 | * guarantees are unit-testable without a live data plane. |
| 205 | * |
| 206 | * Returns `{ clauses, params }` — caller joins with ` AND ` and prefixes |
| 207 | * `WHERE `. Throws `ValidationError` on bad column name, unknown column, |
| 208 | * or unknown operator. Values are NEVER inlined — every value is pushed |
| 209 | * into `params` and referenced as `$N`, so user-supplied strings (e.g. |
| 210 | * `'; DROP TABLE …`) cannot escape parameterisation. |
| 211 | */ |
| 212 | export function buildFilterClauses( |
| 213 | filters: ReadonlyArray<{ |
| 214 | column: string; |
| 215 | op: FilterOp; |
| 216 | value: string | number | boolean | null; |
| 217 | }>, |
| 218 | colNames: ReadonlySet<string>, |
| 219 | tableName: string, |
| 220 | ): { clauses: string[]; params: unknown[] } { |
| 221 | const clauses: string[] = []; |
| 222 | const params: unknown[] = []; |
| 223 | for (const { column: col, op, value } of filters) { |
| 224 | if (!COLUMN_NAME_RE.test(col)) { |
| 225 | throw new ValidationError('invalid filter column', { column: col }); |
| 226 | } |
| 227 | if (!colNames.has(col)) { |
| 228 | throw new ValidationError('filter column not found on table', { |
| 229 | table: tableName, |
| 230 | column: col, |
| 231 | }); |
| 232 | } |
| 233 | if (!(FILTER_OPS as readonly string[]).includes(op)) { |
| 234 | throw new ValidationError('invalid filter operator', { op }); |
| 235 | } |
| 236 | params.push(value); |
| 237 | const placeholder = `$${params.length}`; |
| 238 | switch (op) { |
| 239 | case 'eq': |
| 240 | clauses.push(`"${col}" = ${placeholder}`); |
| 241 | break; |
| 242 | case 'contains': |
| 243 | // DoltGres has no ILIKE, so `lower(col) LIKE lower(value)` gives the |
| 244 | // same case-insensitive substring match. The placeholder is wrapped |
| 245 | // server-side so callers can't smuggle pattern characters by writing |
| 246 | // `%foo%` themselves — `'%' || lower($N) || '%'` parameterises the |
| 247 | // literal value with `%` glued in SQL, not in the input. |
| 248 | clauses.push(`lower("${col}"::text) LIKE '%' || lower(${placeholder}) || '%'`); |
| 249 | break; |
| 250 | case 'gt': |
| 251 | clauses.push(`"${col}" > ${placeholder}`); |
| 252 | break; |
| 253 | case 'lt': |
| 254 | clauses.push(`"${col}" < ${placeholder}`); |
| 255 | break; |
| 256 | case 'gte': |
| 257 | clauses.push(`"${col}" >= ${placeholder}`); |
| 258 | break; |
| 259 | case 'lte': |
| 260 | clauses.push(`"${col}" <= ${placeholder}`); |
| 261 | break; |
| 262 | } |
| 263 | } |
| 264 | return { clauses, params }; |
| 265 | } |
| 266 | |
| 267 | export interface RowsOpts { |
| 268 | readonly limit?: number; |
| 269 | readonly offset?: number; |
| 270 | readonly orderBy?: { column: string; direction: 'asc' | 'desc' } | null; |
| 271 | /** Filter clauses with operators. Order is preserved in WHERE generation. */ |
| 272 | readonly filters?: ReadonlyArray<{ |
| 273 | column: string; |
| 274 | op: FilterOp; |
| 275 | value: string | number | boolean | null; |
| 276 | }>; |
| 277 | } |
| 278 | |
| 279 | /** |
| 280 | * Paginated table contents. Identifier interpolation is gated by |
| 281 | * `assertTableExists` — we never accept a raw table name without |
| 282 | * confirming it's in the project's schema. Limit/offset/orderBy/filter |
| 283 | * column names are validated against the actual columns set; values |
| 284 | * pass through `sql.unsafe`'s parameterisation, never string concat. |
| 285 | * |
| 286 | * Pulls one extra row to detect `hasMore` without a separate `count(*)` |
| 287 | * — counting a 10M-row table would dominate the request. |
| 288 | */ |
| 289 | export async function getTableRows( |
| 290 | projectId: string, |
| 291 | tableName: string, |
| 292 | opts: RowsOpts = {}, |
| 293 | ): Promise<TableRows> { |
| 294 | await assertTableExists(projectId, tableName); |
| 295 | const limit = clamp(opts.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT); |
| 296 | const offset = Math.max(0, opts.offset ?? 0); |
| 297 | const columns = await getTableColumns(projectId, tableName); |
| 298 | const colNames = new Set(columns.map((c) => c.name)); |
| 299 | |
| 300 | const { clauses: filterClauses, params } = buildFilterClauses( |
| 301 | opts.filters ?? [], |
| 302 | colNames, |
| 303 | tableName, |
| 304 | ); |
| 305 | const where = filterClauses.length > 0 ? `WHERE ${filterClauses.join(' AND ')}` : ''; |
| 306 | |
| 307 | // Build ORDER BY — validated identifier, fixed-set direction. |
| 308 | let orderBy = ''; |
| 309 | if (opts.orderBy) { |
| 310 | if (!COLUMN_NAME_RE.test(opts.orderBy.column)) { |
| 311 | throw new ValidationError('invalid order-by column', { column: opts.orderBy.column }); |
| 312 | } |
| 313 | if (!colNames.has(opts.orderBy.column)) { |
| 314 | throw new ValidationError('order-by column not found on table', { |
| 315 | table: tableName, |
| 316 | column: opts.orderBy.column, |
| 317 | }); |
| 318 | } |
| 319 | const dir = opts.orderBy.direction === 'desc' ? 'DESC' : 'ASC'; |
| 320 | orderBy = `ORDER BY "${opts.orderBy.column}" ${dir}`; |
| 321 | } |
| 322 | |
| 323 | // limit + offset are appended as the last two parameters. |
| 324 | params.push(limit + 1); |
| 325 | params.push(offset); |
| 326 | const limitOffset = `LIMIT $${params.length - 1} OFFSET $${params.length}`; |
| 327 | |
| 328 | const rows = (await runInProjectDatabase(projectId, async (tx) => |
| 329 | tx.unsafe( |
| 330 | `SELECT * FROM "${tableName}" ${where} ${orderBy} ${limitOffset}`.replace(/\s+/g, ' ').trim(), |
| 331 | params as never[], |
| 332 | ), |
| 333 | )) as Array<Record<string, unknown>>; |
| 334 | const hasMore = rows.length > limit; |
| 335 | const trimmed = hasMore ? rows.slice(0, limit) : rows; |
| 336 | return { columns, rows: trimmed, limit, offset, hasMore }; |
| 337 | } |
| 338 | |
| 339 | function clamp(n: number, lo: number, hi: number): number { |
| 340 | if (!Number.isFinite(n)) return lo; |
| 341 | if (n < lo) return lo; |
| 342 | if (n > hi) return hi; |
| 343 | return Math.floor(n); |
| 344 | } |
| 345 | |
| 346 | const COLUMN_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/; |
| 347 | |
| 348 | /** |
| 349 | * One {column, value} pair for matching a row by primary key. For |
| 350 | * single-PK tables this is a length-1 array; for composite-keyed tables |
| 351 | * (M2M, time-series with natural keys) it's length-N. The service |
| 352 | * enforces that the caller-provided column set EXACTLY matches the |
| 353 | * table's PK column set — a partial PK would silently match many rows. |
| 354 | */ |
| 355 | export interface PrimaryKeyValue { |
| 356 | readonly column: string; |
| 357 | readonly value: string | number; |
| 358 | } |
| 359 | |
| 360 | export interface UpdateCellInput { |
| 361 | readonly projectId: string; |
| 362 | readonly tableName: string; |
| 363 | readonly primaryKey: ReadonlyArray<PrimaryKeyValue>; |
| 364 | readonly column: string; |
| 365 | /** Already-typed JS value. The data plane casts via parameter binding. */ |
| 366 | readonly value: unknown; |
| 367 | } |
| 368 | |
| 369 | export interface UpdateCellResult { |
| 370 | readonly affected: number; |
| 371 | } |
| 372 | |
| 373 | function assertPrimaryKeyMatches( |
| 374 | cols: ReadonlyArray<{ name: string; isPrimaryKey: boolean }>, |
| 375 | pk: ReadonlyArray<PrimaryKeyValue>, |
| 376 | ): void { |
| 377 | if (pk.length === 0) { |
| 378 | throw new ValidationError('primary key array must not be empty', {}); |
| 379 | } |
| 380 | for (const pair of pk) { |
| 381 | if (!COLUMN_NAME_RE.test(pair.column)) { |
| 382 | throw new ValidationError('invalid primary-key column', { column: pair.column }); |
| 383 | } |
| 384 | } |
| 385 | const callerSet = new Set(pk.map((p) => p.column)); |
| 386 | if (callerSet.size !== pk.length) { |
| 387 | throw new ValidationError('duplicate primary-key column in request', {}); |
| 388 | } |
| 389 | const tablePkSet = new Set(cols.filter((c) => c.isPrimaryKey).map((c) => c.name)); |
| 390 | if (tablePkSet.size === 0) { |
| 391 | throw new ValidationError('table has no primary key — inline edits disabled', {}); |
| 392 | } |
| 393 | if (tablePkSet.size !== callerSet.size) { |
| 394 | throw new ValidationError( |
| 395 | `primary-key column count mismatch — table has ${tablePkSet.size}, request has ${callerSet.size}`, |
| 396 | {}, |
| 397 | ); |
| 398 | } |
| 399 | for (const name of callerSet) { |
| 400 | if (!tablePkSet.has(name)) { |
| 401 | throw new ValidationError("primary-key column doesn't match the table's pk", { |
| 402 | column: name, |
| 403 | }); |
| 404 | } |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | /** |
| 409 | * Update a single cell on a single row. Admin-tier guarded at the route |
| 410 | * layer; this service additionally: |
| 411 | * |
| 412 | * - validates the table + column identifiers against existence in the |
| 413 | * project's schema (no SQL injection through identifiers) |
| 414 | * - refuses to touch platform-owned `_briven_*` tables |
| 415 | * - parameterises the row-key + the new value so the only thing |
| 416 | * interpolated is the verified table/column name |
| 417 | * - requires the primary-key array to match the table's pk EXACTLY — |
| 418 | * a partial PK on a composite-keyed table would silently match many |
| 419 | * rows and clobber every one |
| 420 | */ |
| 421 | export async function updateCell(input: UpdateCellInput): Promise<UpdateCellResult> { |
| 422 | // Single-column update is the one-value special case of a multi-column |
| 423 | // update — delegate so both paths share one validated, snap-back-safe |
| 424 | // SQL builder (see updateRow). |
| 425 | return updateRow({ |
| 426 | projectId: input.projectId, |
| 427 | tableName: input.tableName, |
| 428 | primaryKey: input.primaryKey, |
| 429 | values: { [input.column]: input.value }, |
| 430 | }); |
| 431 | } |
| 432 | |
| 433 | export interface UpdateRowInput { |
| 434 | readonly projectId: string; |
| 435 | readonly tableName: string; |
| 436 | readonly primaryKey: ReadonlyArray<PrimaryKeyValue>; |
| 437 | /** |
| 438 | * Column → new value. Every key is validated against the real schema and |
| 439 | * COLUMN_NAME_RE; every value is parameter-bound. Must contain ≥1 column. |
| 440 | */ |
| 441 | readonly values: Record<string, unknown>; |
| 442 | } |
| 443 | |
| 444 | /** |
| 445 | * Update one OR many columns on a single row, matched by primary key. |
| 446 | * Backs both the single-column studio cell edit (`updateCell` delegates |
| 447 | * here) and the multi-column dashboard save. Like `updateCell` it: |
| 448 | * |
| 449 | * - validates the table + every column identifier against existence in the |
| 450 | * project's schema (no SQL injection through identifiers) |
| 451 | * - refuses to touch platform-owned `_briven_*` tables |
| 452 | * - requires the primary-key array to match the table's pk EXACTLY — |
| 453 | * a partial PK on a composite-keyed table would silently match many rows |
| 454 | * - parameterises every SET value + every row-key value; the only things |
| 455 | * interpolated are verified table/column names |
| 456 | * - SETs all columns in ONE `UPDATE ... SET c1=$1, c2=$2, ... WHERE ...` |
| 457 | * statement inside the same `runInProjectDatabase` + |
| 458 | * `SET dolt_transaction_commit = 1` pattern as the rest of studio |
| 459 | * |
| 460 | * Snap-back fix: recovers the affected-row count via `RETURNING <pkColumn>` |
| 461 | * (a guaranteed-real column) rather than `RETURNING 1`. DoltGres accepts |
| 462 | * `RETURNING <column>` (proven by the dolt-compat alarm) but a bare literal |
| 463 | * `RETURNING 1` on UPDATE threw — which reverted single-cell edits in the |
| 464 | * dashboard ("snap-back"). The pg ProjectTx adapter returns only the rows |
| 465 | * array (no `.count`), so a real RETURNING column is how we count. |
| 466 | */ |
| 467 | export async function updateRow(input: UpdateRowInput): Promise<UpdateCellResult> { |
| 468 | await assertTableExists(input.projectId, input.tableName); |
| 469 | const setKeys = Object.keys(input.values); |
| 470 | if (setKeys.length === 0) { |
| 471 | throw new ValidationError('update requires at least one column', {}); |
| 472 | } |
| 473 | for (const k of setKeys) { |
| 474 | if (!COLUMN_NAME_RE.test(k)) { |
| 475 | throw new ValidationError('invalid column name', { column: k }); |
| 476 | } |
| 477 | } |
| 478 | const cols = await getTableColumns(input.projectId, input.tableName); |
| 479 | const colNames = new Set(cols.map((c) => c.name)); |
| 480 | for (const k of setKeys) { |
| 481 | if (!colNames.has(k)) { |
| 482 | throw new ValidationError('column not found on table', { |
| 483 | table: input.tableName, |
| 484 | column: k, |
| 485 | }); |
| 486 | } |
| 487 | } |
| 488 | assertPrimaryKeyMatches(cols, input.primaryKey); |
| 489 | |
| 490 | // Param layout: $1..$M are the SET values (in setKeys order), then |
| 491 | // $M+1..$M+N are the WHERE (primary-key) values. Every identifier stays |
| 492 | // inside double-quotes; every value is parameter-bound. |
| 493 | const params: unknown[] = []; |
| 494 | const setSql = setKeys |
| 495 | .map((k) => { |
| 496 | params.push(input.values[k]); |
| 497 | return `"${k}" = $${params.length}`; |
| 498 | }) |
| 499 | .join(', '); |
| 500 | const whereSql = input.primaryKey |
| 501 | .map((p) => { |
| 502 | params.push(p.value); |
| 503 | return `"${p.column}" = $${params.length}`; |
| 504 | }) |
| 505 | .join(' AND '); |
| 506 | // `RETURNING <pkColumn>` (a real column) recovers the affected-row count — |
| 507 | // see the snap-back note above; a bare `RETURNING 1` reverted edits. |
| 508 | const pkReturn = `"${input.primaryKey[0]!.column}"`; |
| 509 | const rows = await runInProjectDatabase(input.projectId, async (tx) => { |
| 510 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 511 | return tx.unsafe( |
| 512 | `UPDATE "${input.tableName}" SET ${setSql} WHERE ${whereSql} RETURNING ${pkReturn}`, |
| 513 | params as unknown as never[], |
| 514 | ); |
| 515 | }); |
| 516 | return { affected: rows.length }; |
| 517 | } |
| 518 | |
| 519 | export interface InsertRowInput { |
| 520 | readonly projectId: string; |
| 521 | readonly tableName: string; |
| 522 | /** Column → value. Every key is validated against the actual schema. */ |
| 523 | readonly values: Record<string, unknown>; |
| 524 | } |
| 525 | |
| 526 | export interface InsertRowResult { |
| 527 | readonly inserted: Record<string, unknown> | null; |
| 528 | } |
| 529 | |
| 530 | /** |
| 531 | * Insert one row, returning the row as the database stored it (with |
| 532 | * defaults filled in). Validates that every key in `values` is a real |
| 533 | * column on the table. Builds a parameterised INSERT — the only things |
| 534 | * interpolated are the verified schema/table/column identifiers. |
| 535 | */ |
| 536 | export async function insertRow(input: InsertRowInput): Promise<InsertRowResult> { |
| 537 | await assertTableExists(input.projectId, input.tableName); |
| 538 | const cols = await getTableColumns(input.projectId, input.tableName); |
| 539 | const colNames = new Set(cols.map((c) => c.name)); |
| 540 | const keys = Object.keys(input.values); |
| 541 | for (const k of keys) { |
| 542 | if (!COLUMN_NAME_RE.test(k)) { |
| 543 | throw new ValidationError('invalid column name', { column: k }); |
| 544 | } |
| 545 | if (!colNames.has(k)) { |
| 546 | throw new ValidationError('column not found on table', { |
| 547 | table: input.tableName, |
| 548 | column: k, |
| 549 | }); |
| 550 | } |
| 551 | } |
| 552 | if (keys.length === 0) { |
| 553 | throw new ValidationError('insert requires at least one column', {}); |
| 554 | } |
| 555 | |
| 556 | const placeholders = keys.map((_, i) => `$${i + 1}`).join(', '); |
| 557 | const cols_sql = keys.map((k) => `"${k}"`).join(', '); |
| 558 | const params = keys.map((k) => input.values[k] as never); |
| 559 | const rows = (await runInProjectDatabase(input.projectId, async (tx) => { |
| 560 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 561 | return tx.unsafe( |
| 562 | `INSERT INTO "${input.tableName}" (${cols_sql}) VALUES (${placeholders}) RETURNING *`, |
| 563 | params, |
| 564 | ); |
| 565 | })) as Array<Record<string, unknown>>; |
| 566 | return { inserted: rows[0] ?? null }; |
| 567 | } |
| 568 | |
| 569 | export interface InsertRowsInput { |
| 570 | readonly projectId: string; |
| 571 | readonly tableName: string; |
| 572 | /** |
| 573 | * The rows to insert. Every row must share the SAME set of columns (a |
| 574 | * uniform column set is required to build one multi-row INSERT), and |
| 575 | * every key is validated against the actual schema. |
| 576 | */ |
| 577 | readonly rows: ReadonlyArray<Record<string, unknown>>; |
| 578 | } |
| 579 | |
| 580 | export interface InsertRowsResult { |
| 581 | readonly inserted: number; |
| 582 | readonly rows: Array<Record<string, unknown>>; |
| 583 | } |
| 584 | |
| 585 | /** Cap on rows per bulk insert — keeps one statement's parameter count sane. */ |
| 586 | const MAX_BULK_INSERT_ROWS = 500; |
| 587 | |
| 588 | /** |
| 589 | * Insert MANY rows in a single parameterised multi-row INSERT, returning the |
| 590 | * rows as the database stored them (with defaults filled in). This exists so a |
| 591 | * client can migrate a whole table in ONE request instead of one-request-per-row |
| 592 | * (which trips the per-minute mutate rate limit). |
| 593 | * |
| 594 | * Modelled on `insertRow`: |
| 595 | * - validates that every key across the rows is a real column on the table |
| 596 | * - requires all rows to share the SAME column set (uniform, so a single |
| 597 | * `VALUES (...),(...)` statement can be built) |
| 598 | * - builds a parameterised INSERT — the only things interpolated are the |
| 599 | * verified table/column identifiers; every value is parameter-bound in |
| 600 | * row-major order |
| 601 | * - runs inside one `runInProjectDatabase` transaction with the same |
| 602 | * `SET dolt_transaction_commit = 1` first statement as insertRow |
| 603 | */ |
| 604 | export async function insertRows(input: InsertRowsInput): Promise<InsertRowsResult> { |
| 605 | if (input.rows.length === 0) { |
| 606 | throw new ValidationError('bulk insert requires at least one row', {}); |
| 607 | } |
| 608 | if (input.rows.length > MAX_BULK_INSERT_ROWS) { |
| 609 | throw new ValidationError( |
| 610 | `bulk insert too large — max ${MAX_BULK_INSERT_ROWS} rows per request; split into batches of ${MAX_BULK_INSERT_ROWS}`, |
| 611 | { max: MAX_BULK_INSERT_ROWS, received: input.rows.length }, |
| 612 | ); |
| 613 | } |
| 614 | |
| 615 | await assertTableExists(input.projectId, input.tableName); |
| 616 | const cols = await getTableColumns(input.projectId, input.tableName); |
| 617 | const colNames = new Set(cols.map((c) => c.name)); |
| 618 | |
| 619 | // The first row defines the column set; every other row must match it |
| 620 | // exactly so one multi-row INSERT covers them all. |
| 621 | const keys = Object.keys(input.rows[0]!); |
| 622 | if (keys.length === 0) { |
| 623 | throw new ValidationError('bulk insert requires at least one column', {}); |
| 624 | } |
| 625 | for (const k of keys) { |
| 626 | if (!COLUMN_NAME_RE.test(k)) { |
| 627 | throw new ValidationError('invalid column name', { column: k }); |
| 628 | } |
| 629 | if (!colNames.has(k)) { |
| 630 | throw new ValidationError('column not found on table', { |
| 631 | table: input.tableName, |
| 632 | column: k, |
| 633 | }); |
| 634 | } |
| 635 | } |
| 636 | |
| 637 | const keySet = new Set(keys); |
| 638 | for (let i = 0; i < input.rows.length; i++) { |
| 639 | const rowKeys = Object.keys(input.rows[i]!); |
| 640 | if ( |
| 641 | rowKeys.length !== keys.length || |
| 642 | !rowKeys.every((k) => keySet.has(k)) |
| 643 | ) { |
| 644 | throw new ValidationError( |
| 645 | `row ${i} has a different column set — every row must share the same columns for a bulk insert`, |
| 646 | { rowIndex: i, expected: keys, received: rowKeys }, |
| 647 | ); |
| 648 | } |
| 649 | } |
| 650 | |
| 651 | const cols_sql = keys.map((k) => `"${k}"`).join(', '); |
| 652 | // Values are laid out row-major: row 0 gets $1..$K, row 1 gets $K+1..$2K, etc. |
| 653 | const valuesSql = input.rows |
| 654 | .map((_, r) => `(${keys.map((_, k) => `$${r * keys.length + k + 1}`).join(', ')})`) |
| 655 | .join(', '); |
| 656 | const params: never[] = []; |
| 657 | for (const row of input.rows) { |
| 658 | for (const k of keys) { |
| 659 | params.push(row[k] as never); |
| 660 | } |
| 661 | } |
| 662 | |
| 663 | const rows = (await runInProjectDatabase(input.projectId, async (tx) => { |
| 664 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 665 | return tx.unsafe( |
| 666 | `INSERT INTO "${input.tableName}" (${cols_sql}) VALUES ${valuesSql} RETURNING *`, |
| 667 | params, |
| 668 | ); |
| 669 | })) as Array<Record<string, unknown>>; |
| 670 | return { inserted: input.rows.length, rows }; |
| 671 | } |
| 672 | |
| 673 | export interface DeleteRowInput { |
| 674 | readonly projectId: string; |
| 675 | readonly tableName: string; |
| 676 | readonly primaryKey: ReadonlyArray<PrimaryKeyValue>; |
| 677 | } |
| 678 | |
| 679 | export interface DeleteRowResult { |
| 680 | readonly affected: number; |
| 681 | } |
| 682 | |
| 683 | export interface QueryResult { |
| 684 | readonly columns: ReadonlyArray<{ name: string; dataType: string }>; |
| 685 | readonly rows: ReadonlyArray<Record<string, unknown>>; |
| 686 | readonly rowCount: number; |
| 687 | readonly command: string; |
| 688 | readonly elapsedMs: number; |
| 689 | } |
| 690 | |
| 691 | /** |
| 692 | * Run an arbitrary SQL statement against the project's OWN DoltGres database. |
| 693 | * Isolation now comes from the database boundary itself — the connection is |
| 694 | * bound to `proj_<id>`, so the user can only ever touch their own tables (the |
| 695 | * legacy per-project SET LOCAL ROLE scoping is obsolete in the |
| 696 | * database-per-project model). |
| 697 | * |
| 698 | * - bound to the project's own database (public schema) by the connection |
| 699 | * itself — no search_path pinning needed (DoltGres has no SET LOCAL) |
| 700 | * - transactional via `runInProjectDatabase`: rolls back if the statement |
| 701 | * errors, and `dolt_transaction_commit` makes the COMMIT a real Dolt commit |
| 702 | * for any writes |
| 703 | * - audit-logged by the route handler (sql text + elapsed) |
| 704 | * |
| 705 | * NB: trusting the user with their own data is fine; this lets them DROP |
| 706 | * their own tables if they want to. |
| 707 | */ |
| 708 | export async function executeQuery(projectId: string, sqlText: string): Promise<QueryResult> { |
| 709 | if (typeof sqlText !== 'string' || sqlText.trim() === '') { |
| 710 | throw new ValidationError('sql is required', {}); |
| 711 | } |
| 712 | if (sqlText.length > 16 * 1024) { |
| 713 | throw new ValidationError('sql payload too large', { max: 16 * 1024 }); |
| 714 | } |
| 715 | const t0 = Date.now(); |
| 716 | |
| 717 | // `runInProjectDatabase` opens one BEGIN/COMMIT transaction on a connection |
| 718 | // bound to the project's own database. `dolt_transaction_commit = 1` is the |
| 719 | // first statement so any write the user runs becomes a real Dolt commit |
| 720 | // (mirrors apps/runtime/src/db.ts:withProjectTx). |
| 721 | const out = (await runInProjectDatabase(projectId, async (tx) => { |
| 722 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 723 | // No SET LOCAL: DoltGres doesn't support it, and the connection is already |
| 724 | // bound to the project's own database (public schema), so search_path |
| 725 | // pinning is unnecessary. statement_timeout isn't enforceable here. |
| 726 | const result = (await tx.unsafe(sqlText)) as Array<Record<string, unknown>>; |
| 727 | const rows = Array.isArray(result) ? [...result] : []; |
| 728 | const first = rows[0]; |
| 729 | return { |
| 730 | rows, |
| 731 | // The pg ProjectTx adapter returns only the rows array — there is no |
| 732 | // `.count`/`.command`/`.columns` like postgres.js exposed. Derive what |
| 733 | // we can: row count and column names from the first row's keys. |
| 734 | count: rows.length, |
| 735 | command: 'UNKNOWN', |
| 736 | columns: first ? Object.keys(first).map((name) => ({ name })) : [], |
| 737 | }; |
| 738 | })) as { |
| 739 | rows: Array<Record<string, unknown>>; |
| 740 | count: number; |
| 741 | command: string; |
| 742 | columns: Array<{ name: string; type?: number }>; |
| 743 | }; |
| 744 | |
| 745 | const elapsedMs = Date.now() - t0; |
| 746 | |
| 747 | // We don't have a cheap type-name lookup here. Surface the column name and |
| 748 | // leave dataType as 'unknown' so the UI can still render a table. Future |
| 749 | // slice: join against pg_type. |
| 750 | const columns = out.columns.map((c) => ({ name: c.name, dataType: 'unknown' })); |
| 751 | |
| 752 | return { |
| 753 | columns, |
| 754 | rows: out.rows, |
| 755 | rowCount: out.count, |
| 756 | command: out.command, |
| 757 | elapsedMs, |
| 758 | }; |
| 759 | } |
| 760 | |
| 761 | /** |
| 762 | * Map a postgres `data_type` (information_schema flavour) onto the briven |
| 763 | * schema-DSL helper name. Best-effort — anything outside the known list |
| 764 | * is emitted as a TODO comment so the user can fix it by hand. |
| 765 | */ |
| 766 | function pgTypeToDslHelper(pgType: string): { helper: string; comment?: string } { |
| 767 | switch (pgType) { |
| 768 | case 'text': |
| 769 | case 'character varying': |
| 770 | case 'varchar': |
| 771 | return { helper: 'text()' }; |
| 772 | case 'integer': |
| 773 | case 'int4': |
| 774 | return { helper: 'integer()' }; |
| 775 | case 'bigint': |
| 776 | case 'int8': |
| 777 | return { helper: 'bigint()' }; |
| 778 | case 'boolean': |
| 779 | return { helper: 'boolean()' }; |
| 780 | case 'timestamp with time zone': |
| 781 | case 'timestamp without time zone': |
| 782 | case 'timestamp': |
| 783 | case 'timestamptz': |
| 784 | return { helper: 'timestamp()' }; |
| 785 | case 'jsonb': |
| 786 | return { helper: 'jsonb()' }; |
| 787 | case 'uuid': |
| 788 | return { helper: 'uuid()' }; |
| 789 | case 'numeric': |
| 790 | case 'decimal': |
| 791 | return { helper: 'numeric()' }; |
| 792 | default: |
| 793 | return { helper: 'text()', comment: `TODO: original type ${pgType} — adjust DSL helper` }; |
| 794 | } |
| 795 | } |
| 796 | |
| 797 | /** |
| 798 | * Generate an equivalent `briven/schema.ts` for the entire project. Used by |
| 799 | * the dashboard's "copy as schema.ts" affordance so a database the user |
| 800 | * built by clicking around in studio can be committed to git and managed |
| 801 | * via the CLI from then on. |
| 802 | */ |
| 803 | export async function exportSchemaAsDsl(projectId: string): Promise<string> { |
| 804 | const tables = await listProjectTables(projectId); |
| 805 | if (tables.length === 0) { |
| 806 | return [ |
| 807 | "import { schema, table } from '@briven/cli/schema';", |
| 808 | '', |
| 809 | '// no tables yet — create one from the dashboard or define it here.', |
| 810 | 'export default schema({});', |
| 811 | '', |
| 812 | ].join('\n'); |
| 813 | } |
| 814 | const tableNames = tables.map((t) => t.name); |
| 815 | const perTable = await Promise.all( |
| 816 | tableNames.map(async (t) => ({ table: t, columns: await getTableColumns(projectId, t) })), |
| 817 | ); |
| 818 | |
| 819 | // Collect every helper actually used so the import line stays minimal. |
| 820 | const helpersUsed = new Set<string>(); |
| 821 | helpersUsed.add('schema'); |
| 822 | helpersUsed.add('table'); |
| 823 | |
| 824 | const lines: string[] = []; |
| 825 | for (const { table: tName, columns } of perTable) { |
| 826 | lines.push(` ${tName}: table({`); |
| 827 | for (const col of columns) { |
| 828 | const { helper, comment } = pgTypeToDslHelper(col.dataType); |
| 829 | const helperName = helper.replace(/\(\)$/, ''); |
| 830 | helpersUsed.add(helperName); |
| 831 | const modifiers: string[] = []; |
| 832 | if (col.isPrimaryKey) modifiers.push('.primaryKey()'); |
| 833 | if (!col.nullable && !col.isPrimaryKey) modifiers.push('.notNull()'); |
| 834 | if (col.defaultExpr) { |
| 835 | // Single-quote the default for the DSL — it accepts a raw expression. |
| 836 | modifiers.push(`.default(${JSON.stringify(col.defaultExpr)})`); |
| 837 | } |
| 838 | if (col.references) { |
| 839 | modifiers.push(`.references('${col.references.table}', '${col.references.column}')`); |
| 840 | } |
| 841 | const commentSuffix = comment ? ` // ${comment}` : ''; |
| 842 | lines.push(` ${col.name}: ${helper}${modifiers.join('')},${commentSuffix}`); |
| 843 | } |
| 844 | lines.push(' }),'); |
| 845 | } |
| 846 | |
| 847 | const helpersList = Array.from(helpersUsed).sort(); |
| 848 | return [ |
| 849 | `import { ${helpersList.join(', ')} } from '@briven/cli/schema';`, |
| 850 | '', |
| 851 | '// generated from the live database by briven studio.', |
| 852 | "// commit this file and run `briven deploy` to keep the schema in git.", |
| 853 | 'export default schema({', |
| 854 | ...lines, |
| 855 | '});', |
| 856 | '', |
| 857 | ].join('\n'); |
| 858 | } |
| 859 | |
| 860 | /** |
| 861 | * Studio-supported column type vocabulary. Keep this list short — exotic |
| 862 | * types (arrays, enums, ranges) still work via the CLI schema.ts path. The |
| 863 | * names map 1:1 onto the actual postgres type a CREATE/ALTER statement uses. |
| 864 | */ |
| 865 | export const STUDIO_COLUMN_TYPES = [ |
| 866 | 'text', |
| 867 | 'integer', |
| 868 | 'bigint', |
| 869 | 'boolean', |
| 870 | 'timestamptz', |
| 871 | 'jsonb', |
| 872 | 'uuid', |
| 873 | 'numeric', |
| 874 | ] as const; |
| 875 | export type StudioColumnType = (typeof STUDIO_COLUMN_TYPES)[number]; |
| 876 | |
| 877 | export interface StudioColumnReference { |
| 878 | readonly table: string; |
| 879 | readonly column: string; |
| 880 | /** `cascade` | `restrict` | `setNull` | `noAction` — defaults to `noAction`. */ |
| 881 | readonly onDelete?: 'cascade' | 'restrict' | 'setNull' | 'noAction'; |
| 882 | } |
| 883 | |
| 884 | export interface StudioColumnSpec { |
| 885 | readonly name: string; |
| 886 | readonly type: StudioColumnType; |
| 887 | readonly notNull?: boolean; |
| 888 | readonly primaryKey?: boolean; |
| 889 | /** Raw SQL default expression — e.g. `'now()'`, `'gen_random_uuid()'`, `'0'`. */ |
| 890 | readonly defaultExpr?: string | null; |
| 891 | /** Optional foreign-key target. Same schema only. */ |
| 892 | readonly references?: StudioColumnReference | null; |
| 893 | } |
| 894 | |
| 895 | export interface CreateTableInput { |
| 896 | readonly projectId: string; |
| 897 | readonly tableName: string; |
| 898 | readonly columns: readonly StudioColumnSpec[]; |
| 899 | } |
| 900 | |
| 901 | const DEFAULT_EXPR_RE = /^[A-Za-z0-9_().,:\s'"\-+/*]{1,200}$/; |
| 902 | |
| 903 | function assertColumnSpec(spec: StudioColumnSpec): void { |
| 904 | if (!COLUMN_NAME_RE.test(spec.name)) { |
| 905 | throw new ValidationError('invalid column name', { column: spec.name }); |
| 906 | } |
| 907 | if (!STUDIO_COLUMN_TYPES.includes(spec.type)) { |
| 908 | throw new ValidationError('unsupported column type', { |
| 909 | type: spec.type, |
| 910 | supported: STUDIO_COLUMN_TYPES.join(','), |
| 911 | }); |
| 912 | } |
| 913 | if (spec.defaultExpr != null && !DEFAULT_EXPR_RE.test(spec.defaultExpr)) { |
| 914 | throw new ValidationError('default expression contains disallowed characters', { |
| 915 | column: spec.name, |
| 916 | }); |
| 917 | } |
| 918 | if (spec.references) { |
| 919 | if (!TABLE_NAME_RE.test(spec.references.table)) { |
| 920 | throw new ValidationError('invalid referenced table name', { |
| 921 | table: spec.references.table, |
| 922 | }); |
| 923 | } |
| 924 | if (!COLUMN_NAME_RE.test(spec.references.column)) { |
| 925 | throw new ValidationError('invalid referenced column name', { |
| 926 | column: spec.references.column, |
| 927 | }); |
| 928 | } |
| 929 | } |
| 930 | } |
| 931 | |
| 932 | const ON_DELETE_SQL: Record<NonNullable<StudioColumnReference['onDelete']>, string> = { |
| 933 | cascade: 'CASCADE', |
| 934 | restrict: 'RESTRICT', |
| 935 | setNull: 'SET NULL', |
| 936 | noAction: 'NO ACTION', |
| 937 | }; |
| 938 | |
| 939 | function columnDdl( |
| 940 | spec: StudioColumnSpec, |
| 941 | opts: { suppressInlinePk?: boolean } = {}, |
| 942 | ): string { |
| 943 | const parts = [`"${spec.name}" ${spec.type}`]; |
| 944 | // For composite PKs, the inline PRIMARY KEY is suppressed — a single |
| 945 | // table-level PRIMARY KEY (col, col) constraint is emitted by the |
| 946 | // caller instead. NOT NULL is implicit on PK columns in Postgres so |
| 947 | // the existing "skip explicit NOT NULL when PK" behaviour stays right |
| 948 | // whether the key is inline or composite. |
| 949 | if (spec.primaryKey && !opts.suppressInlinePk) parts.push('PRIMARY KEY'); |
| 950 | if (spec.notNull && !spec.primaryKey) parts.push('NOT NULL'); |
| 951 | if (spec.defaultExpr != null && spec.defaultExpr !== '') { |
| 952 | parts.push(`DEFAULT ${spec.defaultExpr}`); |
| 953 | } |
| 954 | if (spec.references) { |
| 955 | // FK target lives in the same project database — unqualified identifier |
| 956 | // resolves to `public`, so no schema prefix is needed. |
| 957 | const onDelete = ON_DELETE_SQL[spec.references.onDelete ?? 'noAction']; |
| 958 | parts.push( |
| 959 | `REFERENCES "${spec.references.table}" ("${spec.references.column}") ON DELETE ${onDelete}`, |
| 960 | ); |
| 961 | } |
| 962 | return parts.join(' '); |
| 963 | } |
| 964 | |
| 965 | async function assertFkTarget( |
| 966 | projectId: string, |
| 967 | ref: StudioColumnReference, |
| 968 | selfTable: string, |
| 969 | ): Promise<void> { |
| 970 | // Self-reference is allowed (e.g. parent_id on comments) — skip the |
| 971 | // existence probe so brand-new tables can FK to themselves. |
| 972 | if (ref.table === selfTable) return; |
| 973 | const row = await runInProjectDatabase(projectId, async (tx) => { |
| 974 | const [r] = (await tx.unsafe( |
| 975 | ` |
| 976 | SELECT EXISTS( |
| 977 | SELECT 1 FROM information_schema.columns |
| 978 | WHERE table_schema = 'public' |
| 979 | AND table_name = $1 |
| 980 | AND column_name = $2 |
| 981 | ) AS exists |
| 982 | `, |
| 983 | [ref.table, ref.column], |
| 984 | )) as Array<{ exists: boolean }>; |
| 985 | return r; |
| 986 | }); |
| 987 | if (!row?.exists) { |
| 988 | throw new ValidationError('referenced table.column does not exist', { |
| 989 | table: ref.table, |
| 990 | column: ref.column, |
| 991 | }); |
| 992 | } |
| 993 | } |
| 994 | |
| 995 | /** |
| 996 | * Create a new table in the project's schema. Refuses platform-owned |
| 997 | * `_briven_*` names, enforces the studio type whitelist on every column, |
| 998 | * and demands at least one primary-key column so the row-edit path keeps |
| 999 | * working downstream. |
| 1000 | */ |
| 1001 | export async function createTable(input: CreateTableInput): Promise<{ name: string }> { |
| 1002 | if (!TABLE_NAME_RE.test(input.tableName)) { |
| 1003 | throw new ValidationError('invalid table name', { tableName: input.tableName }); |
| 1004 | } |
| 1005 | if (input.tableName.startsWith('_briven_')) { |
| 1006 | throw new ValidationError('platform-owned table prefix is reserved', { |
| 1007 | tableName: input.tableName, |
| 1008 | }); |
| 1009 | } |
| 1010 | if (input.columns.length === 0) { |
| 1011 | throw new ValidationError('a table needs at least one column', {}); |
| 1012 | } |
| 1013 | const seen = new Set<string>(); |
| 1014 | let pkCount = 0; |
| 1015 | for (const col of input.columns) { |
| 1016 | assertColumnSpec(col); |
| 1017 | if (seen.has(col.name)) { |
| 1018 | throw new ValidationError('duplicate column name', { column: col.name }); |
| 1019 | } |
| 1020 | seen.add(col.name); |
| 1021 | if (col.primaryKey) pkCount++; |
| 1022 | } |
| 1023 | if (pkCount === 0) { |
| 1024 | throw new ValidationError('one column must be marked primaryKey', {}); |
| 1025 | } |
| 1026 | |
| 1027 | for (const col of input.columns) { |
| 1028 | if (col.references) { |
| 1029 | await assertFkTarget(input.projectId, col.references, input.tableName); |
| 1030 | } |
| 1031 | } |
| 1032 | |
| 1033 | // Composite PK → table-level constraint; single PK stays inline so the |
| 1034 | // SQL output is unchanged for every non-M2M shape. |
| 1035 | const isComposite = pkCount > 1; |
| 1036 | const colsSql = input.columns |
| 1037 | .map((c) => columnDdl(c, { suppressInlinePk: isComposite })) |
| 1038 | .join(', '); |
| 1039 | const pkClause = isComposite |
| 1040 | ? `, PRIMARY KEY (${input.columns |
| 1041 | .filter((c) => c.primaryKey) |
| 1042 | .map((c) => `"${c.name}"`) |
| 1043 | .join(', ')})` |
| 1044 | : ''; |
| 1045 | await runInProjectDatabase(input.projectId, async (tx) => { |
| 1046 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 1047 | await tx.unsafe(`CREATE TABLE "${input.tableName}" (${colsSql}${pkClause})`); |
| 1048 | }); |
| 1049 | return { name: input.tableName }; |
| 1050 | } |
| 1051 | |
| 1052 | /** |
| 1053 | * Drop a table (cascade off — refuses if FKs point at it, so callers get |
| 1054 | * a clear error rather than nuking dependent data silently). |
| 1055 | */ |
| 1056 | export async function dropTable(projectId: string, tableName: string): Promise<void> { |
| 1057 | await assertTableExists(projectId, tableName); |
| 1058 | await runInProjectDatabase(projectId, async (tx) => { |
| 1059 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 1060 | await tx.unsafe(`DROP TABLE "${tableName}"`); |
| 1061 | }); |
| 1062 | } |
| 1063 | |
| 1064 | /** |
| 1065 | * TRUNCATE a table — wipes every row but keeps the schema. DoltGres does not |
| 1066 | * support `RESTART IDENTITY` (no serial sequences) nor `CASCADE`, so neither |
| 1067 | * is emitted; cascade=true is rejected with a clear error rather than sent as |
| 1068 | * SQL DoltGres can't run. |
| 1069 | */ |
| 1070 | export async function truncateTable( |
| 1071 | projectId: string, |
| 1072 | tableName: string, |
| 1073 | cascade = false, |
| 1074 | ): Promise<void> { |
| 1075 | await assertTableExists(projectId, tableName); |
| 1076 | if (cascade) { |
| 1077 | throw new ValidationError('TRUNCATE CASCADE is not supported on DoltGres', {}); |
| 1078 | } |
| 1079 | await runInProjectDatabase(projectId, async (tx) => { |
| 1080 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 1081 | await tx.unsafe(`TRUNCATE TABLE "${tableName}"`); |
| 1082 | }); |
| 1083 | } |
| 1084 | |
| 1085 | /** |
| 1086 | * Add a column to an existing table. Same type whitelist + default-expr |
| 1087 | * regex as createTable. |
| 1088 | */ |
| 1089 | export async function addColumn(input: { |
| 1090 | projectId: string; |
| 1091 | tableName: string; |
| 1092 | column: StudioColumnSpec; |
| 1093 | }): Promise<void> { |
| 1094 | await assertTableExists(input.projectId, input.tableName); |
| 1095 | assertColumnSpec(input.column); |
| 1096 | if (input.column.primaryKey) { |
| 1097 | throw new ValidationError('cannot add a primary-key column to an existing table via studio', {}); |
| 1098 | } |
| 1099 | const cols = await getTableColumns(input.projectId, input.tableName); |
| 1100 | if (cols.find((c) => c.name === input.column.name)) { |
| 1101 | throw new ValidationError('column already exists', { column: input.column.name }); |
| 1102 | } |
| 1103 | if (input.column.references) { |
| 1104 | await assertFkTarget(input.projectId, input.column.references, input.tableName); |
| 1105 | } |
| 1106 | await runInProjectDatabase(input.projectId, async (tx) => { |
| 1107 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 1108 | await tx.unsafe( |
| 1109 | `ALTER TABLE "${input.tableName}" ADD COLUMN ${columnDdl(input.column)}`, |
| 1110 | ); |
| 1111 | }); |
| 1112 | } |
| 1113 | |
| 1114 | export interface RelationshipEdge { |
| 1115 | /** Table holding the FK column. */ |
| 1116 | readonly fromTable: string; |
| 1117 | readonly fromColumn: string; |
| 1118 | /** Table the FK points at. */ |
| 1119 | readonly toTable: string; |
| 1120 | readonly toColumn: string; |
| 1121 | } |
| 1122 | |
| 1123 | /** |
| 1124 | * Every foreign-key edge in the project's schema. Drives the studio |
| 1125 | * overview's "relationships" panel so users can see the shape of their |
| 1126 | * data model at a glance. |
| 1127 | */ |
| 1128 | export interface FullSchemaTable { |
| 1129 | readonly name: string; |
| 1130 | readonly columns: readonly ColumnInfo[]; |
| 1131 | } |
| 1132 | |
| 1133 | export interface FullSchema { |
| 1134 | readonly tables: readonly FullSchemaTable[]; |
| 1135 | readonly relationships: readonly RelationshipEdge[]; |
| 1136 | } |
| 1137 | |
| 1138 | /** |
| 1139 | * One-shot read of every table + its columns + every FK edge. Drives the |
| 1140 | * "schema overview" page so users see the entire data model in one place |
| 1141 | * without paginating through each table individually. |
| 1142 | * |
| 1143 | * Single information_schema sweep — no per-table round-trip. |
| 1144 | */ |
| 1145 | export async function getFullSchema(projectId: string): Promise<FullSchema> { |
| 1146 | const tables = await listProjectTables(projectId); |
| 1147 | const perTable = await Promise.all( |
| 1148 | tables.map(async (t) => ({ |
| 1149 | name: t.name, |
| 1150 | columns: await getTableColumns(projectId, t.name), |
| 1151 | })), |
| 1152 | ); |
| 1153 | const relationships = await listRelationships(projectId); |
| 1154 | return { tables: perTable, relationships }; |
| 1155 | } |
| 1156 | |
| 1157 | export async function listRelationships(projectId: string): Promise<readonly RelationshipEdge[]> { |
| 1158 | const rows = (await runInProjectDatabase(projectId, async (tx) => |
| 1159 | tx.unsafe(` |
| 1160 | SELECT |
| 1161 | tc.table_name AS from_table, |
| 1162 | kcu.column_name AS from_column, |
| 1163 | ccu.table_name AS to_table, |
| 1164 | ccu.column_name AS to_column |
| 1165 | FROM information_schema.table_constraints tc |
| 1166 | JOIN information_schema.key_column_usage kcu |
| 1167 | ON kcu.constraint_name = tc.constraint_name |
| 1168 | AND kcu.table_schema = tc.table_schema |
| 1169 | JOIN information_schema.constraint_column_usage ccu |
| 1170 | ON ccu.constraint_name = tc.constraint_name |
| 1171 | AND ccu.table_schema = tc.table_schema |
| 1172 | WHERE tc.constraint_type = 'FOREIGN KEY' |
| 1173 | AND tc.table_schema = 'public' |
| 1174 | ORDER BY tc.table_name, kcu.column_name |
| 1175 | `), |
| 1176 | )) as Array<{ |
| 1177 | from_table: string; |
| 1178 | from_column: string; |
| 1179 | to_table: string; |
| 1180 | to_column: string; |
| 1181 | }>; |
| 1182 | return rows.map((r) => ({ |
| 1183 | fromTable: r.from_table, |
| 1184 | fromColumn: r.from_column, |
| 1185 | toTable: r.to_table, |
| 1186 | toColumn: r.to_column, |
| 1187 | })); |
| 1188 | } |
| 1189 | |
| 1190 | export interface IndexSummary { |
| 1191 | /** Index name as it lives in pg_class. */ |
| 1192 | readonly name: string; |
| 1193 | /** Ordered list of column names participating in the index. */ |
| 1194 | readonly columns: readonly string[]; |
| 1195 | /** True if the index was created with UNIQUE. */ |
| 1196 | readonly unique: boolean; |
| 1197 | /** True if this is the table's primary-key index — never droppable from studio. */ |
| 1198 | readonly isPrimary: boolean; |
| 1199 | } |
| 1200 | |
| 1201 | const INDEX_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/; |
| 1202 | |
| 1203 | /** |
| 1204 | * Every index on a table — primary, unique, and plain. Used by the schema |
| 1205 | * panel so the user can see what's already indexed before adding more. |
| 1206 | */ |
| 1207 | export async function listIndexes( |
| 1208 | projectId: string, |
| 1209 | tableName: string, |
| 1210 | ): Promise<readonly IndexSummary[]> { |
| 1211 | await assertTableExists(projectId, tableName); |
| 1212 | // DoltGres rejects the pg_index/array_position introspection join |
| 1213 | // ("operator does not exist: smallint = int2vector"). information_schema. |
| 1214 | // statistics IS supported (MySQL-heritage view) and gives one row per |
| 1215 | // (index, column) with order + uniqueness. The primary key surfaces as the |
| 1216 | // synthetic name 'PRIMARY'. NB: DoltGres returns these column keys in |
| 1217 | // UPPERCASE, so we read each row case-insensitively. |
| 1218 | const rawRows = (await runInProjectDatabase(projectId, async (tx) => |
| 1219 | tx.unsafe( |
| 1220 | ` |
| 1221 | SELECT index_name, column_name, seq_in_index, non_unique |
| 1222 | FROM information_schema.statistics |
| 1223 | WHERE table_schema = 'public' |
| 1224 | AND table_name = $1 |
| 1225 | ORDER BY index_name, seq_in_index |
| 1226 | `, |
| 1227 | [tableName], |
| 1228 | ), |
| 1229 | )) as Array<Record<string, unknown>>; |
| 1230 | |
| 1231 | // Normalise UPPER/lower key casing into a predictable shape. |
| 1232 | const pick = (row: Record<string, unknown>, key: string): unknown => |
| 1233 | row[key] ?? row[key.toUpperCase()] ?? row[key.toLowerCase()]; |
| 1234 | |
| 1235 | // Group the per-column rows into one entry per index, preserving column order. |
| 1236 | const byIndex = new Map<string, { columns: string[]; unique: boolean; isPrimary: boolean }>(); |
| 1237 | for (const row of rawRows) { |
| 1238 | const indexName = String(pick(row, 'index_name')); |
| 1239 | const columnName = String(pick(row, 'column_name')); |
| 1240 | const nonUnique = Number(pick(row, 'non_unique')) === 1; |
| 1241 | let entry = byIndex.get(indexName); |
| 1242 | if (!entry) { |
| 1243 | entry = { columns: [], unique: !nonUnique, isPrimary: indexName === 'PRIMARY' }; |
| 1244 | byIndex.set(indexName, entry); |
| 1245 | } |
| 1246 | entry.columns.push(columnName); |
| 1247 | } |
| 1248 | |
| 1249 | const summaries: IndexSummary[] = Array.from(byIndex.entries()).map(([name, e]) => ({ |
| 1250 | name, |
| 1251 | columns: e.columns, |
| 1252 | unique: e.unique, |
| 1253 | isPrimary: e.isPrimary, |
| 1254 | })); |
| 1255 | // Primary key first, then the rest by name — matches the previous ordering. |
| 1256 | return summaries.sort((a, b) => |
| 1257 | a.isPrimary === b.isPrimary ? a.name.localeCompare(b.name) : a.isPrimary ? -1 : 1, |
| 1258 | ); |
| 1259 | } |
| 1260 | |
| 1261 | export interface CreateIndexInput { |
| 1262 | readonly projectId: string; |
| 1263 | readonly tableName: string; |
| 1264 | readonly columns: readonly string[]; |
| 1265 | readonly unique?: boolean; |
| 1266 | /** Optional explicit index name. Defaults to `<table>_<cols>_idx`. */ |
| 1267 | readonly name?: string | null; |
| 1268 | } |
| 1269 | |
| 1270 | /** |
| 1271 | * Create an index on one or more columns. Refuses platform-owned tables |
| 1272 | * (via assertTableExists) and validates every column identifier against |
| 1273 | * the actual column set. |
| 1274 | */ |
| 1275 | export async function createIndex(input: CreateIndexInput): Promise<{ name: string }> { |
| 1276 | await assertTableExists(input.projectId, input.tableName); |
| 1277 | if (input.columns.length === 0) { |
| 1278 | throw new ValidationError('an index needs at least one column', {}); |
| 1279 | } |
| 1280 | const cols = await getTableColumns(input.projectId, input.tableName); |
| 1281 | const colNames = new Set(cols.map((c) => c.name)); |
| 1282 | for (const col of input.columns) { |
| 1283 | if (!COLUMN_NAME_RE.test(col)) { |
| 1284 | throw new ValidationError('invalid column name', { column: col }); |
| 1285 | } |
| 1286 | if (!colNames.has(col)) { |
| 1287 | throw new ValidationError('column not found on table', { |
| 1288 | table: input.tableName, |
| 1289 | column: col, |
| 1290 | }); |
| 1291 | } |
| 1292 | } |
| 1293 | |
| 1294 | const indexName = |
| 1295 | input.name ?? |
| 1296 | `${input.tableName}_${input.columns.join('_')}_idx`.replace(/[^A-Za-z0-9_]/g, '_').slice(0, 63); |
| 1297 | if (!INDEX_NAME_RE.test(indexName)) { |
| 1298 | throw new ValidationError('invalid index name', { name: indexName }); |
| 1299 | } |
| 1300 | |
| 1301 | const uniqueClause = input.unique ? 'UNIQUE' : ''; |
| 1302 | const colsList = input.columns.map((c) => `"${c}"`).join(', '); |
| 1303 | await runInProjectDatabase(input.projectId, async (tx) => { |
| 1304 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 1305 | await tx.unsafe( |
| 1306 | `CREATE ${uniqueClause} INDEX "${indexName}" ON "${input.tableName}" (${colsList})`, |
| 1307 | ); |
| 1308 | }); |
| 1309 | return { name: indexName }; |
| 1310 | } |
| 1311 | |
| 1312 | /** |
| 1313 | * Drop an index by name. Refuses the primary-key index (which would |
| 1314 | * invalidate row-level operations) — that has to go via dropping the |
| 1315 | * column or the table. |
| 1316 | */ |
| 1317 | export async function dropIndex( |
| 1318 | projectId: string, |
| 1319 | tableName: string, |
| 1320 | indexName: string, |
| 1321 | ): Promise<void> { |
| 1322 | await assertTableExists(projectId, tableName); |
| 1323 | if (!INDEX_NAME_RE.test(indexName)) { |
| 1324 | throw new ValidationError('invalid index name', { name: indexName }); |
| 1325 | } |
| 1326 | const all = await listIndexes(projectId, tableName); |
| 1327 | const target = all.find((i) => i.name === indexName); |
| 1328 | if (!target) { |
| 1329 | throw new ValidationError('index not found on table', { table: tableName, index: indexName }); |
| 1330 | } |
| 1331 | if (target.isPrimary) { |
| 1332 | throw new ValidationError('cannot drop the primary-key index', { index: indexName }); |
| 1333 | } |
| 1334 | await runInProjectDatabase(projectId, async (tx) => { |
| 1335 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 1336 | await tx.unsafe(`DROP INDEX "${indexName}"`); |
| 1337 | }); |
| 1338 | } |
| 1339 | |
| 1340 | /** |
| 1341 | * Rename a table. Refuses platform-owned `_briven_*` tables (source name) |
| 1342 | * and refuses to rename into the platform-owned prefix (target name). |
| 1343 | */ |
| 1344 | export async function renameTable(args: { |
| 1345 | projectId: string; |
| 1346 | oldName: string; |
| 1347 | newName: string; |
| 1348 | }): Promise<void> { |
| 1349 | await assertTableExists(args.projectId, args.oldName); |
| 1350 | if (!TABLE_NAME_RE.test(args.newName)) { |
| 1351 | throw new ValidationError('invalid new table name', { newName: args.newName }); |
| 1352 | } |
| 1353 | if (args.newName.startsWith('_briven_')) { |
| 1354 | throw new ValidationError('platform-owned table prefix is reserved', { |
| 1355 | newName: args.newName, |
| 1356 | }); |
| 1357 | } |
| 1358 | if (args.oldName === args.newName) return; |
| 1359 | await runInProjectDatabase(args.projectId, async (tx) => { |
| 1360 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 1361 | await tx.unsafe(`ALTER TABLE "${args.oldName}" RENAME TO "${args.newName}"`); |
| 1362 | }); |
| 1363 | } |
| 1364 | |
| 1365 | /** |
| 1366 | * Rename a column on a table. PK / FK / index references survive |
| 1367 | * automatically — Postgres rewrites them by oid. |
| 1368 | */ |
| 1369 | export async function renameColumn(args: { |
| 1370 | projectId: string; |
| 1371 | tableName: string; |
| 1372 | oldName: string; |
| 1373 | newName: string; |
| 1374 | }): Promise<void> { |
| 1375 | await assertTableExists(args.projectId, args.tableName); |
| 1376 | if (!COLUMN_NAME_RE.test(args.oldName)) { |
| 1377 | throw new ValidationError('invalid old column name', { oldName: args.oldName }); |
| 1378 | } |
| 1379 | if (!COLUMN_NAME_RE.test(args.newName)) { |
| 1380 | throw new ValidationError('invalid new column name', { newName: args.newName }); |
| 1381 | } |
| 1382 | if (args.oldName === args.newName) return; |
| 1383 | const cols = await getTableColumns(args.projectId, args.tableName); |
| 1384 | if (!cols.find((c) => c.name === args.oldName)) { |
| 1385 | throw new ValidationError('column not found on table', { |
| 1386 | table: args.tableName, |
| 1387 | column: args.oldName, |
| 1388 | }); |
| 1389 | } |
| 1390 | if (cols.find((c) => c.name === args.newName)) { |
| 1391 | throw new ValidationError('a column with the new name already exists', { |
| 1392 | newName: args.newName, |
| 1393 | }); |
| 1394 | } |
| 1395 | await runInProjectDatabase(args.projectId, async (tx) => { |
| 1396 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 1397 | await tx.unsafe( |
| 1398 | `ALTER TABLE "${args.tableName}" RENAME COLUMN "${args.oldName}" TO "${args.newName}"`, |
| 1399 | ); |
| 1400 | }); |
| 1401 | } |
| 1402 | |
| 1403 | export interface AlterColumnInput { |
| 1404 | readonly projectId: string; |
| 1405 | readonly tableName: string; |
| 1406 | readonly column: string; |
| 1407 | /** Set to true to make NOT NULL; false to drop NOT NULL. */ |
| 1408 | readonly notNull?: boolean; |
| 1409 | /** |
| 1410 | * Default expression. Pass `null` to drop the default. Pass a string to |
| 1411 | * set it. Omit to leave alone. |
| 1412 | */ |
| 1413 | readonly defaultExpr?: string | null; |
| 1414 | } |
| 1415 | |
| 1416 | /** |
| 1417 | * Change a column's nullability + default. Refuses to alter a PK column's |
| 1418 | * nullability (PKs are implicitly NOT NULL) and validates the default |
| 1419 | * expression against the same regex as create-column. |
| 1420 | */ |
| 1421 | export async function alterColumn(input: AlterColumnInput): Promise<void> { |
| 1422 | await assertTableExists(input.projectId, input.tableName); |
| 1423 | if (!COLUMN_NAME_RE.test(input.column)) { |
| 1424 | throw new ValidationError('invalid column name', { column: input.column }); |
| 1425 | } |
| 1426 | const cols = await getTableColumns(input.projectId, input.tableName); |
| 1427 | const target = cols.find((c) => c.name === input.column); |
| 1428 | if (!target) { |
| 1429 | throw new ValidationError('column not found on table', { |
| 1430 | table: input.tableName, |
| 1431 | column: input.column, |
| 1432 | }); |
| 1433 | } |
| 1434 | if (target.isPrimaryKey && typeof input.notNull === 'boolean') { |
| 1435 | throw new ValidationError('cannot change nullability of a primary-key column', { |
| 1436 | column: input.column, |
| 1437 | }); |
| 1438 | } |
| 1439 | if ( |
| 1440 | input.defaultExpr !== undefined |
| 1441 | && input.defaultExpr !== null |
| 1442 | && !DEFAULT_EXPR_RE.test(input.defaultExpr) |
| 1443 | ) { |
| 1444 | throw new ValidationError('default expression contains disallowed characters', { |
| 1445 | column: input.column, |
| 1446 | }); |
| 1447 | } |
| 1448 | |
| 1449 | await runInProjectDatabase(input.projectId, async (tx) => { |
| 1450 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 1451 | if (typeof input.notNull === 'boolean' && input.notNull !== !target.nullable) { |
| 1452 | const clause = input.notNull ? 'SET NOT NULL' : 'DROP NOT NULL'; |
| 1453 | await tx.unsafe( |
| 1454 | `ALTER TABLE "${input.tableName}" ALTER COLUMN "${input.column}" ${clause}`, |
| 1455 | ); |
| 1456 | } |
| 1457 | if (input.defaultExpr === null && target.defaultExpr !== null) { |
| 1458 | await tx.unsafe( |
| 1459 | `ALTER TABLE "${input.tableName}" ALTER COLUMN "${input.column}" DROP DEFAULT`, |
| 1460 | ); |
| 1461 | } else if (typeof input.defaultExpr === 'string' && input.defaultExpr !== '') { |
| 1462 | await tx.unsafe( |
| 1463 | `ALTER TABLE "${input.tableName}" ALTER COLUMN "${input.column}" SET DEFAULT ${input.defaultExpr}`, |
| 1464 | ); |
| 1465 | } |
| 1466 | }); |
| 1467 | } |
| 1468 | |
| 1469 | /** |
| 1470 | * Drop a column. Refuses to drop a PK column — that would invalidate every |
| 1471 | * row-level operation in studio and is almost always a mistake. |
| 1472 | */ |
| 1473 | export async function dropColumn(input: { |
| 1474 | projectId: string; |
| 1475 | tableName: string; |
| 1476 | column: string; |
| 1477 | }): Promise<void> { |
| 1478 | await assertTableExists(input.projectId, input.tableName); |
| 1479 | if (!COLUMN_NAME_RE.test(input.column)) { |
| 1480 | throw new ValidationError('invalid column name', { column: input.column }); |
| 1481 | } |
| 1482 | const cols = await getTableColumns(input.projectId, input.tableName); |
| 1483 | const target = cols.find((c) => c.name === input.column); |
| 1484 | if (!target) { |
| 1485 | throw new ValidationError('column not found on table', { |
| 1486 | table: input.tableName, |
| 1487 | column: input.column, |
| 1488 | }); |
| 1489 | } |
| 1490 | if (target.isPrimaryKey) { |
| 1491 | throw new ValidationError('cannot drop a primary-key column', { column: input.column }); |
| 1492 | } |
| 1493 | await runInProjectDatabase(input.projectId, async (tx) => { |
| 1494 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 1495 | await tx.unsafe(`ALTER TABLE "${input.tableName}" DROP COLUMN "${input.column}"`); |
| 1496 | }); |
| 1497 | } |
| 1498 | |
| 1499 | /** |
| 1500 | * Delete one row by primary key. Validates the table + pk-column |
| 1501 | * identifiers against the project's schema; parameterises the row-key. |
| 1502 | * Refuses to touch platform-owned `_briven_*` tables (caught by |
| 1503 | * `assertTableExists`). |
| 1504 | */ |
| 1505 | export async function deleteRow(input: DeleteRowInput): Promise<DeleteRowResult> { |
| 1506 | await assertTableExists(input.projectId, input.tableName); |
| 1507 | const cols = await getTableColumns(input.projectId, input.tableName); |
| 1508 | assertPrimaryKeyMatches(cols, input.primaryKey); |
| 1509 | |
| 1510 | const whereSql = input.primaryKey |
| 1511 | .map((p, i) => `"${p.column}" = $${i + 1}`) |
| 1512 | .join(' AND '); |
| 1513 | const params: ReadonlyArray<never> = input.primaryKey.map((p) => p.value as never); |
| 1514 | // The pg ProjectTx adapter returns the rows array (not a result object with |
| 1515 | // `.count`), so `RETURNING *` lets us recover the affected-row count. NOT a |
| 1516 | // bare `RETURNING 1` — DoltGres rejects an integer literal in RETURNING and |
| 1517 | // throws (same snap-back bug that broke single-cell UPDATEs; see updateRow). |
| 1518 | const rows = await runInProjectDatabase(input.projectId, async (tx) => { |
| 1519 | await tx.unsafe('SET dolt_transaction_commit = 1'); |
| 1520 | return tx.unsafe( |
| 1521 | `DELETE FROM "${input.tableName}" WHERE ${whereSql} RETURNING *`, |
| 1522 | params as unknown as never[], |
| 1523 | ); |
| 1524 | }); |
| 1525 | return { affected: rows.length }; |
| 1526 | } |