Files
supabase/apps/studio/data/content/content-remap.test.ts
Charis 7743fee3ab feat(studio): log_sql content shape + remap content.sql to unchecked_sql (#48305)
## What

PR **2 of 9** in the SQL-editor query-source (Database vs Logs) stack.

**Base:** `charislam/snippet-source-typing` (#48301) — this is a stacked
PR; review/merge that one first.

Client-side rename only — **the wire format is unchanged** (the platform
API still stores and returns `content.sql`). This moves the frontend
`LogSqlSnippets.Content` field to the branded `unchecked_sql`, matching
`SqlSnippets.Content`, and hardens the remap boundary so the rename
can't silently drop saved query text.

## Changes

- **`types/userContent.ts`** — `LogSqlSnippets.Content`'s plain `sql:
string` becomes `unchecked_sql: UntrustedLogSqlFragment` (the brand
added in PR 1). Shape kept minimal: `{ content_id, unchecked_sql,
schema_version }`.
- **`data/content/content-remap.ts`** — extend
`remapSqlContentField`/`unmapSqlContentField` to `log_sql`, branding
**per type** (`untrustedLogSql` for logs, `untrustedSql` for database)
and never mixing brands. **Defensive unmap**: content missing
`unchecked_sql` is never clobbered with `sql: undefined`; a residual raw
`sql` field (a missed save-path rename) throws in development to surface
the bug loudly, while production no-ops safely.
- **Legacy Logs Explorer consumers** updated to the branded field: the
explorer save/update paths, `SavedQueriesItem`, `RecentQueriesItem`, and
the recent-queries page.
- **Two db-only write sites** that leaned on
`LogSqlSnippets.Content.sql`: `EditorPanel` now saves `unchecked_sql`,
and `MoveQueryModal` switches to the SQL-editor-specific
`getSqlSnippetById` so its content is typed as `SqlSnippets.Content` —
no narrowing or casting.

## Tests

- **content-remap**: `log_sql` remap/unmap round-trip with the logs
brand; the defensive-unmap no-op (prod) and dev throw.
- **content-upsert-mutation**: a `log_sql` payload reaches the wire as a
plain `content.sql` and the response remaps back to `unchecked_sql` (the
data-loss-critical round-trip shared by both explorer save-new and
`SavedQueriesItem` update).

## Verification

- `pnpm --filter studio run typecheck` ✓
- `pnpm --filter studio run lint:ratchet` ✓ (no new warnings)
- `pnpm test:studio` for `data/content` + `Settings/Logs` — 139 passing
✓
- Prettier ✓

Nothing is user-visible yet — logs snippet entry points arrive later in
the stack behind the `sqlEditorLogsSource` flag.

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

- **Bug Fixes**
- Improved handling of saved and recent log queries across the SQL
editor and Logs Explorer.
- Log SQL now uses `unchecked_sql` (branded as untrusted) consistently
when creating, editing, moving, and reopening queries, with correct
remapping to/from the API boundary.
- Fixed saved-query update payloads to preserve the right query content
and omit legacy fields.

- **Tests**
- Added/expanded Vitest coverage for saved log query editing, recent-log
normalization, and `log_sql` remap/upsert request/response behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-28 10:47:43 -04:00

179 lines
5.6 KiB
TypeScript

import { untrustedSql } from '@supabase/pg-meta'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { remapSqlContentField, remapSqlContentFields, unmapSqlContentField } from './content-remap'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
const SQL_SNIPPET = {
id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
type: 'sql' as const,
name: 'My query',
content: {
sql: 'SELECT 1',
content_id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
schema_version: '1.0',
},
}
const LOG_SQL_SNIPPET = {
id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22',
type: 'log_sql' as const,
name: 'My logs query',
content: {
sql: "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10",
content_id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22',
schema_version: '1',
},
}
afterEach(() => {
vi.unstubAllEnvs()
})
describe('remapSqlContentField', () => {
it('remaps content.sql to content.unchecked_sql for SQL snippets', () => {
const result = remapSqlContentField(SQL_SNIPPET)
expect(result.content).toEqual({
content_id: SQL_SNIPPET.content.content_id,
schema_version: '1.0',
unchecked_sql: untrustedSql('SELECT 1'),
})
expect(result.content).not.toHaveProperty('sql')
})
it('remaps content.sql to content.unchecked_sql for log_sql snippets using the logs brand', () => {
const result = remapSqlContentField(LOG_SQL_SNIPPET)
expect(result.content).toEqual({
content_id: LOG_SQL_SNIPPET.content.content_id,
schema_version: '1',
unchecked_sql: untrustedLogSql(LOG_SQL_SNIPPET.content.sql),
})
expect(result.content).not.toHaveProperty('sql')
})
it('leaves non-SQL content types unchanged', () => {
const report = { id: '1', type: 'report' as const, content: { foo: 'bar' } }
expect(remapSqlContentField(report)).toBe(report)
})
it('leaves SQL snippets without a content field unchanged', () => {
const snippet = { id: '1', type: 'sql' as const, name: 'No content' }
expect(remapSqlContentField(snippet)).toBe(snippet)
})
it('leaves SQL snippets whose content has no sql field unchanged', () => {
const snippet = {
id: '1',
type: 'sql' as const,
content: { content_id: '1', schema_version: '1.0', unchecked_sql: untrustedSql('x') },
}
expect(remapSqlContentField(snippet)).toBe(snippet)
})
})
describe('remapSqlContentFields', () => {
it('remaps every SQL snippet in a list', () => {
const items = [SQL_SNIPPET, { id: '2', type: 'report' as const }]
const result = remapSqlContentFields(items)
const remappedSql = result.find((item) => item.type === 'sql')
expect(remappedSql && 'content' in remappedSql && remappedSql.content).toMatchObject({
unchecked_sql: untrustedSql('SELECT 1'),
})
expect(result[1]).toBe(items[1])
})
})
describe('unmapSqlContentField', () => {
it('remaps content.unchecked_sql back to content.sql for the API', () => {
const snippet = remapSqlContentField(SQL_SNIPPET)
const result = unmapSqlContentField(snippet)
expect(result.content).toEqual({
content_id: SQL_SNIPPET.content.content_id,
schema_version: '1.0',
sql: untrustedSql('SELECT 1'),
})
expect(result.content).not.toHaveProperty('unchecked_sql')
})
it('remaps content.unchecked_sql back to content.sql for log_sql snippets', () => {
const snippet = remapSqlContentField(LOG_SQL_SNIPPET)
const result = unmapSqlContentField(snippet)
expect(result.content).toEqual({
content_id: LOG_SQL_SNIPPET.content.content_id,
schema_version: '1',
sql: LOG_SQL_SNIPPET.content.sql,
})
expect(result.content).not.toHaveProperty('unchecked_sql')
})
it('leaves non-SQL content types unchanged', () => {
const report = { id: '1', type: 'report' as const, content: { foo: 'bar' } }
expect(unmapSqlContentField(report)).toBe(report)
})
it('leaves content without a sql or unchecked_sql field unchanged (never fabricates sql)', () => {
const snippet = {
id: '1',
type: 'sql' as const,
content: { content_id: '1', schema_version: '1.0' },
}
const result = unmapSqlContentField(snippet)
expect(result).toBe(snippet)
expect(result.content).not.toHaveProperty('sql')
})
it('throws in development when content still carries a raw sql field (missed rename)', () => {
const snippet = {
id: '1',
type: 'log_sql' as const,
content: { content_id: '1', schema_version: '1', sql: 'select 1' },
}
expect(() => unmapSqlContentField(snippet)).toThrow(/not migrated/)
})
it('never clobbers a raw sql field in production (defensive no-op)', () => {
vi.stubEnv('NODE_ENV', 'production')
const snippet = {
id: '1',
type: 'log_sql' as const,
content: { content_id: '1', schema_version: '1', sql: 'select 1' },
}
const result = unmapSqlContentField(snippet)
// The pre-rename `{ sql }` shape is already the wire shape, so passing it through
// untouched is correct — and crucially avoids writing `sql: undefined`.
expect(result).toBe(snippet)
expect(result.content.sql).toBe('select 1')
})
})
describe('remap/unmap round-trip', () => {
it('returns the original content shape after remap then unmap for sql snippets', () => {
const result = unmapSqlContentField(remapSqlContentField(SQL_SNIPPET))
expect(result.content).toEqual(SQL_SNIPPET.content)
})
it('returns the original content shape after remap then unmap for log_sql snippets', () => {
const result = unmapSqlContentField(remapSqlContentField(LOG_SQL_SNIPPET))
expect(result.content).toEqual(LOG_SQL_SNIPPET.content)
})
})