mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
## Summary - Regenerates `packages/api-types` for the content endpoints now that the Platform API's `notebook` content type has landed (list/get/upsert `type` enums, plus `UpsertContentBody`'s notebook cell shape with `_id`/`y_series`). Unrelated schema drift from the same regen (Warehouse, SSO, notification exceptions, etc.) is excluded — only the content-endpoint hunks are applied. - Removes every local widening cast added while the API support was pending (`content-query.ts`, `content-infinite-query.ts`, `notebook-query.ts`, `notebook-upsert-mutation.ts`, `sql-folders-query.ts`). - What remains is scoped and renamed to match: draft ids (`generateDraftId`/`isDraftId`), used only for cells created client-side in the editor before their first save, dropped before they'd ever reach the backend as a fake `_id`. ## Test plan - [x] `pnpm typecheck` — clean - [x] `pnpm --filter studio test` — full suite passes (518 files / 5471 tests) - [x] `pnpm --filter studio run lint:ratchet` — no new warnings - [x] `pnpm format` / prettier — clean <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved notebook cell tracking during editing, reordering, insertion, and deletion. * Preserved existing cell identifiers while removing temporary draft identifiers before saving. * Improved chart configuration for selecting and displaying multiple Y-axis series. * Strengthened notebook validation and content persistence behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
314 lines
9.7 KiB
TypeScript
314 lines
9.7 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 MARKDOWN_CELL = {
|
|
_tag: 'markdown_cell' as const,
|
|
_id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
|
text: '# Signup funnel',
|
|
}
|
|
|
|
const DATABASE_CELL = {
|
|
_tag: 'database_cell' as const,
|
|
_id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22',
|
|
sql: 'select * from auth.users limit 100',
|
|
row_limit: 100,
|
|
}
|
|
|
|
const LOG_CELL = {
|
|
_tag: 'log_cell' as const,
|
|
_id: 'c2001199-1e2b-4ef8-bb6d-6bb9bd380a33',
|
|
sql: "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10",
|
|
time_range: {
|
|
_tag: 'relative_time_range' as const,
|
|
unit: 'hour' as const,
|
|
amount: 1,
|
|
},
|
|
}
|
|
|
|
const NOTEBOOK = {
|
|
id: 'd3aadd77-7c3c-4de7-aa5c-5aa8ac270b44',
|
|
type: 'notebook' as const,
|
|
name: 'Signup funnel',
|
|
content: {
|
|
schema_version: 1 as const,
|
|
cells: [MARKDOWN_CELL, DATABASE_CELL, LOG_CELL],
|
|
},
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
it('brands database_cell and log_cell sql per-cell for notebooks, leaving markdown_cell untouched', () => {
|
|
const result = remapSqlContentField(NOTEBOOK)
|
|
|
|
const [markdownCell, databaseCell, logCell] = result.content.cells
|
|
expect(markdownCell).toEqual({
|
|
_tag: 'markdown_cell',
|
|
_id: MARKDOWN_CELL._id,
|
|
text: MARKDOWN_CELL.text,
|
|
})
|
|
expect(databaseCell).toEqual({
|
|
_tag: 'database_cell',
|
|
_id: DATABASE_CELL._id,
|
|
row_limit: 100,
|
|
view: 'table',
|
|
unchecked_sql: untrustedSql(DATABASE_CELL.sql),
|
|
})
|
|
expect(databaseCell).not.toHaveProperty('sql')
|
|
expect(logCell).toEqual({
|
|
_tag: 'log_cell',
|
|
_id: LOG_CELL._id,
|
|
time_range: LOG_CELL.time_range,
|
|
view: 'table',
|
|
unchecked_sql: untrustedLogSql(LOG_CELL.sql),
|
|
})
|
|
expect(logCell).not.toHaveProperty('sql')
|
|
})
|
|
|
|
it('throws when a notebook cell is missing a required field', () => {
|
|
const invalidNotebook = {
|
|
id: '1',
|
|
type: 'notebook' as const,
|
|
content: {
|
|
schema_version: 1 as const,
|
|
cells: [{ _tag: 'database_cell' as const, _id: '1', sql: 'select 1' }],
|
|
},
|
|
}
|
|
|
|
expect(() => remapSqlContentField(invalidNotebook)).toThrow()
|
|
})
|
|
})
|
|
|
|
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('unmapSqlContentField (notebooks)', () => {
|
|
// Notebook content only ever reaches `unmapSqlContentField` via createNotebook/updateNotebook,
|
|
// which always hand it a WritableNotebook already keyed on the wire's `_id` — present for
|
|
// an existing cell, omitted for a new one.
|
|
it('passes a real _id through, and leaves a cell with none without one', () => {
|
|
const writableNotebook = {
|
|
id: 'e4bbee88-8d2b-4de7-aa5c-5aa8ac270b55',
|
|
type: 'notebook' as const,
|
|
content: {
|
|
schema_version: 1 as const,
|
|
cells: [
|
|
{ _tag: 'markdown_cell' as const, text: '# New cell' },
|
|
{
|
|
_tag: 'database_cell' as const,
|
|
_id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22',
|
|
sql: 'select * from auth.users limit 100',
|
|
row_limit: 100,
|
|
},
|
|
{
|
|
_tag: 'log_cell' as const,
|
|
sql: "select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10",
|
|
time_range: {
|
|
_tag: 'relative_time_range' as const,
|
|
unit: 'hour' as const,
|
|
amount: 1,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}
|
|
|
|
const result = unmapSqlContentField(writableNotebook)
|
|
|
|
const [markdownCell, databaseCell, logCell] = result.content.cells
|
|
expect(markdownCell).not.toHaveProperty('_id')
|
|
expect(databaseCell).toMatchObject({ _id: 'b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22' })
|
|
expect(logCell).not.toHaveProperty('_id')
|
|
})
|
|
|
|
it('drops a client-fabricated draft id rather than sending it back as _id', () => {
|
|
const writableNotebook = {
|
|
id: 'e4bbee88-8d2b-4de7-aa5c-5aa8ac270b55',
|
|
type: 'notebook' as const,
|
|
content: {
|
|
schema_version: 1 as const,
|
|
cells: [
|
|
{
|
|
_tag: 'markdown_cell' as const,
|
|
_id: 'draft-b1ffcd88-8d1a-4de7-aa5c-5aa8ac270b22',
|
|
text: '# New cell',
|
|
},
|
|
],
|
|
},
|
|
}
|
|
|
|
const result = unmapSqlContentField(writableNotebook)
|
|
|
|
expect(result.content.cells[0]).not.toHaveProperty('_id')
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|