mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
feat: notebook content schema (#48813)
Related to FE-4109. ## Summary - **API codegen workaround**: Platform API's `notebook` content type hasn't shipped to the OpenAPI spec yet, so `pnpm api:codegen` can't be run. Locally widened `ContentBase.type` to include `'notebook'` (marked with TODO for removal once spec publishes). - **Notebook schema & type system**: Introduced Zod schemas mirroring RFC-defined notebook shape (`schema_version: 1, cells: Cell[]`). Maintains wire/domain boundary (cell `sql` → `unchecked_sql` branded for security). Agent-writable schema for `create_notebook` tool omits cell IDs (backend-generated); future update operations will require them. All TypeScript types are `z.infer`'d from schemas (no hand-written parallel interfaces). - **IsoDateTimeString moved**: Extracted ISO datetime validator from `querySource.ts` to `lib/iso-datetime.ts` (data layer shouldn't import from components layer). Needed by notebook `time_range` fields. ## Test plan - [x] Unit tests: `notebook-schema.test.ts` (9 tests), `iso-datetime.test.ts` (3 tests), `querySource.test.ts` updated and passing (26 tests) - [x] Typecheck: no new errors - [x] Prettier: formatting clean <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added support for validating and processing notebook content, including markdown, database, log cells, time ranges, and chart configurations. - Added compatibility for notebook content types in content handling. - Added reliable ISO date-time validation for notebook data and related features. - **Tests** - Expanded coverage for valid and invalid notebook structures, cell requirements, time ranges, chart settings, and date-time values. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
1 parent
a515f3d81f
commit
0b97e37ccf
8 files changed
+339
-34
No files matched your search
@@ -6,7 +6,6 @@ import {
|
||||
DEFAULT_LOG_DATE_RANGE,
|
||||
getSnippetSource,
|
||||
isLogsSource,
|
||||
isoDateTimeString,
|
||||
logDateRangesEqual,
|
||||
logDateRangeToDatePickerValue,
|
||||
resolveLogRunRange,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
import { generateHelpersFromInput } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers'
|
||||
import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
|
||||
import type { DatetimeHelper } from '@/components/interfaces/Settings/Logs/Logs.types'
|
||||
import { isoDateTimeString } from '@/lib/iso-datetime'
|
||||
|
||||
/** Build the `DatePickerValue` the Logs picker submits when a helper is selected. */
|
||||
const valueFromHelper = (helper: DatetimeHelper): DatePickerValue => ({
|
||||
@@ -92,22 +92,6 @@ describe('querySource.ts:resolveSnippetSource', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('querySource.ts:isoDateTimeString', () => {
|
||||
it('accepts a valid ISO datetime', () => {
|
||||
const raw = '2025-01-01T12:00:00.000Z'
|
||||
expect(isoDateTimeString(raw)).toBe(raw)
|
||||
})
|
||||
|
||||
it('rejects an empty string', () => {
|
||||
expect(isoDateTimeString('')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects junk', () => {
|
||||
expect(isoDateTimeString('not-a-date')).toBeNull()
|
||||
expect(isoDateTimeString('2025-13-45T99:99:99Z')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('querySource.ts:datePickerValueToLogDateRange', () => {
|
||||
it('parses every static preset into a relative range', () => {
|
||||
const cases: Array<[string, { amount: number; unit: 'minute' | 'hour' | 'day' }]> = [
|
||||
|
||||
@@ -5,6 +5,7 @@ import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Log
|
||||
import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
|
||||
import type { ResolvedLogDateRange } from '@/components/interfaces/Settings/Logs/logsDateRange'
|
||||
import type { Snippet } from '@/data/content/sql-folders-query'
|
||||
import { isoDateTimeString, type IsoDateTimeString } from '@/lib/iso-datetime'
|
||||
|
||||
/**
|
||||
* Domain view of where a snippet's query runs. Derived from the content TYPE:
|
||||
@@ -60,22 +61,6 @@ export function resolveSnippetSource(
|
||||
return snippet !== undefined ? getSnippetSource(snippet) : parseSqlSnippetSource(sourceParam)
|
||||
}
|
||||
|
||||
/**
|
||||
* An ISO-8601 datetime proven valid at construction via a dayjs parse. Absolute
|
||||
* log ranges carry these instead of raw strings so an unvalidated datetime can
|
||||
* never reach execution.
|
||||
*/
|
||||
export type IsoDateTimeString = string & { readonly __isoDateTimeBrand: unique symbol }
|
||||
|
||||
/**
|
||||
* Validate a raw string as an ISO datetime, returning the branded value or null.
|
||||
* The sole construction site for `IsoDateTimeString` outside `now`.
|
||||
*/
|
||||
export function isoDateTimeString(raw: string): IsoDateTimeString | null {
|
||||
if (!raw) return null
|
||||
return dayjs(raw).isValid() ? (raw as IsoDateTimeString) : null
|
||||
}
|
||||
|
||||
/** `now` as a branded ISO datetime — `toISOString()` is always valid ISO-8601. */
|
||||
function nowIsoDateTime(): IsoDateTimeString {
|
||||
return dayjs().toISOString() as IsoDateTimeString
|
||||
|
||||
@@ -6,7 +6,12 @@ import { contentKeys } from './keys'
|
||||
import { get, handleError } from '@/data/fetchers'
|
||||
import type { Dashboards, LogSqlSnippets, SqlSnippets, UseCustomQueryOptions } from '@/types'
|
||||
|
||||
export type ContentBase = components['schemas']['GetUserContentResponse']['data'][number]
|
||||
// TODO — Charis 2026-08-06
|
||||
// Temporary widening until we have API support for notebooks
|
||||
export type ContentBase = Omit<
|
||||
components['schemas']['GetUserContentResponse']['data'][number],
|
||||
'type'
|
||||
> & { type: components['schemas']['GetUserContentResponse']['data'][number]['type'] | 'notebook' }
|
||||
|
||||
export type Content = Omit<ContentBase, 'content' | 'type'> &
|
||||
(
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { untrustedSql } from '@supabase/pg-meta'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { agentNotebookSchema, notebookDomainSchema, notebookSchema } from './notebook-schema'
|
||||
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
|
||||
|
||||
const FULL_NOTEBOOK = {
|
||||
schema_version: 1 as const,
|
||||
cells: [
|
||||
{
|
||||
_tag: 'markdown_cell' as const,
|
||||
id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
|
||||
text: '# Signup funnel',
|
||||
},
|
||||
{
|
||||
_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,
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('notebookSchema', () => {
|
||||
it('accepts a full three-cell notebook', () => {
|
||||
expect(notebookSchema.safeParse(FULL_NOTEBOOK).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an unknown cell _tag', () => {
|
||||
const result = notebookSchema.safeParse({
|
||||
schema_version: 1,
|
||||
cells: [{ _tag: 'chart_cell', id: '1', text: 'hi' }],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a database_cell missing row_limit', () => {
|
||||
const result = notebookSchema.safeParse({
|
||||
schema_version: 1,
|
||||
cells: [{ _tag: 'database_cell', id: '1', sql: 'select 1' }],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a non-ISO absolute_time_range bound', () => {
|
||||
const result = notebookSchema.safeParse({
|
||||
schema_version: 1,
|
||||
cells: [
|
||||
{
|
||||
_tag: 'log_cell',
|
||||
id: '1',
|
||||
sql: 'select 1',
|
||||
time_range: {
|
||||
_tag: 'absolute_time_range',
|
||||
start: 'not-a-real-date',
|
||||
end: '2024-01-02T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts an absolute_time_range with ISO8601 bounds', () => {
|
||||
const result = notebookSchema.safeParse({
|
||||
schema_version: 1,
|
||||
cells: [
|
||||
{
|
||||
_tag: 'log_cell',
|
||||
id: '1',
|
||||
sql: 'select 1',
|
||||
time_range: {
|
||||
_tag: 'absolute_time_range',
|
||||
start: '2024-01-01T00:00:00.000Z',
|
||||
end: '2024-01-02T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an invalid relative_time_range unit', () => {
|
||||
const result = notebookSchema.safeParse({
|
||||
schema_version: 1,
|
||||
cells: [
|
||||
{
|
||||
_tag: 'log_cell',
|
||||
id: '1',
|
||||
sql: 'select 1',
|
||||
time_range: { _tag: 'relative_time_range', unit: 'fortnight', amount: 1 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentNotebookSchema', () => {
|
||||
it('accepts cells without ids', () => {
|
||||
const result = agentNotebookSchema.safeParse({
|
||||
schema_version: 1,
|
||||
cells: [
|
||||
{ _tag: 'markdown_cell', text: 'hello' },
|
||||
{ _tag: 'database_cell', sql: 'select 1', row_limit: 100 },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects cells that carry an agent-supplied id', () => {
|
||||
const result = agentNotebookSchema.safeParse({
|
||||
schema_version: 1,
|
||||
cells: [{ _tag: 'markdown_cell', id: 'should-not-be-here', text: 'hello' }],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('notebookDomainSchema', () => {
|
||||
it('brands database_cell and log_cell sql as unchecked_sql, leaving markdown_cell untouched', () => {
|
||||
const result = notebookDomainSchema.safeParse(FULL_NOTEBOOK)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
|
||||
const [markdownCell, databaseCell, logCell] = result.data.cells
|
||||
expect(markdownCell).toEqual(FULL_NOTEBOOK.cells[0])
|
||||
expect(databaseCell).toEqual({
|
||||
_tag: 'database_cell',
|
||||
id: FULL_NOTEBOOK.cells[1].id,
|
||||
row_limit: 100,
|
||||
unchecked_sql: untrustedSql('select * from auth.users limit 100'),
|
||||
})
|
||||
expect(databaseCell).not.toHaveProperty('sql')
|
||||
expect(logCell).toMatchObject({
|
||||
_tag: 'log_cell',
|
||||
unchecked_sql: untrustedLogSql(
|
||||
"select timestamp, event_message from edge_logs where source = 'edge_logs' limit 10"
|
||||
),
|
||||
})
|
||||
expect(logCell).not.toHaveProperty('sql')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { untrustedSql } from '@supabase/pg-meta'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
|
||||
import { isoDateTimeString } from '@/lib/iso-datetime'
|
||||
|
||||
const isoDateTimeSchema = z.string().transform((raw, ctx) => {
|
||||
const parsed = isoDateTimeString(raw)
|
||||
if (parsed === null) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must be a valid ISO-8601 datetime' })
|
||||
return z.NEVER
|
||||
}
|
||||
return parsed
|
||||
})
|
||||
|
||||
const chartConfigSchema = z.object({
|
||||
x_column: z.string(),
|
||||
y_column: z.string(),
|
||||
cumulative: z.boolean(),
|
||||
})
|
||||
|
||||
const absoluteTimeRangeSchema = z.object({
|
||||
_tag: z.literal('absolute_time_range'),
|
||||
start: isoDateTimeSchema,
|
||||
end: isoDateTimeSchema,
|
||||
})
|
||||
|
||||
const relativeTimeRangeSchema = z.object({
|
||||
_tag: z.literal('relative_time_range'),
|
||||
unit: z.enum(['minute', 'hour', 'day', 'week', 'month', 'year']),
|
||||
amount: z.number(),
|
||||
})
|
||||
|
||||
const timeRangeSchema = z.discriminatedUnion('_tag', [
|
||||
absoluteTimeRangeSchema,
|
||||
relativeTimeRangeSchema,
|
||||
])
|
||||
|
||||
const markdownCellSchema = z.object({
|
||||
_tag: z.literal('markdown_cell'),
|
||||
id: z.string(),
|
||||
text: z.string(),
|
||||
})
|
||||
|
||||
const databaseCellSchema = z.object({
|
||||
_tag: z.literal('database_cell'),
|
||||
id: z.string(),
|
||||
sql: z.string(),
|
||||
row_limit: z.number(),
|
||||
chart: chartConfigSchema.optional(),
|
||||
})
|
||||
|
||||
const logCellSchema = z.object({
|
||||
_tag: z.literal('log_cell'),
|
||||
id: z.string(),
|
||||
sql: z.string(),
|
||||
time_range: timeRangeSchema,
|
||||
chart: chartConfigSchema.optional(),
|
||||
})
|
||||
|
||||
const cellSchema = z.discriminatedUnion('_tag', [
|
||||
markdownCellSchema,
|
||||
databaseCellSchema,
|
||||
logCellSchema,
|
||||
])
|
||||
|
||||
// The wire shape: every notebook fetched from the API has this shape, with backend-
|
||||
// generated cell `id`s and plaintext `sql`.
|
||||
export const notebookSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
cells: z.array(cellSchema),
|
||||
})
|
||||
|
||||
export type NotebookWire = z.infer<typeof notebookSchema>
|
||||
export type CellWire = z.infer<typeof cellSchema>
|
||||
|
||||
// Agents have restrictions on writing IDs to preserve guarantees about ID
|
||||
// uniqueness.
|
||||
export const agentCellSchema = z.discriminatedUnion('_tag', [
|
||||
markdownCellSchema.omit({ id: true }).strict(),
|
||||
databaseCellSchema.omit({ id: true }).strict(),
|
||||
logCellSchema.omit({ id: true }).strict(),
|
||||
])
|
||||
|
||||
export const agentNotebookSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
cells: z.array(agentCellSchema),
|
||||
})
|
||||
|
||||
export type AgentNotebook = z.infer<typeof agentNotebookSchema>
|
||||
export type AgentCell = z.infer<typeof agentCellSchema>
|
||||
|
||||
// The domain shape: parses the same wire cell (`cellSchema`) and transforms `sql` into a
|
||||
// branded `unchecked_sql`.
|
||||
const cellDomainSchema = cellSchema.transform((cell) => {
|
||||
switch (cell._tag) {
|
||||
case 'markdown_cell':
|
||||
return cell
|
||||
case 'database_cell': {
|
||||
const { sql, ...rest } = cell
|
||||
return { ...rest, unchecked_sql: untrustedSql(sql) }
|
||||
}
|
||||
case 'log_cell': {
|
||||
const { sql, ...rest } = cell
|
||||
return { ...rest, unchecked_sql: untrustedLogSql(sql) }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const notebookDomainSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
cells: z.array(cellDomainSchema),
|
||||
})
|
||||
|
||||
export type NotebookContent = z.infer<typeof notebookDomainSchema>
|
||||
export type Cell = z.infer<typeof cellDomainSchema>
|
||||
export type MarkdownCell = Extract<Cell, { _tag: 'markdown_cell' }>
|
||||
export type DatabaseCell = Extract<Cell, { _tag: 'database_cell' }>
|
||||
export type LogCell = Extract<Cell, { _tag: 'log_cell' }>
|
||||
export type TimeRange = z.infer<typeof timeRangeSchema>
|
||||
export type ChartConfig = z.infer<typeof chartConfigSchema>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isoDateTimeString } from './iso-datetime'
|
||||
|
||||
describe('isoDateTimeString', () => {
|
||||
it('accepts a valid ISO datetime', () => {
|
||||
const raw = '2025-01-01T12:00:00.000Z'
|
||||
expect(isoDateTimeString(raw)).toBe(raw)
|
||||
})
|
||||
|
||||
it('rejects an empty string', () => {
|
||||
expect(isoDateTimeString('')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects junk', () => {
|
||||
expect(isoDateTimeString('not-a-date')).toBeNull()
|
||||
expect(isoDateTimeString('2025-13-45T99:99:99Z')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
/**
|
||||
* An ISO-8601 datetime proven valid at construction via a dayjs parse. Callers that need to
|
||||
* carry a datetime through the type system without re-validating it (e.g. absolute log
|
||||
* ranges, notebook time ranges) use this instead of a raw string, so an unvalidated value
|
||||
* can never reach execution.
|
||||
*/
|
||||
export type IsoDateTimeString = string & { readonly __isoDateTimeBrand: unique symbol }
|
||||
|
||||
/**
|
||||
* Validate a raw string as an ISO datetime, returning the branded value or null. The sole
|
||||
* construction site for `IsoDateTimeString` besides a direct `toISOString()` call (which is
|
||||
* always valid ISO-8601 by construction).
|
||||
*/
|
||||
export function isoDateTimeString(raw: string): IsoDateTimeString | null {
|
||||
if (!raw) return null
|
||||
return dayjs(raw).isValid() ? (raw as IsoDateTimeString) : null
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { UntrustedSqlFragment } from '@supabase/pg-meta'
|
||||
|
||||
import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
|
||||
import type * as NotebookSchema from '@/data/content/notebooks/notebook-schema'
|
||||
import type { UntrustedLogSqlFragment } from '@/data/logs/safe-analytics-sql'
|
||||
|
||||
export namespace Notebooks {
|
||||
export type Content = NotebookSchema.NotebookContent
|
||||
export type Cell = NotebookSchema.Cell
|
||||
export type MarkdownCell = NotebookSchema.MarkdownCell
|
||||
export type DatabaseCell = NotebookSchema.DatabaseCell
|
||||
export type LogCell = NotebookSchema.LogCell
|
||||
export type TimeRange = NotebookSchema.TimeRange
|
||||
export type ChartConfig = NotebookSchema.ChartConfig
|
||||
}
|
||||
|
||||
export interface UserContent<
|
||||
T = Dashboards.Content | SqlSnippets.Content | LogSqlSnippets.Content,
|
||||
> {
|
||||
|
||||
Reference in new issue
Block a user