diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.test.ts b/apps/studio/components/interfaces/SQLEditor/querySource.test.ts index d0e4659d0da..6c68f56e2db 100644 --- a/apps/studio/components/interfaces/SQLEditor/querySource.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/querySource.test.ts @@ -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' }]> = [ diff --git a/apps/studio/components/interfaces/SQLEditor/querySource.ts b/apps/studio/components/interfaces/SQLEditor/querySource.ts index 32e84bd78a3..4a1174f8375 100644 --- a/apps/studio/components/interfaces/SQLEditor/querySource.ts +++ b/apps/studio/components/interfaces/SQLEditor/querySource.ts @@ -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 diff --git a/apps/studio/data/content/content-query.ts b/apps/studio/data/content/content-query.ts index e65c862acbd..65ad09dc4ba 100644 --- a/apps/studio/data/content/content-query.ts +++ b/apps/studio/data/content/content-query.ts @@ -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 & ( diff --git a/apps/studio/data/content/notebooks/notebook-schema.test.ts b/apps/studio/data/content/notebooks/notebook-schema.test.ts new file mode 100644 index 00000000000..8f81fff51cf --- /dev/null +++ b/apps/studio/data/content/notebooks/notebook-schema.test.ts @@ -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') + }) +}) diff --git a/apps/studio/data/content/notebooks/notebook-schema.ts b/apps/studio/data/content/notebooks/notebook-schema.ts new file mode 100644 index 00000000000..e5ca0c796da --- /dev/null +++ b/apps/studio/data/content/notebooks/notebook-schema.ts @@ -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 +export type CellWire = z.infer + +// 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 +export type AgentCell = z.infer + +// 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 +export type Cell = z.infer +export type MarkdownCell = Extract +export type DatabaseCell = Extract +export type LogCell = Extract +export type TimeRange = z.infer +export type ChartConfig = z.infer diff --git a/apps/studio/lib/iso-datetime.test.ts b/apps/studio/lib/iso-datetime.test.ts new file mode 100644 index 00000000000..332782ad9f0 --- /dev/null +++ b/apps/studio/lib/iso-datetime.test.ts @@ -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() + }) +}) diff --git a/apps/studio/lib/iso-datetime.ts b/apps/studio/lib/iso-datetime.ts new file mode 100644 index 00000000000..9153213804d --- /dev/null +++ b/apps/studio/lib/iso-datetime.ts @@ -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 +} diff --git a/apps/studio/types/userContent.ts b/apps/studio/types/userContent.ts index 0910808bb82..e1206a77739 100644 --- a/apps/studio/types/userContent.ts +++ b/apps/studio/types/userContent.ts @@ -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, > {