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:
CharisandJoshen Lim authored and GitHub committed 2026-08-07 13:40:18 +07:00
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 -1
View File
@@ -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>
+19
View File
@@ -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()
})
})
+19
View File
@@ -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
}
+11
View File
@@ -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,
> {