Files
supabase/apps/studio/data/content/content-remap.ts
T
Charis fa5eb17277 feat(studio): discriminated snippet union + source-aware writes (#48313)
Stacked on #48305.

## What

PR 3 of the stacked SQL-editor query-source series (Database vs Logs).
Stacked on the PR 2 branch `charislam/log-sql-content-shape`.

Turns `SnippetWithContent` into a discriminated union on `type` and
makes all snippet writes source-aware:

- `data/content/sql-folders-query.ts`: `SnippetWithContent` is now `{
type: 'sql'; content?: SqlSnippets.Content } | { type: 'log_sql';
content?: LogSqlSnippets.Content } | { type: 'report'; content?: never
}`. `report` is kept (the content endpoints' wire type carries it) but
has no SQL content — its body is `Dashboards.Content`, loaded through
the separate `Content` union.
- `setSql` brands per type (`untrustedLogSql` vs `untrustedSql`).
- `buildUpsertPayload` persists `snippet.type` (no longer hardcoded
`'sql'`).
- `createSqlSnippetSkeletonV2({ source })` emits the matching type +
content shape with the `as any` cast removed.
- New `components/interfaces/SQLEditor/querySource.ts`:
`SqlSnippetSource` + `getSnippetSource`.
- `seedSnippet` test helper gains a `source` arg.
- New `remapWireSnippet` boundary helper in `content-remap.ts`
concentrates the single wire->domain assertion, so `content-id-query` /
`content-upsert-mutation` call sites are cast-free (no `as unknown as`).
- Collateral: query result types aligned to the union; `updateSnippet`
no longer accepts `type` (source is immutable); db-only editor read
paths narrow away `log_sql`.

## Why

Impossible-states-impossible typing: a snippet's brand follows its
content type, so logs SQL and database SQL can never cross execution
paths. No behavior change for existing database snippets.

## Testing

- \`pnpm typecheck\` — clean
- \`pnpm --filter studio run lint:ratchet\` — no new warnings
- \`pnpm test:studio\` (data/content, SQLEditor, state/sql-editor) —
passing, including new tests for \`getSnippetSource\`, source-aware
\`setSql\`, type-aware \`buildUpsertPayload\`, and both skeleton shapes.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added source-aware creation for SQL editor snippets, including
log-based SQL snippets.
* Introduced backend source mapping so log snippets are treated as
log_sql.
* **Bug Fixes**
* Improved SQL retrieval/prettification so log snippets no longer use
the wrong fallback content.
* Ensured log snippets are sanitized and preserve correct type, content,
identifiers, and statuses during save/upsert flows.
* **Tests**
* Expanded unit and integration coverage for log snippet creation,
source mapping, editing, prettification, and upsert payloads.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-28 12:28:36 -04:00

65 lines
3.3 KiB
TypeScript

// Remap `sql` → `unchecked_sql` on SQL snippet content objects as they cross the API boundary.
// The API stores and returns the field as `sql`; the frontend type uses `unchecked_sql` to make
// it explicit that this value must never be executed without user confirmation.
//
// Both database (`type: 'sql'`) and logs (`type: 'log_sql'`) snippets carry user-authored SQL
// under the same wire field, but each is branded with its own untrusted brand so Postgres SQL
// and logs SQL can never cross execution paths. Branding is per-type and never mixed.
import { untrustedSql } from '@supabase/pg-meta'
import type { SnippetStatus } from './snippet-status'
import type { SnippetWithContent } from './sql-folders-query'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
function isSqlContentType(type: string): type is 'sql' | 'log_sql' {
return type === 'sql' || type === 'log_sql'
}
export function remapSqlContentField<T extends { type: string }>(item: T): T {
if (!isSqlContentType(item.type)) return item
if (!('content' in item)) return item
const content = item.content as Record<string, unknown>
if (!('sql' in content)) return item
const { sql, ...rest } = content
const unchecked_sql =
item.type === 'log_sql' ? untrustedLogSql(sql as string) : untrustedSql(sql as string)
return { ...item, content: { ...rest, unchecked_sql } } as T
}
export function remapSqlContentFields<T extends { type: string }>(items: Array<T>): Array<T> {
return items.map(remapSqlContentField)
}
// Wire→domain boundary for a single SQL-editor snippet. The platform API types a
// content row's `content` as an opaque `{ [key: string]: unknown }` map (and its
// `type` as the full `'sql' | 'report' | 'log_sql'`), so turning a fetched row
// into a branded, discriminated `SnippetWithContent` needs exactly one assertion.
// Concentrating it here — the single place that already owns the sql↔unchecked_sql
// rename — keeps the query/mutation call sites free of `as unknown as` casts.
export function remapWireSnippet(row: unknown, status: SnippetStatus): SnippetWithContent {
const snippet = remapSqlContentField(row as SnippetWithContent)
return { ...snippet, status }
}
// Reverse remap: `unchecked_sql` → `sql` before sending to the API.
export function unmapSqlContentField<T extends { type: string }>(item: T): T {
if (!isSqlContentType(item.type)) return item
if (!('content' in item)) return item
const content = item.content as Record<string, unknown>
if (!('unchecked_sql' in content)) {
// Defensive guard against a writer that still submits the pre-rename `{ sql }` shape.
// Such a payload happens to reach the wire correctly (the API stores `sql`), but it
// means a save path was missed during the rename — surface it loudly in development.
// Crucially, we NEVER fabricate `sql: undefined` here: that would clobber the user's
// saved query text. The no-op below preserves whatever the content already holds.
if (process.env.NODE_ENV !== 'production' && 'sql' in content) {
throw new Error(
`unmapSqlContentField: ${item.type} content is missing 'unchecked_sql' but still carries a raw 'sql' field — a save path was not migrated to the branded field.`
)
}
return item
}
const { unchecked_sql, ...rest } = content
return { ...item, content: { ...rest, sql: unchecked_sql } } as T
}