Files
supabase/apps/studio/data/content/notebooks/notebook-schema.ts
T
CharisandJoshen Lim 0b97e37ccf 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>
2026-08-07 13:40:18 +07:00

122 lines
3.5 KiB
TypeScript

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>