Files
supabase/apps/studio/data/content/content-remap.ts
Charis 1296a1c745 feat(studio): notebook query and mutation hooks (#48907)
## Summary

Implements the "notebook query and mutation hooks" step of the notebooks
data layer:

- `data/content/notebooks/notebook-query.ts` —
`getNotebook`/`useNotebookQuery`, wrapping the existing `getContentById`
and narrowing to `type: 'notebook'`.
- `data/content/notebooks/notebooks-infinite-query.ts` —
`useNotebooksInfiniteQuery`, a typed wrapper over
`useContentInfiniteQuery` narrowing pages to notebook rows.
- `data/content/notebooks/notebook-upsert-mutation.ts` —
`createNotebook`/`updateNotebook` + their mutation hooks, PUTting
through the existing `upsertContent`.

Write-path correctness, worked out while building the mutation hooks:

- Cell `id`s are always backend-generated, never client-supplied — a
brand-new cell has no `id` at all; an existing cell being kept/edited in
an update keeps its real id so the backend can diff it against the
previous version. `notebook-schema.ts` gains
`writableCellSchema`/`writableNotebookSchema` (ids optional per cell)
and `WritableCell`/`WritableNotebook` types, derived from `z.infer` of
those schemas rather than hand-duplicated, with only the `sql` field
re-branded per cell type via a small distributive conditional type.
- Cell SQL at this write boundary must already be
`SafeSqlFragment`/`SafeLogSqlFragment` (proven user-authored at a
save/run event handler), not `unchecked_sql` — matching the
`safe-sql-execution` skill's provenance model.
- `content-remap.ts`'s notebook `unmapSqlContentField` branch is
simplified to a passthrough: notebook writes only ever arrive already
wire-shaped via `createNotebook`/`updateNotebook`, so there's nothing
left to unmap.

Note: this was originally stacked on
`feature/notebooks-types-convergence`, but that branch merged into
`master` (#48905) while this PR was in progress, so it's rebased
directly onto `master` now.

## Test plan

- [x] `pnpm --filter studio run typecheck` passes
- [x] `pnpm --filter studio exec vitest run data/content/notebooks
data/content/content-remap.test.ts` — 38/38 passing
- [x] `pnpm --filter studio exec eslint` clean on all touched files

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added notebook listing with pagination, filtering, sorting, and
project-specific queries.
  * Added notebook retrieval for viewing individual notebooks.
  * Added notebook creation and editing with automatic content refresh.
* Added support for preserving cell IDs and safely handling SQL content.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-10 15:32:46 -04:00

85 lines
4.3 KiB
TypeScript

// Remap `sql` → `unchecked_sql` on SQL snippet content objects as they cross the API boundary.
// The API stores and returns the field as `sql`; the frontend type uses `unchecked_sql` to make
// it explicit that this value must never be executed without user confirmation.
//
// Both database (`type: 'sql'`) and logs (`type: 'log_sql'`) snippets carry user-authored SQL
// under the same wire field, but each is branded with its own untrusted brand so Postgres SQL
// and logs SQL can never cross execution paths. Branding is per-type and never mixed.
//
// Notebooks (`type: 'notebook'`) carry the same kind of untrusted SQL, but nested per-cell
// rather than as a single top-level field, so they go through the dedicated notebook schemas
// (data/content/notebooks/notebook-schema.ts) instead of the single-field swap below.
import { untrustedSql } from '@supabase/pg-meta'
import { notebookDomainSchema } from './notebooks/notebook-schema'
import type { SnippetStatus } from './snippet-status'
import type { SnippetWithContent } from './sql-folders-query'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
function isSqlContentType(type: string): type is 'sql' | 'log_sql' {
return type === 'sql' || type === 'log_sql'
}
function isNotebookContentType(type: string): type is 'notebook' {
return type === 'notebook'
}
export function remapSqlContentField<T extends { type: string }>(item: T): T {
if (isNotebookContentType(item.type)) {
if (!('content' in item)) return item
const content = notebookDomainSchema.parse(item.content)
return { ...item, content } as T
}
if (!isSqlContentType(item.type)) return item
if (!('content' in item)) return item
const content = item.content as Record<string, unknown>
if (!('sql' in content)) return item
const { sql, ...rest } = content
const unchecked_sql =
item.type === 'log_sql' ? untrustedLogSql(sql as string) : untrustedSql(sql as string)
return { ...item, content: { ...rest, unchecked_sql } } as T
}
export function remapSqlContentFields<T extends { type: string }>(items: Array<T>): Array<T> {
return items.map(remapSqlContentField)
}
// Wire→domain boundary for a single SQL-editor snippet. The platform API types a
// content row's `content` as an opaque `{ [key: string]: unknown }` map (and its
// `type` as the full `'sql' | 'report' | 'log_sql'`), so turning a fetched row
// into a branded, discriminated `SnippetWithContent` needs exactly one assertion.
// Concentrating it here — the single place that already owns the sql↔unchecked_sql
// rename — keeps the query/mutation call sites free of `as unknown as` casts.
export function remapWireSnippet(row: unknown, status: SnippetStatus): SnippetWithContent {
const snippet = remapSqlContentField(row as SnippetWithContent)
return { ...snippet, status }
}
// Reverse remap: `unchecked_sql` → `sql` before sending to the API.
export function unmapSqlContentField<T extends { type: string }>(item: T): T {
if (isNotebookContentType(item.type)) {
// Notebook content only ever reaches this function via createNotebook/updateNotebook,
// which always hand it a WritableNotebook — plain `sql` per cell, `id` present only
// for existing cells. That's already wire-shaped, so there's nothing to unmap.
return item
}
if (!isSqlContentType(item.type)) return item
if (!('content' in item)) return item
const content = item.content as Record<string, unknown>
if (!('unchecked_sql' in content)) {
// Defensive guard against a writer that still submits the pre-rename `{ sql }` shape.
// Such a payload happens to reach the wire correctly (the API stores `sql`), but it
// means a save path was missed during the rename — surface it loudly in development.
// Crucially, we NEVER fabricate `sql: undefined` here: that would clobber the user's
// saved query text. The no-op below preserves whatever the content already holds.
if (process.env.NODE_ENV !== 'production' && 'sql' in content) {
throw new Error(
`unmapSqlContentField: ${item.type} content is missing 'unchecked_sql' but still carries a raw 'sql' field — a save path was not migrated to the branded field.`
)
}
return item
}
const { unchecked_sql, ...rest } = content
return { ...item, content: { ...rest, sql: unchecked_sql } } as T
}