feat: SQL Notebooks for team runbooks (#157)

* feat(notebooks): add shared types for notebooks and cells

* feat(notebooks): add SQLite storage layer with full CRUD

Implements NotebookStorage class backed by better-sqlite3 with WAL mode,
foreign-key cascading deletes, and full CRUD for notebooks and cells.

* feat(notebooks): add IPC handlers for notebook CRUD

* feat(notebooks): add preload bridge for notebook IPC

* feat(notebooks): add NotebookTab type and createNotebookTab action

Adds 'notebook' to TabType union, NotebookTab interface, notebookId to
PersistedTab, createNotebookTab action with deduplication, persistence
handling, and notebook type guards in tab-query-editor.tsx.

* feat(notebooks): add Zustand store for notebook state management

* feat(notebooks): add NotebookEditor component and wire into TabContainer

* feat(notebooks): add sidebar section for browsing and creating notebooks

* feat(notebooks): add export functions for .dpnb and Markdown formats

* feat(notebooks): add NotebookCell component and dependencies

Add react-markdown and remark-gfm for markdown cell rendering.
NotebookCell handles SQL execution, markdown rendering, result
pinning, and keyboard shortcuts.

* fix: address review feedback for SQL notebooks

- Wrap JSON.parse in try/catch for corrupt pinned_result data
- Fix IPC type mismatches: update/duplicate/updateCell return actual data
- Remove non-functional Run All button from notebook editor
- Replace confirm() dialog with immediate delete + toast notification
- Remove dead notebook placeholder branch in tab-query-editor
- Remove duplicate react-markdown/remark-gfm from root package.json

* chore: lock files

* docs(notebooks): add feature docs, demo runbook, and nav entry

- SQL Notebooks feature documentation page for docs site
- Demo runbook .dpnb file for ACME SaaS health checks
- Add sql-notebooks to features navigation

* content(notebooks): add release notes, social posts, and 3 blog posts

- Release notes for v0.20.0 (SQL Notebooks)
- Social media posts (Twitter, Reddit, Dev.to)
- Blog 11: Feature announcement
- Blog 12: Storage architecture deep dive
- Blog 13: Lazy-loading Monaco in notebook cells

* content(notebooks): add Threads posts and posting strategy

4 Threads posts: launch carousel thread, technical behind-the-scenes,
runbook showcase, and conversation starter. Includes posting cadence
and format tips.

* docs(notebooks): add demo recording script

* feat(video): add release video and demo video for SQL Notebooks

- ReleaseVideo020: 24s release announcement (4 feature scenes)
- NotebookDemo: 45s feature walkthrough with animated notebook mockup
  showing cells typing in, queries executing, results sliding in,
  keyboard navigation, and export formats

* feat(video): add new background music for notebook videos

Screen Saver by Kevin MacLeod (CC BY 4.0) as primary track,
Equatorial Complex as alternative. Both compositions updated
to use the new track.

* fix: apply CodeRabbit auto-fixes

Fixed 5 file(s) based on 5 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
This commit is contained in:
Rohith Gilla 2026-04-14 19:07:13 +05:30 committed by GitHub
parent bdf4b709a4
commit c9f8e4297d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 6910 additions and 75 deletions

View file

@ -79,7 +79,9 @@
"papaparse": "^5.5.3",
"pg": "^8.16.3",
"react-grid-layout": "^2.1.0",
"react-markdown": "^10.1.0",
"recharts": "^3.5.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.6.10",
"ssh2": "^1.17.0",
"tailwind-merge": "^3.4.0",

View file

@ -0,0 +1,373 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import * as fs from 'fs'
import * as path from 'path'
import * as os from 'os'
vi.mock('electron-log/main', () => ({
default: {
initialize: vi.fn(),
transports: {
console: { level: 'debug' },
file: { level: 'debug', maxSize: 0, format: '' }
},
scope: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
})
}
}))
vi.mock('electron', () => ({
app: {
isPackaged: false
}
}))
import { NotebookStorage } from '../notebook-storage'
describe('NotebookStorage', () => {
let storage: NotebookStorage
let tmpDir: string
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notebook-storage-test-'))
storage = new NotebookStorage(tmpDir)
})
afterEach(() => {
storage.close()
fs.rmSync(tmpDir, { recursive: true, force: true })
})
describe('creates and lists notebooks', () => {
it('returns empty array when no notebooks exist', () => {
const notebooks = storage.listNotebooks()
expect(notebooks).toEqual([])
})
it('creates a notebook and lists it', () => {
const notebook = storage.createNotebook({
title: 'My Notebook',
connectionId: 'conn-1'
})
expect(notebook.id).toBeDefined()
expect(notebook.title).toBe('My Notebook')
expect(notebook.connectionId).toBe('conn-1')
expect(notebook.folder).toBeNull()
expect(notebook.createdAt).toBeGreaterThan(0)
expect(notebook.updatedAt).toBeGreaterThan(0)
const list = storage.listNotebooks()
expect(list).toHaveLength(1)
expect(list[0].id).toBe(notebook.id)
expect(list[0].title).toBe('My Notebook')
})
it('lists multiple notebooks ordered by updatedAt desc', () => {
const nb1 = storage.createNotebook({ title: 'First', connectionId: 'conn-1' })
const nb2 = storage.createNotebook({ title: 'Second', connectionId: 'conn-1' })
const nb3 = storage.createNotebook({ title: 'Third', connectionId: 'conn-1' })
const list = storage.listNotebooks()
expect(list).toHaveLength(3)
expect(list.map((n) => n.id)).toContain(nb1.id)
expect(list.map((n) => n.id)).toContain(nb2.id)
expect(list.map((n) => n.id)).toContain(nb3.id)
})
})
describe('creates notebook with folder', () => {
it('creates a notebook with a folder', () => {
const notebook = storage.createNotebook({
title: 'Foldered Notebook',
connectionId: 'conn-2',
folder: 'work/analytics'
})
expect(notebook.folder).toBe('work/analytics')
const list = storage.listNotebooks()
expect(list[0].folder).toBe('work/analytics')
})
})
describe('gets notebook with cells', () => {
it('returns null for non-existent notebook', () => {
const result = storage.getNotebook('nonexistent-id')
expect(result).toBeNull()
})
it('returns notebook with empty cells array', () => {
const notebook = storage.createNotebook({ title: 'Empty', connectionId: 'conn-1' })
const result = storage.getNotebook(notebook.id)
expect(result).not.toBeNull()
expect(result!.id).toBe(notebook.id)
expect(result!.title).toBe('Empty')
expect(result!.cells).toEqual([])
})
it('returns notebook with its cells', () => {
const notebook = storage.createNotebook({ title: 'With Cells', connectionId: 'conn-1' })
storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
storage.addCell(notebook.id, { type: 'markdown', content: '# Title', order: 1 })
const result = storage.getNotebook(notebook.id)
expect(result!.cells).toHaveLength(2)
expect(result!.cells[0].content).toBe('SELECT 1')
expect(result!.cells[0].type).toBe('sql')
expect(result!.cells[1].content).toBe('# Title')
expect(result!.cells[1].type).toBe('markdown')
})
})
describe('updates notebook title and folder', () => {
it('updates the title', () => {
const notebook = storage.createNotebook({ title: 'Old Title', connectionId: 'conn-1' })
const updated = storage.updateNotebook(notebook.id, { title: 'New Title' })
expect(updated).not.toBeNull()
expect(updated!.title).toBe('New Title')
expect(updated!.updatedAt).toBeGreaterThanOrEqual(notebook.updatedAt)
})
it('updates the folder', () => {
const notebook = storage.createNotebook({
title: 'NB',
connectionId: 'conn-1',
folder: 'old-folder'
})
const updated = storage.updateNotebook(notebook.id, { folder: 'new-folder' })
expect(updated!.folder).toBe('new-folder')
})
it('clears the folder when set to null', () => {
const notebook = storage.createNotebook({
title: 'NB',
connectionId: 'conn-1',
folder: 'some-folder'
})
const updated = storage.updateNotebook(notebook.id, { folder: null })
expect(updated!.folder).toBeNull()
})
it('returns null for non-existent notebook', () => {
const result = storage.updateNotebook('nonexistent', { title: 'X' })
expect(result).toBeNull()
})
})
describe('deletes notebook and cascades cells', () => {
it('deletes a notebook', () => {
const notebook = storage.createNotebook({ title: 'To Delete', connectionId: 'conn-1' })
storage.deleteNotebook(notebook.id)
const list = storage.listNotebooks()
expect(list).toHaveLength(0)
})
it('cascades deletion to cells', () => {
const notebook = storage.createNotebook({ title: 'Has Cells', connectionId: 'conn-1' })
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
storage.deleteNotebook(notebook.id)
// The cell should no longer exist; verify by recreating and checking no cells leak
const newNotebook = storage.createNotebook({ title: 'Fresh', connectionId: 'conn-1' })
const result = storage.getNotebook(newNotebook.id)
expect(result!.cells).toHaveLength(0)
// Also ensure the old notebook is gone
expect(storage.getNotebook(notebook.id)).toBeNull()
void cell // used above indirectly
})
})
describe('duplicates notebook to different connection', () => {
it('duplicates a notebook preserving title and cells', () => {
const original = storage.createNotebook({
title: 'Original',
connectionId: 'conn-1',
folder: 'myfolder'
})
storage.addCell(original.id, { type: 'sql', content: 'SELECT 1', order: 0 })
storage.addCell(original.id, { type: 'markdown', content: '# Note', order: 1 })
const duplicate = storage.duplicateNotebook(original.id, 'conn-2')
expect(duplicate).not.toBeNull()
expect(duplicate!.id).not.toBe(original.id)
expect(duplicate!.title).toBe('Original')
expect(duplicate!.connectionId).toBe('conn-2')
expect(duplicate!.folder).toBe('myfolder')
const dupWithCells = storage.getNotebook(duplicate!.id)
expect(dupWithCells!.cells).toHaveLength(2)
expect(dupWithCells!.cells[0].content).toBe('SELECT 1')
expect(dupWithCells!.cells[1].content).toBe('# Note')
// Duplicated cells should have new IDs
const originalWithCells = storage.getNotebook(original.id)
expect(dupWithCells!.cells[0].id).not.toBe(originalWithCells!.cells[0].id)
})
it('returns null when duplicating non-existent notebook', () => {
const result = storage.duplicateNotebook('nonexistent', 'conn-2')
expect(result).toBeNull()
})
})
describe('adds and retrieves cells in order', () => {
it('adds a cell and retrieves it', () => {
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT *', order: 0 })
expect(cell.id).toBeDefined()
expect(cell.notebookId).toBe(notebook.id)
expect(cell.type).toBe('sql')
expect(cell.content).toBe('SELECT *')
expect(cell.order).toBe(0)
expect(cell.pinnedResult).toBeNull()
expect(cell.createdAt).toBeGreaterThan(0)
expect(cell.updatedAt).toBeGreaterThan(0)
})
it('retrieves cells sorted by order', () => {
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
storage.addCell(notebook.id, { type: 'sql', content: 'C', order: 2 })
storage.addCell(notebook.id, { type: 'sql', content: 'A', order: 0 })
storage.addCell(notebook.id, { type: 'sql', content: 'B', order: 1 })
const result = storage.getNotebook(notebook.id)
expect(result!.cells.map((c) => c.content)).toEqual(['A', 'B', 'C'])
})
})
describe('updates cell content', () => {
it('updates the content of a cell', () => {
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
const updated = storage.updateCell(cell.id, { content: 'SELECT 2' })
expect(updated).not.toBeNull()
expect(updated!.content).toBe('SELECT 2')
expect(updated!.updatedAt).toBeGreaterThanOrEqual(cell.updatedAt)
})
it('returns null for non-existent cell', () => {
const result = storage.updateCell('nonexistent', { content: 'x' })
expect(result).toBeNull()
})
it('updates notebook updatedAt when cell content changes', () => {
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
storage.updateCell(cell.id, { content: 'SELECT 2' })
const updated = storage.getNotebook(notebook.id)
expect(updated!.updatedAt).toBeGreaterThanOrEqual(notebook.updatedAt)
})
})
describe('pins and unpins results', () => {
it('pins a result to a cell', () => {
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
const pinnedResult = {
columns: ['id', 'name'],
rows: [[1, 'Alice'], [2, 'Bob']],
rowCount: 2,
executedAt: Date.now(),
durationMs: 42,
error: null
}
const updated = storage.updateCell(cell.id, { pinnedResult })
expect(updated!.pinnedResult).not.toBeNull()
expect(updated!.pinnedResult!.columns).toEqual(['id', 'name'])
expect(updated!.pinnedResult!.rowCount).toBe(2)
expect(updated!.pinnedResult!.durationMs).toBe(42)
})
it('unpins a result from a cell', () => {
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
const pinnedResult = {
columns: ['id'],
rows: [[1]],
rowCount: 1,
executedAt: Date.now(),
durationMs: 10,
error: null
}
storage.updateCell(cell.id, { pinnedResult })
const unpinned = storage.updateCell(cell.id, { pinnedResult: null })
expect(unpinned!.pinnedResult).toBeNull()
})
it('persists pinned result across getNotebook calls', () => {
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
const pinnedResult = {
columns: ['val'],
rows: [[42]],
rowCount: 1,
executedAt: 1000,
durationMs: 5,
error: null
}
storage.updateCell(cell.id, { pinnedResult })
const fetched = storage.getNotebook(notebook.id)
expect(fetched!.cells[0].pinnedResult).toEqual(pinnedResult)
})
})
describe('deletes a cell', () => {
it('deletes a cell from a notebook', () => {
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 2', order: 1 })
storage.deleteCell(cell.id)
const result = storage.getNotebook(notebook.id)
expect(result!.cells).toHaveLength(1)
expect(result!.cells[0].content).toBe('SELECT 2')
})
})
describe('reorders cells', () => {
it('reorders cells in a notebook', () => {
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
const cell1 = storage.addCell(notebook.id, { type: 'sql', content: 'A', order: 0 })
const cell2 = storage.addCell(notebook.id, { type: 'sql', content: 'B', order: 1 })
const cell3 = storage.addCell(notebook.id, { type: 'sql', content: 'C', order: 2 })
storage.reorderCells(notebook.id, [cell3.id, cell1.id, cell2.id])
const result = storage.getNotebook(notebook.id)
expect(result!.cells.map((c) => c.content)).toEqual(['C', 'A', 'B'])
})
it('reorders cells and updates notebook updatedAt', () => {
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
const cell1 = storage.addCell(notebook.id, { type: 'sql', content: 'A', order: 0 })
const cell2 = storage.addCell(notebook.id, { type: 'sql', content: 'B', order: 1 })
storage.reorderCells(notebook.id, [cell2.id, cell1.id])
const result = storage.getNotebook(notebook.id)
expect(result!.updatedAt).toBeGreaterThanOrEqual(notebook.updatedAt)
})
})
})

View file

@ -11,6 +11,7 @@ import { initLicenseStore } from './license-service'
import { initAIStore } from './ai-service'
import { initAutoUpdater, stopPeriodicChecks } from './updater'
import { DpStorage } from './storage'
import { NotebookStorage } from './notebook-storage'
import { initSchemaCache } from './schema-cache'
import { registerAllHandlers } from './ipc'
import { setForceQuit } from './app-state'
@ -102,11 +103,12 @@ app.whenReady().then(async () => {
})
// Register all IPC handlers
const notebookStorage = new NotebookStorage(app.getPath('userData'))
registerAllHandlers({
connections: store,
savedQueries: savedQueriesStore,
snippets: snippetsStore
})
}, notebookStorage)
// Create initial window
await windowManager.createWindow()

View file

@ -1,5 +1,6 @@
import type { ConnectionConfig, SavedQuery, Snippet } from '@shared/index'
import type { DpStorage } from '../storage'
import type { NotebookStorage } from '../notebook-storage'
import { registerConnectionHandlers } from './connection-handlers'
import { registerQueryHandlers } from './query-handlers'
import { registerDDLHandlers } from './ddl-handlers'
@ -18,6 +19,7 @@ import { registerDataGenHandlers } from './data-gen-handlers'
import { registerPgNotifyHandlers } from './pg-notify-handlers'
import { registerHealthHandlers } from './health-handlers'
import { registerPgExportImportHandlers } from './pg-export-import-handlers'
import { registerNotebookHandlers } from './notebook-handlers'
const log = createLogger('ipc')
@ -32,7 +34,7 @@ export interface IpcStores {
*
* @param stores - Persistent stores required by handler categories; includes `connections` (connection configs) and `savedQueries` (saved query entries)
*/
export function registerAllHandlers(stores: IpcStores): void {
export function registerAllHandlers(stores: IpcStores, notebookStorage: NotebookStorage): void {
// Connection CRUD operations
registerConnectionHandlers(stores.connections)
@ -84,6 +86,9 @@ export function registerAllHandlers(stores: IpcStores): void {
// PostgreSQL export/import (pg_dump/pg_restore)
registerPgExportImportHandlers()
// SQL Notebooks
registerNotebookHandlers(notebookStorage)
log.debug('All handlers registered')
}
@ -102,3 +107,4 @@ export { registerDataGenHandlers } from './data-gen-handlers'
export { registerPgNotifyHandlers } from './pg-notify-handlers'
export { registerHealthHandlers } from './health-handlers'
export { registerPgExportImportHandlers } from './pg-export-import-handlers'
export { registerNotebookHandlers } from './notebook-handlers'

View file

@ -0,0 +1,124 @@
import { ipcMain } from 'electron'
import type { NotebookStorage } from '../notebook-storage'
import type { CreateNotebookInput, UpdateNotebookInput, AddCellInput, UpdateCellInput } from '@shared/index'
export function registerNotebookHandlers(storage: NotebookStorage): void {
ipcMain.handle('notebooks:list', () => {
try {
const data = storage.listNotebooks()
return { success: true, data }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMessage }
}
})
ipcMain.handle('notebooks:get', (_, id: string) => {
try {
const data = storage.getNotebook(id)
if (!data) return { success: false, error: 'Notebook not found' }
return { success: true, data }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMessage }
}
})
ipcMain.handle('notebooks:create', (_, input: CreateNotebookInput) => {
try {
const data = storage.createNotebook(input)
return { success: true, data }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMessage }
}
})
ipcMain.handle(
'notebooks:update',
(_, { id, updates }: { id: string; updates: UpdateNotebookInput }) => {
try {
const data = storage.updateNotebook(id, updates)
if (!data) return { success: false, error: 'Notebook not found' }
return { success: true, data }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMessage }
}
}
)
ipcMain.handle('notebooks:delete', (_, id: string) => {
try {
storage.deleteNotebook(id)
return { success: true }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMessage }
}
})
ipcMain.handle(
'notebooks:duplicate',
(_, { id, connectionId }: { id: string; connectionId: string }) => {
try {
const data = storage.duplicateNotebook(id, connectionId)
if (!data) return { success: false, error: 'Notebook not found' }
return { success: true, data }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMessage }
}
}
)
ipcMain.handle(
'notebooks:add-cell',
(_, { notebookId, input }: { notebookId: string; input: AddCellInput }) => {
try {
const data = storage.addCell(notebookId, input)
return { success: true, data }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMessage }
}
}
)
ipcMain.handle(
'notebooks:update-cell',
(_, { cellId, updates }: { cellId: string; updates: UpdateCellInput }) => {
try {
const data = storage.updateCell(cellId, updates)
if (!data) return { success: false, error: 'Cell not found' }
return { success: true, data }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMessage }
}
}
)
ipcMain.handle('notebooks:delete-cell', (_, cellId: string) => {
try {
storage.deleteCell(cellId)
return { success: true }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMessage }
}
})
ipcMain.handle(
'notebooks:reorder-cells',
(_, { notebookId, cellIds }: { notebookId: string; cellIds: string[] }) => {
try {
storage.reorderCells(notebookId, cellIds)
return { success: true }
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: errorMessage }
}
}
)
}

View file

@ -0,0 +1,287 @@
import { randomUUID } from 'crypto'
import { join } from 'path'
import Database from 'better-sqlite3'
import type {
Notebook,
NotebookCell,
NotebookWithCells,
PinnedResult,
CreateNotebookInput,
UpdateNotebookInput,
AddCellInput,
UpdateCellInput
} from '@shared/index'
import { createLogger } from './lib/logger'
const log = createLogger('notebook-storage')
interface NotebookRow {
id: string
title: string
connection_id: string
folder: string | null
created_at: number
updated_at: number
}
interface CellRow {
id: string
notebook_id: string
type: 'sql' | 'markdown'
content: string
pinned_result: string | null
order_index: number
created_at: number
updated_at: number
}
function rowToNotebook(row: NotebookRow): Notebook {
return {
id: row.id,
title: row.title,
connectionId: row.connection_id,
folder: row.folder,
createdAt: row.created_at,
updatedAt: row.updated_at
}
}
function parsePinnedResult(raw: string): PinnedResult | null {
try {
return JSON.parse(raw) as PinnedResult
} catch {
log.warn('Corrupt pinned_result JSON, falling back to null')
return null
}
}
function rowToCell(row: CellRow): NotebookCell {
return {
id: row.id,
notebookId: row.notebook_id,
type: row.type,
content: row.content,
pinnedResult: row.pinned_result ? parsePinnedResult(row.pinned_result) : null,
order: row.order_index,
createdAt: row.created_at,
updatedAt: row.updated_at
}
}
export class NotebookStorage {
private db: Database.Database
constructor(userDataPath: string) {
const dbPath = join(userDataPath, 'notebooks.db')
this.db = new Database(dbPath)
this.db.pragma('journal_mode = WAL')
this.db.pragma('foreign_keys = ON')
this.init()
log.info('NotebookStorage initialised', dbPath)
}
private init(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS notebooks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
connection_id TEXT NOT NULL,
folder TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS notebook_cells (
id TEXT PRIMARY KEY,
notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK(type IN ('sql', 'markdown')),
content TEXT NOT NULL DEFAULT '',
pinned_result TEXT,
order_index INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notebook_cells_notebook_id ON notebook_cells(notebook_id);
CREATE INDEX IF NOT EXISTS idx_notebooks_updated_at ON notebooks(updated_at DESC);
`)
}
private touchNotebook(id: string): void {
const now = Date.now()
this.db.prepare('UPDATE notebooks SET updated_at = ? WHERE id = ?').run(now, id)
}
listNotebooks(): Notebook[] {
const rows = this.db
.prepare('SELECT * FROM notebooks ORDER BY updated_at DESC')
.all() as NotebookRow[]
return rows.map(rowToNotebook)
}
getNotebook(id: string): NotebookWithCells | null {
const notebookRow = this.db.prepare('SELECT * FROM notebooks WHERE id = ?').get(id) as
| NotebookRow
| undefined
if (!notebookRow) return null
const cellRows = this.db
.prepare('SELECT * FROM notebook_cells WHERE notebook_id = ? ORDER BY order_index ASC')
.all(id) as CellRow[]
return {
...rowToNotebook(notebookRow),
cells: cellRows.map(rowToCell)
}
}
createNotebook(input: CreateNotebookInput): Notebook {
const now = Date.now()
const id = randomUUID()
this.db
.prepare(
'INSERT INTO notebooks (id, title, connection_id, folder, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
)
.run(id, input.title, input.connectionId, input.folder ?? null, now, now)
log.debug('Created notebook', id)
return rowToNotebook(
this.db.prepare('SELECT * FROM notebooks WHERE id = ?').get(id) as NotebookRow
)
}
updateNotebook(id: string, input: UpdateNotebookInput): Notebook | null {
const existing = this.db.prepare('SELECT * FROM notebooks WHERE id = ?').get(id) as
| NotebookRow
| undefined
if (!existing) return null
const now = Date.now()
const title = input.title !== undefined ? input.title : existing.title
const folder = input.folder !== undefined ? input.folder : existing.folder
this.db
.prepare('UPDATE notebooks SET title = ?, folder = ?, updated_at = ? WHERE id = ?')
.run(title, folder, now, id)
return rowToNotebook(
this.db.prepare('SELECT * FROM notebooks WHERE id = ?').get(id) as NotebookRow
)
}
deleteNotebook(id: string): void {
this.db.prepare('DELETE FROM notebooks WHERE id = ?').run(id)
log.debug('Deleted notebook', id)
}
duplicateNotebook(id: string, targetConnectionId: string): Notebook | null {
const original = this.getNotebook(id)
if (!original) return null
const duplicateInTransaction = this.db.transaction(() => {
const now = Date.now()
const newNotebookId = randomUUID()
this.db
.prepare(
'INSERT INTO notebooks (id, title, connection_id, folder, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
)
.run(newNotebookId, original.title, targetConnectionId, original.folder, now, now)
for (const cell of original.cells) {
const newCellId = randomUUID()
this.db
.prepare(
'INSERT INTO notebook_cells (id, notebook_id, type, content, pinned_result, order_index, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
)
.run(
newCellId,
newNotebookId,
cell.type,
cell.content,
cell.pinnedResult ? JSON.stringify(cell.pinnedResult) : null,
cell.order,
now,
now
)
}
return newNotebookId
})
const newId = duplicateInTransaction()
log.debug('Duplicated notebook', id, '->', newId)
return rowToNotebook(
this.db.prepare('SELECT * FROM notebooks WHERE id = ?').get(newId) as NotebookRow
)
}
addCell(notebookId: string, input: AddCellInput): NotebookCell {
const now = Date.now()
const id = randomUUID()
this.db
.prepare(
'INSERT INTO notebook_cells (id, notebook_id, type, content, pinned_result, order_index, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
)
.run(id, notebookId, input.type, input.content, null, input.order, now, now)
this.touchNotebook(notebookId)
log.debug('Added cell', id, 'to notebook', notebookId)
return rowToCell(
this.db.prepare('SELECT * FROM notebook_cells WHERE id = ?').get(id) as CellRow
)
}
updateCell(id: string, input: UpdateCellInput): NotebookCell | null {
const existing = this.db.prepare('SELECT * FROM notebook_cells WHERE id = ?').get(id) as
| CellRow
| undefined
if (!existing) return null
const now = Date.now()
const content = input.content !== undefined ? input.content : existing.content
const pinnedResult =
input.pinnedResult !== undefined
? input.pinnedResult === null
? null
: JSON.stringify(input.pinnedResult)
: existing.pinned_result
this.db
.prepare(
'UPDATE notebook_cells SET content = ?, pinned_result = ?, updated_at = ? WHERE id = ?'
)
.run(content, pinnedResult, now, id)
this.touchNotebook(existing.notebook_id)
return rowToCell(
this.db.prepare('SELECT * FROM notebook_cells WHERE id = ?').get(id) as CellRow
)
}
deleteCell(id: string): void {
const cell = this.db.prepare('SELECT * FROM notebook_cells WHERE id = ?').get(id) as
| CellRow
| undefined
if (!cell) return
this.db.prepare('DELETE FROM notebook_cells WHERE id = ?').run(id)
this.touchNotebook(cell.notebook_id)
log.debug('Deleted cell', id)
}
reorderCells(notebookId: string, orderedIds: string[]): void {
const reorderInTransaction = this.db.transaction(() => {
for (let i = 0; i < orderedIds.length; i++) {
this.db
.prepare('UPDATE notebook_cells SET order_index = ? WHERE id = ? AND notebook_id = ?')
.run(i, orderedIds[i], notebookId)
}
this.touchNotebook(notebookId)
})
reorderInTransaction()
log.debug('Reordered cells for notebook', notebookId)
}
close(): void {
this.db.close()
}
}

View file

@ -54,7 +54,14 @@ import type {
PgExportResult,
PgImportOptions,
PgImportProgress,
PgImportResult
PgImportResult,
Notebook,
NotebookWithCells,
NotebookCell,
CreateNotebookInput,
UpdateNotebookInput,
AddCellInput,
UpdateCellInput
} from '@shared/index'
// AI Types
@ -423,9 +430,7 @@ interface DataPeekApi {
onEvent: (callback: (event: PgNotificationEvent) => void) => () => void
onStatus: (callback: (status: PgNotificationConnectionStatus) => void) => () => void
reconnect: (connectionId: string) => Promise<IpcResponse<void>>
getStatus: (
connectionId: string
) => Promise<IpcResponse<PgNotificationConnectionStatus | null>>
getStatus: (connectionId: string) => Promise<IpcResponse<PgNotificationConnectionStatus | null>>
getAllStatuses: () => Promise<IpcResponse<PgNotificationConnectionStatus[]>>
}
health: {
@ -455,6 +460,18 @@ interface DataPeekApi {
cancelImport: () => Promise<IpcResponse<void>>
onImportProgress: (callback: (progress: PgImportProgress) => void) => () => void
}
notebooks: {
list: () => Promise<IpcResponse<Notebook[]>>
get: (id: string) => Promise<IpcResponse<NotebookWithCells>>
create: (input: CreateNotebookInput) => Promise<IpcResponse<Notebook>>
update: (id: string, updates: UpdateNotebookInput) => Promise<IpcResponse<Notebook>>
delete: (id: string) => Promise<IpcResponse<void>>
duplicate: (id: string, connectionId: string) => Promise<IpcResponse<Notebook>>
addCell: (notebookId: string, input: AddCellInput) => Promise<IpcResponse<NotebookCell>>
updateCell: (cellId: string, updates: UpdateCellInput) => Promise<IpcResponse<NotebookCell>>
deleteCell: (cellId: string) => Promise<IpcResponse<void>>
reorderCells: (notebookId: string, cellIds: string[]) => Promise<IpcResponse<void>>
}
files: {
openFilePicker: () => Promise<string | null>
}

View file

@ -63,7 +63,14 @@ import type {
PgExportResult,
PgImportOptions,
PgImportProgress,
PgImportResult
PgImportResult,
Notebook,
NotebookWithCells,
NotebookCell,
CreateNotebookInput,
UpdateNotebookInput,
AddCellInput,
UpdateCellInput
} from '@shared/index'
// Re-export AI types for renderer consumers
@ -310,6 +317,27 @@ const api = {
ipcRenderer.invoke('snippets:update', { id, updates }),
delete: (id: string): Promise<IpcResponse<void>> => ipcRenderer.invoke('snippets:delete', id)
},
// Notebooks management
notebooks: {
list: (): Promise<IpcResponse<Notebook[]>> => ipcRenderer.invoke('notebooks:list'),
get: (id: string): Promise<IpcResponse<NotebookWithCells>> =>
ipcRenderer.invoke('notebooks:get', id),
create: (input: CreateNotebookInput): Promise<IpcResponse<Notebook>> =>
ipcRenderer.invoke('notebooks:create', input),
update: (id: string, updates: UpdateNotebookInput): Promise<IpcResponse<Notebook>> =>
ipcRenderer.invoke('notebooks:update', { id, updates }),
delete: (id: string): Promise<IpcResponse<void>> => ipcRenderer.invoke('notebooks:delete', id),
duplicate: (id: string, connectionId: string): Promise<IpcResponse<Notebook>> =>
ipcRenderer.invoke('notebooks:duplicate', { id, connectionId }),
addCell: (notebookId: string, input: AddCellInput): Promise<IpcResponse<NotebookCell>> =>
ipcRenderer.invoke('notebooks:add-cell', { notebookId, input }),
updateCell: (cellId: string, updates: UpdateCellInput): Promise<IpcResponse<NotebookCell>> =>
ipcRenderer.invoke('notebooks:update-cell', { cellId, updates }),
deleteCell: (cellId: string): Promise<IpcResponse<void>> =>
ipcRenderer.invoke('notebooks:delete-cell', cellId),
reorderCells: (notebookId: string, cellIds: string[]): Promise<IpcResponse<void>> =>
ipcRenderer.invoke('notebooks:reorder-cells', { notebookId, cellIds })
},
// Scheduled queries management
scheduledQueries: {
list: (): Promise<IpcResponse<ScheduledQuery[]>> =>
@ -542,11 +570,8 @@ const api = {
ipcRenderer.on('pg-notify:event', handler)
return () => ipcRenderer.removeListener('pg-notify:event', handler)
},
onStatus: (
callback: (status: PgNotificationConnectionStatus) => void
): (() => void) => {
const handler = (_: unknown, status: PgNotificationConnectionStatus): void =>
callback(status)
onStatus: (callback: (status: PgNotificationConnectionStatus) => void): (() => void) => {
const handler = (_: unknown, status: PgNotificationConnectionStatus): void => callback(status)
ipcRenderer.on('pg-notify:status', handler)
return () => ipcRenderer.removeListener('pg-notify:status', handler)
},

View file

@ -10,6 +10,7 @@ import { SchemaExplorer } from '@/components/schema-explorer'
import { SidebarOmnibar } from '@/components/sidebar-omnibar'
import { SidebarQuickQuery } from '@/components/sidebar-quick-query'
import { Snippets } from '@/components/snippets'
import { NotebookSidebar } from '@/components/notebook-sidebar'
import { FunAnalytics } from '@/components/fun-analytics'
import { useConnectionStore, useTabStore } from '@/stores'
@ -74,6 +75,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<QueryHistory />
<SavedQueries />
<Snippets />
<NotebookSidebar />
<SidebarSeparator className="mx-3" />

View file

@ -0,0 +1,440 @@
import { useState, useCallback, useRef, useEffect, memo } from 'react'
import { Play, MoreHorizontal, GripVertical, Check, X, Pin, PinOff } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
cn
} from '@data-peek/ui'
import type { NotebookCell as CellType, PinnedResult } from '@shared/index'
import { useNotebookStore } from '@/stores/notebook-store'
import { useConnectionStore } from '@/stores/connection-store'
interface NotebookCellProps {
cell: CellType
connectionId: string
isFocused: boolean
onFocus: () => void
onRunAndAdvance: () => void
onDelete: () => void
}
interface QueryResult {
fields: { name: string }[]
rows: unknown[][]
}
const MAX_DISPLAY_ROWS = 100
function ResultTable({ result }: { result: QueryResult }) {
const columns = result.fields.map((f) => f.name)
const rows = result.rows.slice(0, MAX_DISPLAY_ROWS)
const isTruncated = result.rows.length > MAX_DISPLAY_ROWS
if (columns.length === 0) {
return <p className="text-xs text-muted-foreground py-2">Query executed successfully.</p>
}
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="border-b border-border/50">
{columns.map((col) => (
<th
key={col}
className="text-left px-2 py-1.5 font-medium text-muted-foreground whitespace-nowrap"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} className="border-b border-border/30 hover:bg-muted/20">
{(row as unknown[]).map((cell, ci) => (
<td key={ci} className="px-2 py-1 font-mono whitespace-nowrap max-w-[300px] truncate">
{cell === null || cell === undefined ? (
<span className="italic text-muted-foreground/50">null</span>
) : (
String(cell)
)}
</td>
))}
</tr>
))}
</tbody>
</table>
{isTruncated && (
<p className="text-[10px] text-muted-foreground px-2 py-1.5 border-t border-border/30">
Showing {MAX_DISPLAY_ROWS} of {result.rows.length} rows
</p>
)}
</div>
)
}
function StatusIcon({ status }: { status: 'idle' | 'running' | 'success' | 'error' }) {
if (status === 'idle') return null
if (status === 'running')
return <span className="size-2 rounded-full bg-primary animate-pulse shrink-0" />
if (status === 'success')
return <Check className="size-3 text-emerald-500 shrink-0" />
return <X className="size-3 text-destructive shrink-0" />
}
export const NotebookCell = memo(function NotebookCell({
cell,
connectionId,
isFocused,
onFocus,
onRunAndAdvance,
onDelete
}: NotebookCellProps) {
const [isEditing, setIsEditing] = useState(false)
const [status, setStatus] = useState<'idle' | 'running' | 'success' | 'error'>('idle')
const [liveResult, setLiveResult] = useState<QueryResult | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [durationMs, setDurationMs] = useState<number | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const updateCellContent = useNotebookStore((s) => s.updateCellContent)
const flushCellContent = useNotebookStore((s) => s.flushCellContent)
const pinResult = useNotebookStore((s) => s.pinResult)
const unpinResult = useNotebookStore((s) => s.unpinResult)
const connections = useConnectionStore((s) => s.connections)
const activeConnection = connections.find((c) => c.id === connectionId) ?? null
useEffect(() => {
if (isEditing && isFocused && textareaRef.current) {
textareaRef.current.focus()
}
}, [isEditing, isFocused])
useEffect(() => {
if (!isFocused) {
setIsEditing(false)
}
}, [isFocused])
const handleContentChange = useCallback(
(value: string) => {
updateCellContent(cell.id, value)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
flushCellContent(cell.id, value)
}, 500)
},
[cell.id, updateCellContent, flushCellContent]
)
const runQuery = useCallback(async () => {
if (!activeConnection || cell.type !== 'sql' || !cell.content.trim()) return
setStatus('running')
setLiveError(null)
const start = Date.now()
try {
const result = await window.api.db.query(activeConnection, cell.content)
const elapsed = Date.now() - start
setDurationMs(elapsed)
if (result.success && result.data) {
setLiveResult(result.data as QueryResult)
setStatus('success')
} else {
setLiveError(result.error ?? 'Query failed')
setStatus('error')
}
} catch (err) {
setDurationMs(Date.now() - start)
setLiveError(err instanceof Error ? err.message : 'Unknown error')
setStatus('error')
}
}, [activeConnection, cell.content, cell.type])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
e.preventDefault()
setIsEditing(false)
return
}
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault()
runQuery().then(() => onRunAndAdvance())
return
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
runQuery()
return
}
},
[runQuery, onRunAndAdvance]
)
const handlePinResult = useCallback(() => {
if (!liveResult) return
const pinned: PinnedResult = {
columns: liveResult.fields.map((f) => f.name),
rows: liveResult.rows,
rowCount: liveResult.rows.length,
executedAt: Date.now(),
durationMs: durationMs ?? 0,
error: null
}
pinResult(cell.id, pinned)
}, [cell.id, liveResult, durationMs, pinResult])
const handleUnpinResult = useCallback(() => {
unpinResult(cell.id)
}, [cell.id, unpinResult])
const pinnedResult = cell.pinnedResult
const hasPinnedResult = pinnedResult !== null && pinnedResult !== undefined
return (
<div
className={cn(
'group relative rounded-lg border transition-all duration-150',
'border-border/50 bg-card',
isFocused && 'border-primary/30 shadow-[0_0_0_1px_oklch(0.55_0.15_250/0.3)]'
)}
onClick={onFocus}
>
{status === 'running' && (
<div className="absolute top-0 left-0 right-0 h-0.5 rounded-t-lg overflow-hidden">
<div className="h-full bg-primary animate-[shimmer_1.5s_ease-in-out_infinite] w-1/3" />
</div>
)}
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/30">
<GripVertical
className={cn(
'size-3.5 text-muted-foreground/40 shrink-0 cursor-grab',
'opacity-0 group-hover:opacity-100 transition-opacity'
)}
/>
<span
className={cn(
'text-[10px] font-medium px-1.5 py-0.5 rounded font-mono uppercase tracking-wide',
cell.type === 'sql'
? 'text-primary bg-primary/10'
: 'text-muted-foreground bg-muted/50'
)}
>
{cell.type === 'sql' ? 'SQL' : 'MD'}
</span>
<StatusIcon status={status} />
{durationMs !== null && status !== 'running' && (
<span className="text-[10px] text-muted-foreground font-mono">
{durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(2)}s`}
</span>
)}
{liveResult && status === 'success' && (
<span className="text-[10px] text-muted-foreground font-mono">
{liveResult.rows.length} row{liveResult.rows.length !== 1 ? 's' : ''}
</span>
)}
<div className="flex-1" />
{cell.type === 'sql' && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
disabled={status === 'running' || !activeConnection}
onClick={(e) => {
e.stopPropagation()
runQuery()
}}
>
<Play className="size-3" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{cell.type === 'sql' && liveResult && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
handlePinResult()
}}
>
<Pin className="size-3.5 mr-2" />
Pin result
</DropdownMenuItem>
)}
{cell.type === 'sql' && hasPinnedResult && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
handleUnpinResult()
}}
>
<PinOff className="size-3.5 mr-2" />
Unpin result
</DropdownMenuItem>
)}
{cell.type === 'sql' && (liveResult || hasPinnedResult) && <DropdownMenuSeparator />}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<X className="size-3.5 mr-2" />
Delete cell
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="p-3">
{cell.type === 'sql' ? (
isEditing && isFocused ? (
<textarea
ref={textareaRef}
value={cell.content}
onChange={(e) => handleContentChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="SELECT * FROM ..."
className={cn(
'w-full min-h-[80px] resize-y font-mono text-sm bg-transparent outline-none',
'placeholder:text-muted-foreground/40 text-foreground'
)}
rows={Math.max(3, cell.content.split('\n').length)}
/>
) : (
<pre
className={cn(
'font-mono text-sm text-foreground whitespace-pre-wrap break-all cursor-text min-h-[1.5rem]',
!cell.content && 'text-muted-foreground/40 italic'
)}
onClick={(e) => {
e.stopPropagation()
onFocus()
setIsEditing(true)
}}
>
{cell.content || 'Click to edit SQL…'}
</pre>
)
) : isEditing && isFocused ? (
<textarea
ref={textareaRef}
value={cell.content}
onChange={(e) => handleContentChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
setIsEditing(false)
}
}}
placeholder="Write markdown here…"
className={cn(
'w-full min-h-[80px] resize-y text-sm bg-transparent outline-none',
'placeholder:text-muted-foreground/40 text-foreground'
)}
rows={Math.max(3, cell.content.split('\n').length)}
/>
) : (
<div
className={cn(
'prose prose-sm dark:prose-invert max-w-none cursor-text min-h-[1.5rem]',
!cell.content && 'text-muted-foreground/40 italic text-sm'
)}
onClick={(e) => {
e.stopPropagation()
onFocus()
setIsEditing(true)
}}
>
{cell.content ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{cell.content}</ReactMarkdown>
) : (
'Click to edit markdown…'
)}
</div>
)}
</div>
{cell.type === 'sql' && (
<div className="border-t border-border/30">
{liveError && (
<div className="px-3 py-2">
<p className="text-xs text-destructive font-mono">{liveError}</p>
</div>
)}
{!liveError && liveResult && (
<div className="px-1 py-1">
<ResultTable result={liveResult} />
</div>
)}
{!liveError && !liveResult && hasPinnedResult && (
<div>
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-border/20">
<Pin className="size-3 text-muted-foreground/60" />
<span className="text-[10px] text-muted-foreground">
Pinned ran {new Date(pinnedResult.executedAt).toLocaleString()}
{pinnedResult.durationMs
? ` · ${pinnedResult.durationMs < 1000 ? `${pinnedResult.durationMs}ms` : `${(pinnedResult.durationMs / 1000).toFixed(2)}s`}`
: ''}
</span>
</div>
<div className="px-1 py-1">
<ResultTable
result={{
fields: pinnedResult.columns.map((c) => ({ name: c })),
rows: pinnedResult.rows as unknown[][]
}}
/>
</div>
</div>
)}
{!liveError && !liveResult && !hasPinnedResult && status === 'idle' && (
<div className="px-3 py-2">
<p className="text-[10px] text-muted-foreground/50">
Not yet executed to run
</p>
</div>
)}
</div>
)}
</div>
)
})

View file

@ -0,0 +1,294 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Plus } from 'lucide-react'
import { Button, cn } from '@data-peek/ui'
import { useNotebookStore } from '@/stores/notebook-store'
import { useConnectionStore } from '@/stores/connection-store'
import { NotebookCell } from './notebook-cell'
import type { NotebookTab } from '@/stores/tab-store'
interface NotebookEditorProps {
tab: NotebookTab
}
function formatTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000)
if (seconds < 5) return 'just now'
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
return `${hours}h ago`
}
export function NotebookEditor({ tab }: NotebookEditorProps) {
const [focusedCellIndex, setFocusedCellIndex] = useState<number>(0)
const [isEditingTitle, setIsEditingTitle] = useState(false)
const [titleValue, setTitleValue] = useState('')
const titleInputRef = useRef<HTMLInputElement>(null)
const activeNotebook = useNotebookStore((s) => s.activeNotebook)
const isLoading = useNotebookStore((s) => s.isLoading)
const lastSavedAt = useNotebookStore((s) => s.lastSavedAt)
const loadNotebook = useNotebookStore((s) => s.loadNotebook)
const addCell = useNotebookStore((s) => s.addCell)
const deleteCell = useNotebookStore((s) => s.deleteCell)
const updateNotebook = useNotebookStore((s) => s.updateNotebook)
const connections = useConnectionStore((s) => s.connections)
const connectionId = tab.connectionId
const connection = connections.find((c) => c.id === connectionId) ?? null
useEffect(() => {
loadNotebook(tab.notebookId)
}, [tab.notebookId, loadNotebook])
useEffect(() => {
if (activeNotebook) {
setTitleValue(activeNotebook.title)
}
}, [activeNotebook?.title])
const cells = activeNotebook?.cells ?? []
const handleAddCell = useCallback(
(type: 'sql' | 'markdown', insertAfterIndex?: number) => {
if (!activeNotebook) return
const order =
insertAfterIndex !== undefined && cells[insertAfterIndex]
? cells[insertAfterIndex].order + 0.5
: cells.length
addCell(activeNotebook.id, { type, content: '', order })
setFocusedCellIndex(insertAfterIndex !== undefined ? insertAfterIndex + 1 : cells.length)
},
[activeNotebook, cells, addCell]
)
const handleDeleteCell = useCallback(
(cellId: string, index: number) => {
deleteCell(cellId)
setFocusedCellIndex((prev) => Math.max(0, prev > index ? prev - 1 : prev))
},
[deleteCell]
)
const handleTitleEdit = useCallback(() => {
if (activeNotebook) {
setTitleValue(activeNotebook.title)
setIsEditingTitle(true)
}
}, [activeNotebook])
const handleTitleSave = useCallback(() => {
if (activeNotebook && titleValue.trim()) {
updateNotebook(activeNotebook.id, { title: titleValue.trim() })
}
setIsEditingTitle(false)
}, [activeNotebook, titleValue, updateNotebook])
const handleTitleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleTitleSave()
}
if (e.key === 'Escape') {
e.preventDefault()
setIsEditingTitle(false)
}
},
[handleTitleSave]
)
useEffect(() => {
if (isEditingTitle && titleInputRef.current) {
titleInputRef.current.focus()
titleInputRef.current.select()
}
}, [isEditingTitle])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
if (e.key === 'j') {
e.preventDefault()
setFocusedCellIndex((prev) => Math.min(prev + 1, cells.length - 1))
} else if (e.key === 'k') {
e.preventDefault()
setFocusedCellIndex((prev) => Math.max(prev - 1, 0))
} else if (e.shiftKey && e.key === 'D') {
e.preventDefault()
const cell = cells[focusedCellIndex]
if (cell) {
handleDeleteCell(cell.id, focusedCellIndex)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [cells, focusedCellIndex, handleDeleteCell])
if (isLoading && !activeNotebook) {
return (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-muted-foreground">Loading notebook</p>
</div>
)
}
if (!activeNotebook) {
return (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-muted-foreground">Notebook not found.</p>
</div>
)
}
return (
<div className="flex flex-1 flex-col overflow-hidden relative">
<div className="sticky top-0 z-10 flex items-center gap-3 px-4 py-2 border-b border-border/50 bg-background/95 backdrop-blur-sm">
{isEditingTitle ? (
<input
ref={titleInputRef}
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={handleTitleSave}
onKeyDown={handleTitleKeyDown}
className="font-medium text-sm bg-transparent outline-none border-b border-primary/50 min-w-[120px] max-w-[300px]"
/>
) : (
<button
onClick={handleTitleEdit}
className="font-medium text-sm hover:text-foreground/80 transition-colors cursor-text"
>
{activeNotebook.title}
</button>
)}
{connection && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border border-border/50">
{connection.name}
</span>
)}
{lastSavedAt && (
<span className="text-[10px] text-muted-foreground">
Saved {formatTimeAgo(lastSavedAt)}
</span>
)}
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs"
onClick={() => handleAddCell('sql')}
>
<Plus className="size-3" />
SQL
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs"
onClick={() => handleAddCell('markdown')}
>
<Plus className="size-3" />
Note
</Button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 pb-16">
{cells.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<p className="text-sm text-muted-foreground">Empty notebook. Add your first cell.</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="gap-1.5 text-xs"
onClick={() => handleAddCell('sql')}
>
<Plus className="size-3" />
SQL cell
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5 text-xs"
onClick={() => handleAddCell('markdown')}
>
<Plus className="size-3" />
Note cell
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-1 max-w-4xl mx-auto">
<InsertPoint onInsert={(type) => handleAddCell(type, -1)} />
{cells.map((cell, index) => (
<div key={cell.id} className="flex flex-col">
<NotebookCell
cell={cell}
connectionId={connectionId ?? ''}
isFocused={focusedCellIndex === index}
onFocus={() => setFocusedCellIndex(index)}
onRunAndAdvance={() => setFocusedCellIndex(Math.min(index + 1, cells.length - 1))}
onDelete={() => handleDeleteCell(cell.id, index)}
/>
<InsertPoint onInsert={(type) => handleAddCell(type, index)} />
</div>
))}
</div>
)}
</div>
<div className="fixed bottom-0 left-0 right-0 flex items-center justify-center gap-4 px-4 py-1.5 border-t border-border/30 bg-background/80 backdrop-blur-sm text-[10px] text-muted-foreground/60 pointer-events-none z-20">
<span> Run &amp; advance</span>
<span> Run cell</span>
<span>J/K Navigate</span>
<span>Esc Exit editor</span>
</div>
</div>
)
}
interface InsertPointProps {
onInsert: (type: 'sql' | 'markdown') => void
}
function InsertPoint({ onInsert }: InsertPointProps) {
const [isHovered, setIsHovered] = useState(false)
return (
<div
className={cn(
'flex items-center gap-2 py-0.5 transition-all duration-150',
isHovered ? 'opacity-100' : 'opacity-0 hover:opacity-100'
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex-1 h-px bg-border/40" />
<div className="flex gap-1">
<button
onClick={() => onInsert('sql')}
className="text-[10px] text-muted-foreground/60 hover:text-foreground px-1.5 py-0.5 rounded hover:bg-muted/50 transition-colors font-mono"
>
+ SQL
</button>
<button
onClick={() => onInsert('markdown')}
className="text-[10px] text-muted-foreground/60 hover:text-foreground px-1.5 py-0.5 rounded hover:bg-muted/50 transition-colors font-mono"
>
+ Note
</button>
</div>
<div className="flex-1 h-px bg-border/40" />
</div>
)
}

View file

@ -0,0 +1,102 @@
import type { NotebookWithCells, PinnedResult } from '@shared/index'
export interface DpnbFile {
version: 1
title: string
folder: string | null
cells: DpnbCell[]
}
export interface DpnbCell {
type: 'sql' | 'markdown'
content: string
pinnedResult?: PinnedResult
}
const MAX_TABLE_ROWS = 50
export function renderResultAsMarkdownTable(result: PinnedResult): string {
const { columns, rows } = result
const displayRows = rows.slice(0, MAX_TABLE_ROWS)
const header = `| ${columns.join(' | ')} |`
const separator = `| ${columns.map(() => '---').join(' | ')} |`
const dataRows = displayRows.map((row) => {
const cells = columns.map((_, i) => {
const val = row[i]
return val === null ? '*null*' : String(val).replace(/\|/g, '\\|')
})
return `| ${cells.join(' | ')} |`
})
const lines = [header, separator, ...dataRows]
if (rows.length > MAX_TABLE_ROWS) {
lines.push('')
lines.push(`*${rows.length - MAX_TABLE_ROWS} more rows not shown (${rows.length} total)*`)
}
return lines.join('\n')
}
export function exportAsDpnb(notebook: NotebookWithCells): string {
const file: DpnbFile = {
version: 1,
title: notebook.title,
folder: notebook.folder,
cells: notebook.cells.map((cell) => {
const dpnbCell: DpnbCell = {
type: cell.type,
content: cell.content
}
if (cell.pinnedResult !== null) {
dpnbCell.pinnedResult = cell.pinnedResult
}
return dpnbCell
})
}
return JSON.stringify(file, null, 2)
}
export function exportAsMarkdown(notebook: NotebookWithCells): string {
const lines: string[] = [`# ${notebook.title}`]
for (const cell of notebook.cells) {
lines.push('')
if (cell.type === 'markdown') {
lines.push(cell.content)
} else {
lines.push('```sql')
lines.push(cell.content)
lines.push('```')
}
if (cell.pinnedResult !== null) {
lines.push('')
lines.push(renderResultAsMarkdownTable(cell.pinnedResult))
lines.push('')
const date = new Date(cell.pinnedResult.executedAt).toISOString()
lines.push(
`*Last run: ${date} (${cell.pinnedResult.durationMs}ms, ${cell.pinnedResult.rowCount} rows)*`
)
}
}
return lines.join('\n')
}
export function parseDpnb(json: string): DpnbFile | null {
try {
const parsed = JSON.parse(json)
if (parsed.version !== 1) return null
if (!Array.isArray(parsed.cells)) return null
if (typeof parsed.title !== 'string') return null
return parsed as DpnbFile
} catch {
return null
}
}

View file

@ -0,0 +1,195 @@
import { useState, useEffect } from 'react'
import { BookOpen, ChevronRight, Plus, MoreHorizontal, Trash2, FolderOpen } from 'lucide-react'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar
} from '@data-peek/ui'
import { useNotebookStore } from '@/stores/notebook-store'
import { useConnectionStore, useTabStore, notify } from '@/stores'
import type { Notebook } from '@shared/index'
export function NotebookSidebar() {
const { isMobile } = useSidebar()
const notebooks = useNotebookStore((s) => s.notebooks)
const isInitialized = useNotebookStore((s) => s.isInitialized)
const initialize = useNotebookStore((s) => s.initialize)
const createNotebook = useNotebookStore((s) => s.createNotebook)
const deleteNotebook = useNotebookStore((s) => s.deleteNotebook)
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const createNotebookTab = useTabStore((s) => s.createNotebookTab)
const [isExpanded, setIsExpanded] = useState(false)
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())
useEffect(() => {
if (!isInitialized) {
initialize()
}
}, [isInitialized, initialize])
const handleCreate = async () => {
if (!activeConnectionId) return
const nb = await createNotebook({
title: 'Untitled Notebook',
connectionId: activeConnectionId
})
if (nb) {
createNotebookTab(nb.connectionId, nb.id, nb.title)
}
}
const handleOpen = (nb: Notebook) => {
createNotebookTab(nb.connectionId, nb.id, nb.title)
}
const handleDelete = async (nb: Notebook) => {
await deleteNotebook(nb.id)
notify.success('Notebook deleted', `"${nb.title}" was removed.`)
}
const toggleFolder = (folder: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev)
if (next.has(folder)) {
next.delete(folder)
} else {
next.add(folder)
}
return next
})
}
const ungrouped = notebooks.filter((nb) => !nb.folder)
const folderMap = new Map<string, Notebook[]>()
for (const nb of notebooks) {
if (nb.folder) {
const existing = folderMap.get(nb.folder) ?? []
existing.push(nb)
folderMap.set(nb.folder, existing)
}
}
const folders = Array.from(folderMap.keys()).sort()
const renderNotebookItem = (nb: Notebook) => (
<SidebarMenuItem key={nb.id}>
<SidebarMenuButton onClick={() => handleOpen(nb)} className="h-auto py-1.5">
<BookOpen className="size-4 shrink-0" />
<span className="text-xs truncate">{nb.title}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontal />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align={isMobile ? 'end' : 'start'}
>
<DropdownMenuItem onClick={() => handleOpen(nb)}>
<BookOpen className="text-muted-foreground" />
<span>Open notebook</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-400" onClick={() => handleDelete(nb)}>
<Trash2 className="text-red-400" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)
return (
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="flex items-center">
<CollapsibleTrigger className="flex items-center gap-1 flex-1">
<ChevronRight
className={`size-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
/>
<span>Notebooks</span>
</CollapsibleTrigger>
<SidebarGroupAction
onClick={(e) => {
e.stopPropagation()
handleCreate()
}}
title="New notebook"
>
<Plus className="size-3.5" />
</SidebarGroupAction>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{notebooks.length === 0 ? (
<div className="px-2 py-4 text-xs text-muted-foreground text-center">
<button
onClick={handleCreate}
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
Create your first notebook
</button>
</div>
) : (
<>
{ungrouped.map(renderNotebookItem)}
{folders.map((folder) => {
const folderNotebooks = folderMap.get(folder) ?? []
const isFolderExpanded = expandedFolders.has(folder)
return (
<Collapsible
key={folder}
open={isFolderExpanded}
onOpenChange={() => toggleFolder(folder)}
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton className="h-auto py-1.5">
<FolderOpen className="size-4 shrink-0" />
<span className="text-xs truncate flex-1">{folder}</span>
<ChevronRight
className={`size-3 transition-transform ml-auto ${isFolderExpanded ? 'rotate-90' : ''}`}
/>
</SidebarMenuButton>
</CollapsibleTrigger>
</SidebarMenuItem>
<CollapsibleContent>
<SidebarMenu className="pl-4">
{folderNotebooks.map(renderNotebookItem)}
</SidebarMenu>
</CollapsibleContent>
</Collapsible>
)
})}
</>
)}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)
}

View file

@ -3,8 +3,10 @@ import { Plus } from 'lucide-react'
import { Button } from '@data-peek/ui'
import { TabBar } from '@/components/tab-bar'
import { TabQueryEditor } from '@/components/tab-query-editor'
import { NotebookEditor } from '@/components/notebook-editor'
import { useTabStore, useConnectionStore } from '@/stores'
import { useHotkeys, type UseHotkeyDefinition, type Hotkey } from '@tanstack/react-hotkeys'
import type { NotebookTab } from '@/stores/tab-store'
export function TabContainer() {
const tabs = useTabStore((s) => s.tabs)
@ -86,7 +88,11 @@ export function TabContainer() {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TabBar />
{activeTab && <TabQueryEditor key={activeTab.id} tabId={activeTab.id} />}
{activeTab && activeTab.type === 'notebook' ? (
<NotebookEditor key={activeTab.id} tab={activeTab as NotebookTab} />
) : (
activeTab && <TabQueryEditor key={activeTab.id} tabId={activeTab.id} />
)}
</div>
)
}

View file

@ -294,6 +294,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
currentTab.type === 'data-generator' ||
currentTab.type === 'pg-notifications' ||
currentTab.type === 'health-monitor' ||
currentTab.type === 'notebook' ||
!tabConnection ||
currentTab.isExecuting ||
!currentTab.query.trim()
@ -443,7 +444,8 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
tab.type === 'table-designer' ||
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor'
tab.type === 'health-monitor' ||
tab.type === 'notebook'
)
return
if (!tab.isExecuting || !tab.executionId) return
@ -489,6 +491,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor' ||
tab.type === 'notebook' ||
!tab.query.trim()
)
return
@ -506,6 +509,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor' ||
tab.type === 'notebook' ||
!tabConnection ||
tab.isExecuting ||
isRunningBenchmark ||
@ -561,6 +565,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor' ||
tab.type === 'notebook' ||
!tabConnection ||
isExplaining ||
!tab.query.trim()
@ -602,6 +607,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor' ||
tab.type === 'notebook' ||
!tabConnection ||
isPerfAnalyzing ||
!tab.query.trim()
@ -672,6 +678,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor' ||
tab.type === 'notebook' ||
!tab.result?.columns
)
return []
@ -722,6 +729,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor' ||
tab.type === 'notebook' ||
!tab.result?.columns ||
tab.type !== 'table-preview'
)
@ -901,7 +909,8 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
tab.type === 'table-designer' ||
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor'
tab.type === 'health-monitor' ||
tab.type === 'notebook'
)
return
@ -999,7 +1008,8 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
tab.type === 'table-designer' ||
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor'
tab.type === 'health-monitor' ||
tab.type === 'notebook'
)
return ''

View file

@ -19,3 +19,4 @@ export * from './pg-notification-store'
export * from './health-store'
export * from './pg-dump-store'
export * from './pokemon-buddy-store'
export * from './notebook-store'

View file

@ -0,0 +1,254 @@
import { create } from 'zustand'
import type {
Notebook,
NotebookWithCells,
CreateNotebookInput,
UpdateNotebookInput,
AddCellInput,
PinnedResult
} from '@shared/index'
interface NotebookState {
notebooks: Notebook[]
activeNotebook: NotebookWithCells | null
isLoading: boolean
isInitialized: boolean
lastSavedAt: number | null
initialize: () => Promise<void>
loadNotebook: (id: string) => Promise<void>
createNotebook: (input: CreateNotebookInput) => Promise<Notebook | null>
updateNotebook: (id: string, updates: UpdateNotebookInput) => Promise<void>
deleteNotebook: (id: string) => Promise<void>
duplicateNotebook: (id: string, connectionId: string) => Promise<void>
addCell: (notebookId: string, input: AddCellInput) => Promise<void>
updateCellContent: (cellId: string, content: string) => void
flushCellContent: (cellId: string, content: string) => Promise<void>
deleteCell: (cellId: string) => Promise<void>
reorderCells: (notebookId: string, cellIds: string[]) => Promise<void>
pinResult: (cellId: string, result: PinnedResult) => Promise<void>
unpinResult: (cellId: string) => Promise<void>
}
export const useNotebookStore = create<NotebookState>((set, get) => ({
notebooks: [],
activeNotebook: null,
isLoading: false,
isInitialized: false,
lastSavedAt: null,
initialize: async () => {
if (get().isInitialized) return
set({ isLoading: true })
try {
const result = await window.api.notebooks.list()
if (result.success && result.data) {
set({ notebooks: result.data, isLoading: false, isInitialized: true })
} else {
console.error('Failed to load notebooks:', result.error)
set({ isLoading: false, isInitialized: true })
}
} catch (error) {
console.error('Failed to initialize notebooks:', error)
set({ isLoading: false, isInitialized: true })
}
},
loadNotebook: async (id) => {
set({ isLoading: true })
try {
const result = await window.api.notebooks.get(id)
if (result.success && result.data) {
set({ activeNotebook: result.data, isLoading: false })
} else {
console.error('Failed to load notebook:', result.error)
set({ isLoading: false })
}
} catch (error) {
console.error('Failed to load notebook:', error)
set({ isLoading: false })
}
},
createNotebook: async (input) => {
try {
const result = await window.api.notebooks.create(input)
if (result.success && result.data) {
set((state) => ({ notebooks: [result.data!, ...state.notebooks] }))
return result.data
} else {
console.error('Failed to create notebook:', result.error)
return null
}
} catch (error) {
console.error('Failed to create notebook:', error)
return null
}
},
updateNotebook: async (id, updates) => {
try {
const result = await window.api.notebooks.update(id, updates)
if (result.success) {
set((state) => ({
notebooks: state.notebooks.map((n) => (n.id === id ? { ...n, ...updates } : n)),
activeNotebook:
state.activeNotebook?.id === id
? { ...state.activeNotebook, ...updates }
: state.activeNotebook
}))
} else {
console.error('Failed to update notebook:', result.error)
}
} catch (error) {
console.error('Failed to update notebook:', error)
}
},
deleteNotebook: async (id) => {
try {
const result = await window.api.notebooks.delete(id)
if (result.success) {
set((state) => ({
notebooks: state.notebooks.filter((n) => n.id !== id),
activeNotebook: state.activeNotebook?.id === id ? null : state.activeNotebook
}))
} else {
console.error('Failed to delete notebook:', result.error)
}
} catch (error) {
console.error('Failed to delete notebook:', error)
}
},
duplicateNotebook: async (id, connectionId) => {
try {
const result = await window.api.notebooks.duplicate(id, connectionId)
if (result.success && result.data) {
set((state) => ({ notebooks: [result.data!, ...state.notebooks] }))
} else {
console.error('Failed to duplicate notebook:', result.error)
}
} catch (error) {
console.error('Failed to duplicate notebook:', error)
}
},
addCell: async (notebookId, input) => {
try {
const result = await window.api.notebooks.addCell(notebookId, input)
if (result.success && result.data) {
set((state) => {
if (!state.activeNotebook || state.activeNotebook.id !== notebookId) return state
const cells = [...state.activeNotebook.cells, result.data!].sort(
(a, b) => a.order - b.order
)
return { activeNotebook: { ...state.activeNotebook, cells } }
})
} else {
console.error('Failed to add cell:', result.error)
}
} catch (error) {
console.error('Failed to add cell:', error)
}
},
updateCellContent: (cellId, content) => {
set((state) => {
if (!state.activeNotebook) return state
const cells = state.activeNotebook.cells.map((c) => (c.id === cellId ? { ...c, content } : c))
return { activeNotebook: { ...state.activeNotebook, cells } }
})
},
flushCellContent: async (cellId, content) => {
try {
const result = await window.api.notebooks.updateCell(cellId, { content })
if (result.success) {
set({ lastSavedAt: Date.now() })
} else {
console.error('Failed to flush cell content:', result.error)
}
} catch (error) {
console.error('Failed to flush cell content:', error)
}
},
deleteCell: async (cellId) => {
try {
const result = await window.api.notebooks.deleteCell(cellId)
if (result.success) {
set((state) => {
if (!state.activeNotebook) return state
const cells = state.activeNotebook.cells.filter((c) => c.id !== cellId)
return { activeNotebook: { ...state.activeNotebook, cells } }
})
} else {
console.error('Failed to delete cell:', result.error)
}
} catch (error) {
console.error('Failed to delete cell:', error)
}
},
reorderCells: async (notebookId, cellIds) => {
try {
const result = await window.api.notebooks.reorderCells(notebookId, cellIds)
if (result.success) {
set((state) => {
if (!state.activeNotebook || state.activeNotebook.id !== notebookId) return state
const cellMap = new Map(state.activeNotebook.cells.map((c) => [c.id, c]))
const cells = cellIds
.map((id) => cellMap.get(id))
.filter(Boolean) as typeof state.activeNotebook.cells
return { activeNotebook: { ...state.activeNotebook, cells } }
})
} else {
console.error('Failed to reorder cells:', result.error)
}
} catch (error) {
console.error('Failed to reorder cells:', error)
}
},
pinResult: async (cellId, result) => {
try {
const ipcResult = await window.api.notebooks.updateCell(cellId, { pinnedResult: result })
if (ipcResult.success) {
set((state) => {
if (!state.activeNotebook) return state
const cells = state.activeNotebook.cells.map((c) =>
c.id === cellId ? { ...c, pinnedResult: result } : c
)
return { activeNotebook: { ...state.activeNotebook, cells } }
})
} else {
console.error('Failed to pin result:', ipcResult.error)
}
} catch (error) {
console.error('Failed to pin result:', error)
}
},
unpinResult: async (cellId) => {
try {
const result = await window.api.notebooks.updateCell(cellId, { pinnedResult: null })
if (result.success) {
set((state) => {
if (!state.activeNotebook) return state
const cells = state.activeNotebook.cells.map((c) =>
c.id === cellId ? { ...c, pinnedResult: null } : c
)
return { activeNotebook: { ...state.activeNotebook, cells } }
})
} else {
console.error('Failed to unpin result:', result.error)
}
} catch (error) {
console.error('Failed to unpin result:', error)
}
}
}))

View file

@ -32,6 +32,7 @@ export type TabType =
| 'data-generator'
| 'pg-notifications'
| 'health-monitor'
| 'notebook'
// Base tab interface
interface BaseTab {
@ -109,6 +110,12 @@ export interface HealthMonitorTab extends BaseTab {
type: 'health-monitor'
}
// Notebook tab
export interface NotebookTab extends BaseTab {
type: 'notebook'
notebookId: string
}
export type Tab =
| QueryTab
| TablePreviewTab
@ -117,6 +124,7 @@ export type Tab =
| DataGeneratorTab
| PgNotificationsTab
| HealthMonitorTab
| NotebookTab
// Persisted tab data (minimal for storage)
interface PersistedTab {
@ -130,6 +138,7 @@ interface PersistedTab {
schemaName?: string
tableName?: string
mode?: 'create' | 'edit'
notebookId?: string
}
interface TabState {
@ -152,6 +161,7 @@ interface TabState {
createDataGeneratorTab: (connectionId: string, schemaName: string, tableName?: string) => string
createPgNotificationsTab: (connectionId: string) => string
createHealthMonitorTab: (connectionId: string) => string
createNotebookTab: (connectionId: string, notebookId: string, title: string) => string
closeTab: (tabId: string) => void
closeAllTabs: () => void
closeOtherTabs: (tabId: string) => void
@ -535,6 +545,33 @@ export const useTabStore = create<TabState>()(
return id
},
createNotebookTab: (connectionId, notebookId, title) => {
const existingTab = get().tabs.find(
(t) => t.type === 'notebook' && (t as NotebookTab).notebookId === notebookId
)
if (existingTab) {
set({ activeTabId: existingTab.id })
return existingTab.id
}
const id = crypto.randomUUID()
const tab: NotebookTab = {
id,
type: 'notebook',
title,
isPinned: false,
connectionId,
createdAt: Date.now(),
order: get().tabs.length,
notebookId
}
set((state) => ({
tabs: [...state.tabs, tab],
activeTabId: id
}))
return id
},
closeTab: (tabId) => {
const tab = get().tabs.find((t) => t.id === tabId)
if (!tab || tab.isPinned) return
@ -662,7 +699,8 @@ export const useTabStore = create<TabState>()(
t.type === 'table-designer' ||
t.type === 'data-generator' ||
t.type === 'pg-notifications' ||
t.type === 'health-monitor'
t.type === 'health-monitor' ||
t.type === 'notebook'
)
return t
// Only update executionId if provided, otherwise clear it when not executing
@ -682,7 +720,8 @@ export const useTabStore = create<TabState>()(
t.type !== 'table-designer' &&
t.type !== 'data-generator' &&
t.type !== 'pg-notifications' &&
t.type !== 'health-monitor'
t.type !== 'health-monitor' &&
t.type !== 'notebook'
? { ...t, savedQuery: t.query }
: t
)
@ -814,7 +853,8 @@ export const useTabStore = create<TabState>()(
tab.type === 'table-designer' ||
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor'
tab.type === 'health-monitor' ||
tab.type === 'notebook'
)
return false
return tab.query !== tab.savedQuery
@ -828,7 +868,8 @@ export const useTabStore = create<TabState>()(
tab.type === 'table-designer' ||
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor'
tab.type === 'health-monitor' ||
tab.type === 'notebook'
)
return []
if (!tab.result) return []
@ -844,7 +885,8 @@ export const useTabStore = create<TabState>()(
tab.type === 'table-designer' ||
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor'
tab.type === 'health-monitor' ||
tab.type === 'notebook'
)
return 0
if (!tab.result) return 0
@ -859,7 +901,8 @@ export const useTabStore = create<TabState>()(
tab.type === 'table-designer' ||
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor'
tab.type === 'health-monitor' ||
tab.type === 'notebook'
)
return undefined
if (!tab.multiResult?.statements) return undefined
@ -874,7 +917,8 @@ export const useTabStore = create<TabState>()(
tab.type === 'table-designer' ||
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor'
tab.type === 'health-monitor' ||
tab.type === 'notebook'
)
return []
return tab.multiResult?.statements ?? []
@ -888,7 +932,8 @@ export const useTabStore = create<TabState>()(
tab.type === 'table-designer' ||
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor'
tab.type === 'health-monitor' ||
tab.type === 'notebook'
)
return []
const statement = tab.multiResult?.statements?.[tab.activeResultIndex]
@ -905,7 +950,8 @@ export const useTabStore = create<TabState>()(
tab.type === 'table-designer' ||
tab.type === 'data-generator' ||
tab.type === 'pg-notifications' ||
tab.type === 'health-monitor'
tab.type === 'health-monitor' ||
tab.type === 'notebook'
)
return 0
const statement = tab.multiResult?.statements?.[tab.activeResultIndex]
@ -1009,6 +1055,13 @@ export const useTabStore = create<TabState>()(
return base
}
if (t.type === 'notebook') {
return {
...base,
notebookId: t.notebookId
}
}
// query or table-preview tabs
return {
...base,
@ -1075,6 +1128,17 @@ export const useTabStore = create<TabState>()(
} as HealthMonitorTab
}
// Notebook tabs
if (t.type === 'notebook') {
const persisted = t as unknown as PersistedTab
return {
...t,
type: 'notebook' as const,
createdAt: Date.now(),
notebookId: persisted.notebookId || ''
} as NotebookTab
}
const base = {
...t,
result: null,

View file

@ -11,6 +11,7 @@
"foreign-key-drilldown",
"saved-queries",
"snippets",
"sql-notebooks",
"command-palette",
"sidebar-omnibar",
"quick-query",

View file

@ -0,0 +1,90 @@
---
title: SQL Notebooks
description: Create Jupyter-style notebooks mixing executable SQL cells with Markdown documentation
---
# SQL Notebooks
SQL Notebooks let you mix executable SQL cells with Markdown documentation in a single document. The primary use case is team runbooks — documented, repeatable workflows that anyone on the team can open and run.
## Creating a Notebook
1. Click **Notebooks** in the sidebar
2. Click **New Notebook**
3. Give it a name and optionally assign a folder
4. Start adding cells
## Cell Types
### SQL Cells
Each SQL cell is an independent query editor. Run the cell to see results inline, directly beneath it. Results stay attached to the cell — no separate results panel.
### Markdown Cells
Markdown cells accept standard Markdown syntax. Use them to add context, explain steps, document expected output, or leave notes for teammates. They render in place when not being edited.
## Running Queries
| Action | Shortcut |
|--------|----------|
| Run cell and advance to next | `Shift+Enter` |
| Run cell (stay in cell) | `Cmd+Enter` |
| Run all cells top to bottom | **Run All** button in the toolbar |
## Pinning Results
Click the **pin icon** on a cell's result set to persist those results across sessions. Pinned results reload as-is the next time you open the notebook — useful for preserving a known baseline or sharing a snapshot alongside the query.
Unpin at any time to clear the stored result and run fresh.
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Shift+Enter` | Run cell and move to next |
| `Cmd+Enter` | Run cell |
| `Cmd+J` | Move to next cell |
| `Cmd+K` | Move to previous cell |
| `Enter` | Enter edit mode |
| `Escape` | Exit edit mode |
| `Cmd+Shift+D` | Delete current cell |
## Organizing with Folders
Notebooks support one level of folder organization. Assign a notebook to a folder when creating it, or rename/move it later via right-click in the sidebar.
Folders let you group notebooks by project, team, or workflow — for example: `Releases`, `Incident Response`, `Weekly Reports`.
## Exporting and Sharing
### .dpnb Format
Export a notebook as a `.dpnb` file — a JSON-based format that preserves all cells, markdown, and pinned results. Share the file with a teammate and they can import it directly into data-peek.
To export: open the notebook, click the **Export** button, and choose **Export as .dpnb**.
To import: drag a `.dpnb` file onto the sidebar, or use **File → Import Notebook**.
### Markdown Export
Export as Markdown to produce a readable document that works anywhere — GitHub, Notion, Confluence, or any text editor. SQL cells export as fenced code blocks; results export as Markdown tables.
To export: click **Export** and choose **Export as Markdown**.
## Duplicating to Another Connection
Reuse a runbook against a different environment without modifying the original:
1. Right-click the notebook in the sidebar
2. Select **Duplicate to Connection**
3. Choose the target connection (e.g. staging, production)
A copy of the notebook opens, connected to the target. Pinned results are not carried over — run the cells fresh against the new connection.
## Tips
- **Auto-save** runs 500ms after your last edit — no manual save needed.
- **Between-cell insertion**: hover between any two cells to reveal an **+** button that inserts a new cell at that position.
- Keep Markdown cells short and actionable — one paragraph per cell is easier to scan than a wall of text.
- Pin results before sharing a `.dpnb` so recipients see your last run alongside the queries.

Binary file not shown.

Binary file not shown.

View file

@ -3,11 +3,31 @@ import { LaunchVideo } from './compositions/LaunchVideo'
import { ReleaseVideo } from './compositions/ReleaseVideo'
import { ReleaseVideo018 } from './compositions/ReleaseVideo018'
import { ReleaseVideo019 } from './compositions/ReleaseVideo019'
import { ReleaseVideo020 } from './compositions/ReleaseVideo020'
import { NotebookDemo } from './compositions/NotebookDemo'
import './global.css'
export const RemotionRoot = () => {
return (
<>
<Composition
id="NotebookDemo"
component={NotebookDemo}
durationInFrames={1350}
fps={30}
width={1920}
height={1080}
defaultProps={{}}
/>
<Composition
id="ReleaseVideo-v0-20-0"
component={ReleaseVideo020}
durationInFrames={728}
fps={30}
width={1920}
height={1080}
defaultProps={{ version: '0.20.0' }}
/>
<Composition
id="ReleaseVideo-v0-19-0"
component={ReleaseVideo019}

View file

@ -0,0 +1,147 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Sequence,
} from 'remotion'
import { brand } from '../../lib/colors'
import { CyanGlow } from '../../components/CyanGlow'
import { BookOpen } from 'lucide-react'
export const EndScene: React.FC = () => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const fadeOut = interpolate(
frame,
[100, 120],
[1, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
)
const iconEntrance = spring({ frame, fps, config: { damping: 12, stiffness: 100 } })
const iconScale = interpolate(iconEntrance, [0, 1], [0, 1])
const iconRotate = interpolate(iconEntrance, [0, 1], [-180, 0])
const titleEntrance = spring({ frame: frame - 10, fps, config: { damping: 200 } })
const titleOpacity = interpolate(titleEntrance, [0, 1], [0, 1])
const titleY = interpolate(titleEntrance, [0, 1], [30, 0])
const versionEntrance = spring({ frame: frame - 25, fps, config: { damping: 200 } })
const versionOpacity = interpolate(versionEntrance, [0, 1], [0, 1])
const versionY = interpolate(versionEntrance, [0, 1], [20, 0])
const ctaEntrance = spring({ frame: frame - 45, fps, config: { damping: 200 } })
const ctaOpacity = interpolate(ctaEntrance, [0, 1], [0, 1])
const ctaY = interpolate(ctaEntrance, [0, 1], [20, 0])
const taglineEntrance = spring({ frame: frame - 60, fps, config: { damping: 200 } })
const taglineOpacity = interpolate(taglineEntrance, [0, 1], [0, 1])
return (
<AbsoluteFill
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 24,
opacity: fadeOut,
}}
>
<CyanGlow size={600} delay={0} />
<div
style={{
transform: `scale(${iconScale}) rotate(${iconRotate}deg)`,
marginBottom: 4,
}}
>
<BookOpen size={56} color={brand.accent} strokeWidth={1.5} />
</div>
<div
style={{
opacity: titleOpacity,
transform: `translateY(${titleY}px)`,
fontFamily: 'Geist Mono, monospace',
fontSize: 80,
fontWeight: 700,
color: brand.textPrimary,
letterSpacing: '-0.05em',
}}
>
SQL Notebooks
</div>
<div
style={{
opacity: versionOpacity,
transform: `translateY(${versionY}px)`,
fontFamily: 'Geist Mono, monospace',
fontSize: 22,
color: brand.textMuted,
letterSpacing: '-0.01em',
}}
>
in{' '}
<span style={{ color: brand.accent, fontWeight: 600 }}>
data-peek v0.20.0
</span>
</div>
<Sequence from={45} layout="none">
<div
style={{
opacity: ctaOpacity,
transform: `translateY(${ctaY}px)`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
marginTop: 12,
}}
>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 16,
color: brand.textMuted,
}}
>
Available now at
</div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 36,
fontWeight: 700,
color: brand.accent,
borderBottom: `2px solid ${brand.accent}60`,
paddingBottom: 4,
letterSpacing: '-0.02em',
}}
>
datapeek.dev
</div>
</div>
</Sequence>
<Sequence from={60} layout="none">
<div
style={{
opacity: taglineOpacity,
fontFamily: 'Geist Mono, monospace',
fontSize: 14,
color: brand.textMuted,
marginTop: 8,
}}
>
Fast. Honest. Modern devtool.
</div>
</Sequence>
</AbsoluteFill>
)
}

View file

@ -0,0 +1,305 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Sequence,
} from 'remotion'
import { brand } from '../../lib/colors'
import { CyanGlow } from '../../components/CyanGlow'
import { FileJson, FileText, ArrowRight } from 'lucide-react'
type FileCardProps = {
icon: React.FC<{ size: number; color: string; strokeWidth: number }>
extension: string
title: string
description: string
previewLines: string[]
accentColor: string
delay: number
}
const FileCard: React.FC<FileCardProps> = ({
icon: Icon,
extension,
title,
description,
previewLines,
accentColor,
delay,
}) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const entrance = spring({ frame: frame - delay, fps, config: { damping: 200 } })
const opacity = interpolate(entrance, [0, 1], [0, 1])
const translateY = interpolate(entrance, [0, 1], [40, 0])
const scale = interpolate(entrance, [0, 1], [0.9, 1])
return (
<div
style={{
opacity,
transform: `translateY(${translateY}px) scale(${scale})`,
width: 380,
backgroundColor: brand.surface,
border: `1px solid ${accentColor}40`,
borderRadius: 12,
overflow: 'hidden',
boxShadow: `0 0 40px ${accentColor}10`,
}}
>
<div
style={{
backgroundColor: `${accentColor}10`,
borderBottom: `1px solid ${accentColor}30`,
padding: '14px 18px',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: `${accentColor}20`,
border: `1px solid ${accentColor}40`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Icon size={20} color={accentColor} strokeWidth={1.5} />
</div>
<div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 16,
fontWeight: 700,
color: brand.textPrimary,
}}
>
{extension}
</div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: accentColor,
marginTop: 2,
}}
>
{title}
</div>
</div>
</div>
<div style={{ padding: '14px 18px' }}>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textSecondary,
marginBottom: 12,
lineHeight: 1.5,
}}
>
{description}
</div>
<div
style={{
backgroundColor: brand.surfaceElevated,
border: `1px solid ${brand.border}`,
borderRadius: 6,
padding: '10px 12px',
}}
>
{previewLines.map((line, i) => (
<div
key={i}
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 11,
color: i === 0 ? accentColor : brand.textMuted,
lineHeight: 1.6,
opacity: i === 0 ? 1 : 0.7 - i * 0.1,
}}
>
{line}
</div>
))}
</div>
</div>
</div>
)
}
export const ExportScene: React.FC = () => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const headerEntrance = spring({ frame, fps, config: { damping: 200 } })
const headerOpacity = interpolate(headerEntrance, [0, 1], [0, 1])
const headerY = interpolate(headerEntrance, [0, 1], [30, 0])
const arrowEntrance = spring({ frame: frame - 30, fps, config: { damping: 200 } })
const arrowOpacity = interpolate(arrowEntrance, [0, 1], [0, 1])
const notebookEntrance = spring({ frame, fps, config: { damping: 200 } })
const notebookOpacity = interpolate(notebookEntrance, [0, 1], [0, 1])
return (
<AbsoluteFill
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 48,
}}
>
<CyanGlow size={400} x="20%" y="70%" delay={0} />
<div
style={{
opacity: headerOpacity,
transform: `translateY(${headerY}px)`,
textAlign: 'center',
}}
>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 42,
fontWeight: 700,
color: brand.textPrimary,
letterSpacing: '-0.03em',
marginBottom: 10,
}}
>
Export anywhere
</div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 18,
color: brand.textMuted,
}}
>
Share as a reimportable notebook or readable Markdown
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 32,
}}
>
<div
style={{
opacity: notebookOpacity,
backgroundColor: brand.surface,
border: `1px solid ${brand.border}`,
borderRadius: 10,
padding: '14px 20px',
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: 8,
backgroundColor: `${brand.accent}15`,
border: `1px solid ${brand.accent}30`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
fontWeight: 700,
color: brand.accent,
}}
>
NB
</div>
<div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 14,
fontWeight: 600,
color: brand.textPrimary,
}}
>
Debug Payment Failures
</div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 11,
color: brand.textMuted,
marginTop: 2,
}}
>
3 cells · 1 pinned result
</div>
</div>
</div>
<div style={{ opacity: arrowOpacity }}>
<ArrowRight size={24} color={brand.textMuted} />
</div>
<div style={{ display: 'flex', gap: 20 }}>
<Sequence from={30} layout="none">
<FileCard
icon={FileJson}
extension=".dpnb"
title="Data Peek Notebook"
description="Reimportable notebook format. Preserves all cells, results, and pinned state."
previewLines={[
'{ "version": "1.0",',
' "cells": [',
' { "type": "md", ... },',
' { "type": "sql", ... }',
' ]',
'}',
]}
accentColor={brand.accent}
delay={0}
/>
</Sequence>
<Sequence from={50} layout="none">
<FileCard
icon={FileText}
extension=".md"
title="Markdown"
description="Human-readable export. Opens in any editor, renders on GitHub, shareable everywhere."
previewLines={[
'## Step 1: Check stuck payments',
'',
'```sql',
'SELECT id, amount, status',
'FROM payments',
'```',
]}
accentColor="#10b981"
delay={0}
/>
</Sequence>
</div>
</div>
</AbsoluteFill>
)
}

View file

@ -0,0 +1,343 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Sequence,
} from 'remotion'
import { brand } from '../../lib/colors'
import { CyanGlow } from '../../components/CyanGlow'
type ShortcutKeyProps = {
keys: string[]
description: string
delay: number
highlight?: boolean
}
const ShortcutKey: React.FC<ShortcutKeyProps> = ({ keys, description, delay, highlight }) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const entrance = spring({ frame: frame - delay, fps, config: { damping: 200 } })
const opacity = interpolate(entrance, [0, 1], [0, 1])
const translateY = interpolate(entrance, [0, 1], [20, 0])
return (
<div
style={{
opacity,
transform: `translateY(${translateY}px)`,
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '14px 24px',
backgroundColor: highlight ? `${brand.accent}10` : brand.surface,
border: `1px solid ${highlight ? brand.accent : brand.border}`,
borderRadius: 10,
minWidth: 480,
}}
>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{keys.map((key, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{i > 0 && (
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textMuted,
marginRight: 2,
}}
>
+
</span>
)}
<div
style={{
backgroundColor: brand.surfaceElevated,
border: `1px solid ${brand.border}`,
borderBottom: `3px solid ${brand.border}`,
borderRadius: 6,
padding: '4px 10px',
fontFamily: 'Geist Mono, monospace',
fontSize: 14,
fontWeight: 600,
color: highlight ? brand.accent : brand.textPrimary,
minWidth: 32,
textAlign: 'center',
lineHeight: 1.2,
}}
>
{key}
</div>
</div>
))}
</div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 16,
color: highlight ? brand.textPrimary : brand.textSecondary,
fontWeight: highlight ? 500 : 400,
}}
>
{description}
</div>
</div>
)
}
type FocusedCellProps = {
focusOffset: number
label: string
isFocused: boolean
}
const FocusedCell: React.FC<FocusedCellProps> = ({ focusOffset, label, isFocused }) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const entrance = spring({ frame, fps, config: { damping: 200 } })
const opacity = interpolate(entrance, [0, 1], [0, 1])
const ringPulse = interpolate(
(frame % 60) / 60,
[0, 0.5, 1],
[0.6, 1, 0.6]
)
return (
<div
style={{
opacity,
position: 'relative',
transform: `translateY(${focusOffset}px)`,
transition: 'transform 0.3s',
width: 600,
}}
>
{isFocused && (
<div
style={{
position: 'absolute',
inset: -2,
borderRadius: 10,
border: `2px solid ${brand.accent}`,
opacity: ringPulse,
pointerEvents: 'none',
boxShadow: `0 0 20px ${brand.accent}40`,
}}
/>
)}
<div
style={{
backgroundColor: brand.surface,
border: `1px solid ${brand.border}`,
borderRadius: 8,
overflow: 'hidden',
}}
>
<div
style={{
backgroundColor: brand.surfaceElevated,
borderBottom: `1px solid ${brand.border}`,
padding: '5px 12px',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<div
style={{
color: brand.accent,
backgroundColor: `${brand.accent}15`,
fontSize: 10,
fontWeight: 700,
fontFamily: 'Geist Mono, monospace',
padding: '2px 8px',
borderRadius: 4,
letterSpacing: '0.05em',
}}
>
SQL
</div>
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 11,
color: brand.textMuted,
}}
>
{label}
</span>
</div>
<div style={{ padding: '10px 16px' }}>
<pre
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
lineHeight: 1.6,
margin: 0,
color: brand.textSecondary,
}}
>
<span style={{ color: '#a855f7' }}>SELECT</span>
<span style={{ color: brand.textSecondary }}> * </span>
<span style={{ color: '#a855f7' }}>FROM</span>
<span style={{ color: '#22d3ee' }}> {label === 'Cell 1' ? 'payments' : label === 'Cell 2' ? 'gateway_logs' : 'refunds'}</span>
</pre>
</div>
</div>
</div>
)
}
export const KeyboardScene: React.FC = () => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const focusOffset = (() => {
if (frame < 40) return 0
if (frame < 80) {
const t = spring({ frame: frame - 40, fps, config: { damping: 200 } })
return interpolate(t, [0, 1], [0, 80])
}
if (frame < 120) return 80
if (frame < 160) {
const t = spring({ frame: frame - 120, fps, config: { damping: 200 } })
return interpolate(t, [0, 1], [80, 0])
}
return 0
})()
const focusedCellIndex = (() => {
if (frame < 40) return 0
if (frame < 120) return 1
return 0
})()
const headerEntrance = spring({ frame, fps, config: { damping: 200 } })
const headerOpacity = interpolate(headerEntrance, [0, 1], [0, 1])
const headerY = interpolate(headerEntrance, [0, 1], [30, 0])
return (
<AbsoluteFill
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 40,
}}
>
<CyanGlow size={400} x="80%" y="20%" delay={0} />
<div
style={{
opacity: headerOpacity,
transform: `translateY(${headerY}px)`,
fontFamily: 'Geist Mono, monospace',
fontSize: 42,
fontWeight: 700,
color: brand.textPrimary,
letterSpacing: '-0.03em',
marginBottom: 8,
}}
>
Keyboard-first navigation
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 80 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Sequence from={10} layout="none">
<ShortcutKey
keys={['⇧', '⏎']}
description="Run & advance to next cell"
delay={0}
highlight
/>
</Sequence>
<Sequence from={10} layout="none">
<ShortcutKey
keys={['⌘', '⏎']}
description="Run cell in place"
delay={15}
/>
</Sequence>
<Sequence from={10} layout="none">
<ShortcutKey
keys={['⌘', 'J']}
description="Move focus down"
delay={30}
/>
</Sequence>
<Sequence from={10} layout="none">
<ShortcutKey
keys={['⌘', 'K']}
description="Move focus up"
delay={45}
/>
</Sequence>
<Sequence from={10} layout="none">
<ShortcutKey
keys={['⌘', 'P']}
description="Pin / unpin results"
delay={60}
/>
</Sequence>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 12,
position: 'relative',
}}
>
<Sequence from={5} layout="none">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<FocusedCell focusOffset={-focusOffset} label="Cell 1" isFocused={focusedCellIndex === 0} />
<FocusedCell focusOffset={-focusOffset + 80} label="Cell 2" isFocused={focusedCellIndex === 1} />
</div>
</Sequence>
{frame >= 40 && frame < 80 && (
<div
style={{
position: 'absolute',
bottom: -40,
left: '50%',
transform: 'translateX(-50%)',
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.accent,
opacity: interpolate(frame, [40, 50], [0, 1], { extrapolateRight: 'clamp' }),
}}
>
+J focus moves down
</div>
)}
{frame >= 120 && frame < 160 && (
<div
style={{
position: 'absolute',
bottom: -40,
left: '50%',
transform: 'translateX(-50%)',
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.accent,
opacity: interpolate(frame, [120, 130], [0, 1], { extrapolateRight: 'clamp' }),
}}
>
+K focus moves up
</div>
)}
</div>
</div>
</AbsoluteFill>
)
}

View file

@ -0,0 +1,622 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Sequence,
} from 'remotion'
import { brand } from '../../lib/colors'
import { Play, Pin, ChevronDown } from 'lucide-react'
const SQL_TEXT = `SELECT id, amount, status
FROM payments
WHERE status = 'processing'
AND updated_at < NOW() - interval '30 mins'`
const GATEWAY_SQL = `SELECT id, gateway, error_code
FROM gateway_logs
WHERE created_at > NOW() - interval '1h'`
type SyntaxToken = { text: string; color: string }
function tokenizeSql(sql: string): SyntaxToken[] {
const keywords = ['SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'ON', 'AS', 'interval']
const tableNames = ['payments', 'gateway_logs']
const tokens: SyntaxToken[] = []
const parts = sql.split(/(\s+|,|'[^']*'|\bNOW\(\)|\b\w+\b)/)
for (const part of parts) {
if (!part) continue
const isKeyword = keywords.includes(part.toUpperCase()) || keywords.includes(part)
const isTable = tableNames.includes(part)
const isString = part.startsWith("'") && part.endsWith("'")
const isFunction = /^\w+\(\)$/.test(part)
const isOperator = ['>', '<', '=', '>=', '<=', '!=', '-'].includes(part)
const isNumber = /^\d+$/.test(part)
if (isKeyword) {
tokens.push({ text: part, color: '#a855f7' })
} else if (isTable) {
tokens.push({ text: part, color: '#22d3ee' })
} else if (isString) {
tokens.push({ text: part, color: '#fbbf24' })
} else if (isFunction) {
tokens.push({ text: part, color: '#22d3ee' })
} else if (isOperator) {
tokens.push({ text: part, color: brand.textMuted })
} else if (isNumber) {
tokens.push({ text: part, color: '#fbbf24' })
} else {
tokens.push({ text: part, color: brand.textSecondary })
}
}
return tokens
}
function renderSqlWithHighlighting(sql: string, visibleChars: number): React.ReactNode {
const visibleText = sql.slice(0, visibleChars)
const tokens = tokenizeSql(visibleText)
return (
<span>
{tokens.map((token, i) => (
<span key={i} style={{ color: token.color }}>
{token.text}
</span>
))}
</span>
)
}
type CellBadgeProps = {
type: 'SQL' | 'MD'
}
const CellBadge: React.FC<CellBadgeProps> = ({ type }) => (
<div
style={{
color: type === 'SQL' ? brand.accent : brand.textMuted,
backgroundColor: type === 'SQL' ? `${brand.accent}15` : `${brand.textMuted}20`,
fontSize: 10,
fontWeight: 700,
fontFamily: 'Geist Mono, monospace',
padding: '2px 8px',
borderRadius: 4,
letterSpacing: '0.05em',
}}
>
{type}
</div>
)
type ResultTableProps = {
opacity: number
translateY: number
}
const ResultTable: React.FC<ResultTableProps> = ({ opacity, translateY }) => {
const rows = [
{ id: 'pay_8xk2', amount: '$142.00', status: 'processing' },
{ id: 'pay_9mf4', amount: '$89.50', status: 'processing' },
{ id: 'pay_3qz7', amount: '$256.00', status: 'processing' },
]
const cols = ['id', 'amount', 'status']
return (
<div
style={{
opacity,
transform: `translateY(${translateY}px)`,
borderTop: `1px solid ${brand.border}`,
padding: '8px 0',
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
padding: '4px 16px',
marginBottom: 4,
}}
>
{cols.map((col) => (
<div
key={col}
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 10,
fontWeight: 700,
color: brand.textMuted,
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
{col}
</div>
))}
</div>
{rows.map((row, i) => (
<div
key={i}
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
padding: '5px 16px',
backgroundColor: i % 2 === 0 ? `${brand.surfaceElevated}80` : 'transparent',
}}
>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textSecondary,
}}
>
{row.id}
</div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textSecondary,
}}
>
{row.amount}
</div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: '#fbbf24',
}}
>
{row.status}
</div>
</div>
))}
<div
style={{
padding: '4px 16px',
fontFamily: 'Geist Mono, monospace',
fontSize: 10,
color: brand.textMuted,
marginTop: 4,
}}
>
3 rows · 12ms
</div>
</div>
)
}
type PinBadgeProps = {
opacity: number
scale: number
}
const PinBadge: React.FC<PinBadgeProps> = ({ opacity, scale }) => (
<div
style={{
position: 'absolute',
top: 8,
right: 12,
display: 'flex',
alignItems: 'center',
gap: 4,
opacity,
transform: `scale(${scale})`,
backgroundColor: `${brand.accent}20`,
border: `1px solid ${brand.accent}40`,
borderRadius: 6,
padding: '3px 8px',
}}
>
<Pin size={10} color={brand.accent} />
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 10,
color: brand.accent,
fontWeight: 600,
}}
>
pinned
</span>
</div>
)
export const NotebookMockup: React.FC = () => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const notebookEntrance = spring({ frame, fps, config: { damping: 200 } })
const notebookOpacity = interpolate(notebookEntrance, [0, 1], [0, 1])
const notebookY = interpolate(notebookEntrance, [0, 1], [40, 0])
const cell1Entrance = spring({ frame: frame - 60, fps, config: { damping: 200 } })
const cell1Opacity = interpolate(cell1Entrance, [0, 1], [0, 1])
const cell1Y = interpolate(cell1Entrance, [0, 1], [20, 0])
const cell2Entrance = spring({ frame: frame - 120, fps, config: { damping: 200 } })
const cell2Opacity = interpolate(cell2Entrance, [0, 1], [0, 1])
const cell2Y = interpolate(cell2Entrance, [0, 1], [20, 0])
const localSqlFrame = Math.max(0, frame - 120)
const totalSqlChars = SQL_TEXT.length
const framesPerChar = fps / 30
const sqlCharsVisible = Math.min(totalSqlChars, Math.floor(localSqlFrame / framesPerChar))
const runPulseFrame = Math.max(0, frame - 200)
const runPulse = interpolate(
(runPulseFrame % 20) / 20,
[0, 0.5, 1],
[0.5, 1, 0.5]
)
const runVisible = frame >= 200 && frame < 240
const resultsEntrance = spring({ frame: frame - 240, fps, config: { damping: 200 } })
const resultsOpacity = interpolate(resultsEntrance, [0, 1], [0, 1])
const resultsY = interpolate(resultsEntrance, [0, 1], [20, 0])
const pinEntrance = spring({ frame: frame - 280, fps, config: { damping: 200 } })
const pinOpacity = interpolate(pinEntrance, [0, 1], [0, 1])
const pinScale = interpolate(pinEntrance, [0, 1], [0.5, 1])
const pinGlowOpacity = frame >= 280 ? interpolate(frame - 280, [0, 20], [0.8, 0.3], {
extrapolateRight: 'clamp',
}) : 0
const cell3Entrance = spring({ frame: frame - 300, fps, config: { damping: 200 } })
const cell3Opacity = interpolate(cell3Entrance, [0, 1], [0, 1])
const cell3Y = interpolate(cell3Entrance, [0, 1], [20, 0])
const localGatewaySqlFrame = Math.max(0, frame - 300)
const gatewayCharsVisible = Math.min(
GATEWAY_SQL.length,
Math.floor(localGatewaySqlFrame / framesPerChar)
)
return (
<AbsoluteFill
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '80px 160px',
}}
>
<div
style={{
width: '100%',
maxWidth: 1100,
opacity: notebookOpacity,
transform: `translateY(${notebookY}px)`,
backgroundColor: brand.surface,
border: `1px solid ${brand.border}`,
borderRadius: 12,
overflow: 'hidden',
boxShadow: `0 0 60px ${brand.accent}10, 0 20px 60px rgba(0,0,0,0.5)`,
}}
>
<div
style={{
backgroundColor: brand.surfaceElevated,
borderBottom: `1px solid ${brand.border}`,
padding: '10px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ display: 'flex', gap: 6 }}>
{['#ff5f57', '#ffbd2e', '#28ca41'].map((c, i) => (
<div
key={i}
style={{ width: 10, height: 10, borderRadius: '50%', backgroundColor: c }}
/>
))}
</div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 14,
fontWeight: 600,
color: brand.textPrimary,
}}
>
Debug Payment Failures
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
backgroundColor: `${brand.accent}15`,
border: `1px solid ${brand.accent}30`,
borderRadius: 6,
padding: '4px 10px',
}}
>
<div
style={{
width: 6,
height: 6,
borderRadius: '50%',
backgroundColor: '#22c55e',
}}
/>
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 11,
color: brand.accent,
fontWeight: 500,
}}
>
payments-db
</span>
</div>
</div>
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<Sequence from={60} layout="none">
<div
style={{
opacity: cell1Opacity,
transform: `translateY(${cell1Y}px)`,
backgroundColor: brand.surface,
border: `1px solid ${brand.border}`,
borderRadius: 8,
overflow: 'hidden',
}}
>
<div
style={{
backgroundColor: brand.surfaceElevated,
borderBottom: `1px solid ${brand.border}`,
padding: '5px 12px',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<CellBadge type="MD" />
</div>
<div style={{ padding: '10px 16px' }}>
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 16,
fontWeight: 700,
color: brand.textPrimary,
}}
>
## Step 1: Check stuck payments
</span>
</div>
</div>
</Sequence>
<Sequence from={120} layout="none">
<div
style={{
opacity: cell2Opacity,
transform: `translateY(${cell2Y}px)`,
backgroundColor: brand.surface,
border: `2px solid ${brand.accent}60`,
borderRadius: 8,
overflow: 'hidden',
position: 'relative',
boxShadow: `0 0 20px ${brand.accent}15`,
}}
>
{frame >= 280 && (
<div
style={{
position: 'absolute',
inset: 0,
borderRadius: 8,
boxShadow: `inset 0 0 20px ${brand.accent}${Math.floor(pinGlowOpacity * 30).toString(16).padStart(2, '0')}`,
pointerEvents: 'none',
}}
/>
)}
<div
style={{
backgroundColor: brand.surfaceElevated,
borderBottom: `1px solid ${brand.border}`,
padding: '5px 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CellBadge type="SQL" />
{runVisible && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
opacity: runPulse,
}}
>
<Play size={10} color={brand.accent} fill={brand.accent} />
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 10,
color: brand.accent,
}}
>
running...
</span>
</div>
)}
</div>
{frame >= 240 && (
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 10,
color: brand.textMuted,
}}
>
3 rows · 12ms
</span>
)}
</div>
<div style={{ padding: '12px 16px', position: 'relative' }}>
<pre
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 13,
lineHeight: 1.6,
margin: 0,
}}
>
{renderSqlWithHighlighting(SQL_TEXT, sqlCharsVisible)}
{sqlCharsVisible < SQL_TEXT.length && (
<span style={{ opacity: Math.round(frame / 8) % 2 === 0 ? 1 : 0, color: brand.accent }}>
|
</span>
)}
</pre>
{frame >= 280 && (
<PinBadge opacity={pinOpacity} scale={pinScale} />
)}
</div>
{frame >= 240 && (
<ResultTable opacity={resultsOpacity} translateY={resultsY} />
)}
</div>
</Sequence>
<Sequence from={300} layout="none">
<div
style={{
opacity: cell3Opacity,
transform: `translateY(${cell3Y}px)`,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div
style={{
backgroundColor: brand.surface,
border: `1px solid ${brand.border}`,
borderRadius: 8,
overflow: 'hidden',
}}
>
<div
style={{
backgroundColor: brand.surfaceElevated,
borderBottom: `1px solid ${brand.border}`,
padding: '5px 12px',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<CellBadge type="MD" />
</div>
<div style={{ padding: '10px 16px' }}>
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 16,
fontWeight: 700,
color: brand.textPrimary,
}}
>
## Step 2: Check gateway logs
</span>
</div>
</div>
{frame >= 330 && (
<div
style={{
backgroundColor: brand.surface,
border: `1px solid ${brand.border}`,
borderRadius: 8,
overflow: 'hidden',
}}
>
<div
style={{
backgroundColor: brand.surfaceElevated,
borderBottom: `1px solid ${brand.border}`,
padding: '5px 12px',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<CellBadge type="SQL" />
</div>
<div style={{ padding: '12px 16px' }}>
<pre
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 13,
lineHeight: 1.6,
margin: 0,
}}
>
{renderSqlWithHighlighting(GATEWAY_SQL, gatewayCharsVisible)}
{gatewayCharsVisible < GATEWAY_SQL.length && (
<span
style={{
opacity: Math.round(frame / 8) % 2 === 0 ? 1 : 0,
color: brand.accent,
}}
>
|
</span>
)}
</pre>
</div>
</div>
)}
</div>
</Sequence>
</div>
<div
style={{
borderTop: `1px solid ${brand.border}`,
padding: '6px 16px',
display: 'flex',
alignItems: 'center',
gap: 8,
backgroundColor: brand.surfaceElevated,
}}
>
<ChevronDown size={12} color={brand.textMuted} />
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 11,
color: brand.textMuted,
}}
>
+ Add cell
</span>
</div>
</div>
</AbsoluteFill>
)
}

View file

@ -0,0 +1,119 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Sequence,
} from 'remotion'
import { brand } from '../../lib/colors'
import { CyanGlow } from '../../components/CyanGlow'
import { BookOpen } from 'lucide-react'
export const TitleScene: React.FC = () => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const iconScale = spring({
frame,
fps,
config: { damping: 12, stiffness: 100 },
})
const iconRotate = interpolate(iconScale, [0, 1], [-180, 0])
const titleEntrance = spring({
frame: frame - 8,
fps,
config: { damping: 15, stiffness: 80 },
})
const titleOpacity = interpolate(frame, [5, 25], [0, 1], {
extrapolateRight: 'clamp',
})
const titleY = interpolate(titleEntrance, [0, 1], [30, 0])
const subtitleEntrance = spring({
frame: frame - 25,
fps,
config: { damping: 200 },
})
const subtitleOpacity = interpolate(subtitleEntrance, [0, 1], [0, 1])
const subtitleY = interpolate(subtitleEntrance, [0, 1], [20, 0])
const logoEntrance = spring({
frame: frame - 45,
fps,
config: { damping: 200 },
})
const logoOpacity = interpolate(logoEntrance, [0, 1], [0, 1])
return (
<AbsoluteFill
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 20,
}}
>
<CyanGlow size={600} delay={5} />
<div
style={{
transform: `scale(${iconScale}) rotate(${iconRotate}deg)`,
marginBottom: 4,
}}
>
<BookOpen size={72} color={brand.accent} strokeWidth={1.5} />
</div>
<div
style={{
opacity: titleOpacity,
transform: `translateY(${titleY}px)`,
fontFamily: 'Geist Mono, monospace',
fontSize: 96,
fontWeight: 700,
color: brand.textPrimary,
letterSpacing: '-0.05em',
}}
>
SQL Notebooks
</div>
<div
style={{
opacity: subtitleOpacity,
transform: `translateY(${subtitleY}px)`,
fontFamily: 'Geist Mono, monospace',
fontSize: 32,
fontWeight: 400,
color: brand.textMuted,
letterSpacing: '-0.02em',
}}
>
Runbooks that actually run.
</div>
<Sequence from={45} layout="none">
<div
style={{
opacity: logoOpacity,
fontFamily: 'Geist Mono, monospace',
fontSize: 18,
fontWeight: 500,
color: brand.textMuted,
marginTop: 12,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<span style={{ color: brand.accent }}>data-peek</span>
<span>·</span>
<span>v0.20.0</span>
</div>
</Sequence>
</AbsoluteFill>
)
}

View file

@ -0,0 +1,447 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
staticFile,
spring,
} from 'remotion'
import { Audio } from '@remotion/media'
import { TransitionSeries, linearTiming } from '@remotion/transitions'
import { fade } from '@remotion/transitions/fade'
import { slide } from '@remotion/transitions/slide'
import { Background } from '../../components/Background'
import { CyanGlow } from '../../components/CyanGlow'
import { brand } from '../../lib/colors'
import { ensureFonts } from '../../lib/fonts'
import { TitleScene } from './TitleScene'
import { NotebookMockup } from './NotebookMockup'
import { KeyboardScene } from './KeyboardScene'
import { ExportScene } from './ExportScene'
import { EndScene } from './EndScene'
ensureFonts()
const TRANSITION_DURATION = 15
const fadeTiming = linearTiming({ durationInFrames: TRANSITION_DURATION })
const fadePresentation = fade()
const slidePresentation = slide({ direction: 'from-right' })
const ProblemScene: React.FC = () => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const headerEntrance = spring({ frame, fps, config: { damping: 200 } })
const headerOpacity = interpolate(headerEntrance, [0, 1], [0, 1])
const headerY = interpolate(headerEntrance, [0, 1], [30, 0])
const leftEntrance = spring({ frame: frame - 20, fps, config: { damping: 200 } })
const leftOpacity = interpolate(leftEntrance, [0, 1], [0, 1])
const leftY = interpolate(leftEntrance, [0, 1], [30, 0])
const rightEntrance = spring({ frame: frame - 35, fps, config: { damping: 200 } })
const rightOpacity = interpolate(rightEntrance, [0, 1], [0, 1])
const rightY = interpolate(rightEntrance, [0, 1], [30, 0])
const crossOpacity = interpolate(frame, [60, 75], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
const solutionEntrance = spring({ frame: frame - 80, fps, config: { damping: 200 } })
const solutionOpacity = interpolate(solutionEntrance, [0, 1], [0, 1])
const solutionY = interpolate(solutionEntrance, [0, 1], [20, 0])
return (
<AbsoluteFill
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 36,
padding: '0 100px',
}}
>
<CyanGlow size={400} x="50%" y="50%" delay={0} />
<div
style={{
opacity: headerOpacity,
transform: `translateY(${headerY}px)`,
fontFamily: 'Geist Mono, monospace',
fontSize: 28,
color: brand.textMuted,
letterSpacing: '-0.01em',
}}
>
Documentation here. Queries there.
</div>
<div style={{ display: 'flex', gap: 32, alignItems: 'stretch' }}>
<div
style={{
opacity: leftOpacity,
transform: `translateY(${leftY}px)`,
position: 'relative',
flex: 1,
}}
>
<div
style={{
backgroundColor: brand.surface,
border: `1px solid ${brand.border}`,
borderRadius: 12,
overflow: 'hidden',
height: '100%',
}}
>
<div
style={{
backgroundColor: brand.surfaceElevated,
borderBottom: `1px solid ${brand.border}`,
padding: '8px 14px',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<div
style={{
width: 12,
height: 12,
borderRadius: 3,
backgroundColor: '#94a3b8',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textMuted,
}}
>
Notion
</span>
</div>
<div style={{ padding: '20px 20px' }}>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 16,
fontWeight: 700,
color: brand.textPrimary,
marginBottom: 12,
}}
>
Step 1: Check stuck payments
</div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 13,
color: brand.textSecondary,
marginBottom: 14,
lineHeight: 1.5,
}}
>
Run this query on the payments DB to find any processing orders stuck for {'>'}30 mins:
</div>
<div
style={{
backgroundColor: brand.background,
border: `1px solid ${brand.border}`,
borderRadius: 6,
padding: '12px 14px',
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textSecondary,
lineHeight: 1.6,
}}
>
<div>SELECT id, amount, status</div>
<div>FROM payments</div>
<div>WHERE status = 'processing'</div>
<div>AND updated_at {'<'} NOW() - interval '30 mins'</div>
</div>
<div
style={{
marginTop: 10,
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textMuted,
fontStyle: 'italic',
}}
>
(copy this and run it in your SQL client)
</div>
</div>
</div>
{frame >= 60 && (
<div
style={{
position: 'absolute',
inset: 0,
opacity: crossOpacity,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
backgroundColor: `${brand.background}80`,
}}
>
<div
style={{
fontSize: 72,
color: '#ef4444',
fontWeight: 700,
textShadow: '0 0 20px #ef444460',
}}
>
</div>
</div>
)}
</div>
<div
style={{
opacity: rightOpacity,
transform: `translateY(${rightY}px)`,
position: 'relative',
flex: 1,
}}
>
<div
style={{
backgroundColor: brand.surface,
border: `1px solid ${brand.border}`,
borderRadius: 12,
overflow: 'hidden',
height: '100%',
}}
>
<div
style={{
backgroundColor: brand.surfaceElevated,
borderBottom: `1px solid ${brand.border}`,
padding: '8px 14px',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<div
style={{
backgroundColor: `${brand.accent}20`,
border: `1px solid ${brand.accent}40`,
borderRadius: 4,
padding: '2px 8px',
fontFamily: 'Geist Mono, monospace',
fontSize: 11,
fontWeight: 700,
color: brand.accent,
}}
>
data-peek
</div>
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textMuted,
}}
>
query_tab_1.sql
</span>
</div>
<div style={{ padding: '20px' }}>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 13,
lineHeight: 1.6,
color: brand.textSecondary,
}}
>
<span style={{ color: '#a855f7' }}>SELECT</span>
{' id, amount, status\n'}
<span style={{ color: '#a855f7' }}>FROM</span>
{' '}
<span style={{ color: '#22d3ee' }}>payments</span>
{'\n'}
<span style={{ color: '#a855f7' }}>WHERE</span>
{" status = "}
<span style={{ color: '#fbbf24' }}>'processing'</span>
{'\n'}
<span style={{ color: '#a855f7' }}>AND</span>
{' updated_at < '}
<span style={{ color: '#22d3ee' }}>NOW()</span>
{' - '}
<span style={{ color: '#a855f7' }}>interval</span>
{" "}
<span style={{ color: '#fbbf24' }}>'30 mins'</span>
</div>
<div
style={{
marginTop: 16,
padding: '8px',
backgroundColor: brand.background,
border: `1px solid ${brand.border}`,
borderRadius: 6,
}}
>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 11,
color: brand.textMuted,
marginBottom: 4,
}}
>
Results
</div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textSecondary,
}}
>
3 rows returned
</div>
</div>
<div
style={{
marginTop: 12,
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textMuted,
fontStyle: 'italic',
}}
>
(where's the context though?)
</div>
</div>
</div>
{frame >= 60 && (
<div
style={{
position: 'absolute',
inset: 0,
opacity: crossOpacity,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
backgroundColor: `${brand.background}80`,
}}
>
<div
style={{
fontSize: 72,
color: '#ef4444',
fontWeight: 700,
textShadow: '0 0 20px #ef444460',
}}
>
</div>
</div>
)}
</div>
</div>
{frame >= 80 && (
<div
style={{
opacity: solutionOpacity,
transform: `translateY(${solutionY}px)`,
fontFamily: 'Geist Mono, monospace',
fontSize: 32,
fontWeight: 700,
color: brand.textPrimary,
letterSpacing: '-0.03em',
textAlign: 'center',
}}
>
What if they were the{' '}
<span style={{ color: brand.accent }}>same thing?</span>
</div>
)}
</AbsoluteFill>
)
}
export const NotebookDemo: React.FC = () => {
const frame = useCurrentFrame()
const { fps, durationInFrames } = useVideoConfig()
return (
<AbsoluteFill>
<Background />
<Audio
src={staticFile('audio/bg-music-notebooks.mp3')}
volume={(f) =>
interpolate(
f,
[0, 1 * fps, durationInFrames - 2 * fps, durationInFrames],
[0, 0.15, 0.15, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
)
}
/>
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={90}>
<TitleScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fadePresentation}
timing={fadeTiming}
/>
<TransitionSeries.Sequence durationInFrames={120}>
<ProblemScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slidePresentation}
timing={fadeTiming}
/>
<TransitionSeries.Sequence durationInFrames={360}>
<NotebookMockup />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slidePresentation}
timing={fadeTiming}
/>
<TransitionSeries.Sequence durationInFrames={180}>
<KeyboardScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fadePresentation}
timing={fadeTiming}
/>
<TransitionSeries.Sequence durationInFrames={120}>
<ExportScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fadePresentation}
timing={fadeTiming}
/>
<TransitionSeries.Sequence durationInFrames={120}>
<EndScene />
</TransitionSeries.Sequence>
</TransitionSeries>
</AbsoluteFill>
)
}

View file

@ -0,0 +1,92 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Sequence,
} from 'remotion'
import { brand } from '../../lib/colors'
import { VersionBadge } from '../../components/VersionBadge'
import { TypewriterText } from '../../components/TypewriterText'
import { CyanGlow } from '../../components/CyanGlow'
import { BookOpen } from 'lucide-react'
type IntroProps = {
version: string
}
export const Intro: React.FC<IntroProps> = ({ version }) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const iconScale = spring({
frame,
fps,
config: { damping: 12, stiffness: 100 },
})
const iconRotate = interpolate(iconScale, [0, 1], [-180, 0])
const titleScale = spring({
frame: frame - 8,
fps,
config: { damping: 15, stiffness: 80 },
})
const titleOpacity = interpolate(frame, [5, 25], [0, 1], {
extrapolateRight: 'clamp',
})
return (
<AbsoluteFill
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 24,
}}
>
<CyanGlow size={500} delay={5} />
<div
style={{
transform: `scale(${iconScale}) rotate(${iconRotate}deg)`,
marginBottom: 8,
}}
>
<BookOpen size={64} color={brand.accent} strokeWidth={1.5} />
</div>
<div
style={{
opacity: titleOpacity,
transform: `scale(${titleScale})`,
fontFamily: 'Geist Mono, monospace',
fontSize: 88,
fontWeight: 700,
color: brand.textPrimary,
letterSpacing: '-0.05em',
}}
>
data-peek
</div>
<Sequence from={15} layout="none">
<VersionBadge version={version} />
</Sequence>
<Sequence from={35} layout="none">
<TypewriterText
text="SQL Notebooks. Runbooks that actually run."
charsPerSecond={28}
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 30,
fontWeight: 400,
color: brand.textMuted,
}}
/>
</Sequence>
</AbsoluteFill>
)
}

View file

@ -0,0 +1,197 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Sequence,
} from 'remotion'
import { brand } from '../../lib/colors'
import { CyanGlow } from '../../components/CyanGlow'
import { BookOpen, Pin, Keyboard, Share2 } from 'lucide-react'
type OutroProps = {
version: string
}
const featureIcons = [
{ icon: BookOpen, color: '#6b8cf5' },
{ icon: Pin, color: '#f59e0b' },
{ icon: Keyboard, color: '#a855f7' },
{ icon: Share2, color: '#10b981' },
]
export const Outro: React.FC<OutroProps> = ({ version }) => {
const frame = useCurrentFrame()
const { fps, durationInFrames } = useVideoConfig()
const fadeOut = interpolate(
frame,
[durationInFrames - 15, durationInFrames],
[1, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
)
return (
<AbsoluteFill
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 36,
opacity: fadeOut,
}}
>
<CyanGlow size={500} delay={0} />
<div style={{ display: 'flex', gap: 16 }}>
{featureIcons.map(({ icon: Icon, color }, i) => {
const entrance = spring({
frame: frame - i * 4,
fps,
config: { damping: 12, stiffness: 100 },
})
const scale = interpolate(entrance, [0, 1], [0, 1])
return (
<div
key={i}
style={{
width: 52,
height: 52,
borderRadius: 14,
backgroundColor: `${color}15`,
border: `1px solid ${color}40`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transform: `scale(${scale})`,
}}
>
<Icon size={22} color={color} strokeWidth={1.5} />
</div>
)
})}
</div>
<Sequence from={15} layout="none">
<TitleReveal version={version} />
</Sequence>
<Sequence from={30} layout="none">
<CtaReveal />
</Sequence>
</AbsoluteFill>
)
}
const TitleReveal: React.FC<{ version: string }> = ({ version }) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const entrance = spring({ frame, fps, config: { damping: 200 } })
const opacity = interpolate(entrance, [0, 1], [0, 1])
const translateY = interpolate(entrance, [0, 1], [20, 0])
return (
<div
style={{
opacity,
transform: `translateY(${translateY}px)`,
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
}}
>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 56,
fontWeight: 700,
color: brand.textPrimary,
letterSpacing: '-0.04em',
}}
>
data-peek{' '}
<span style={{ color: brand.accent }}>v{version}</span>
</div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 22,
color: brand.textMuted,
}}
>
SQL Notebooks runbooks that run.
</div>
</div>
)
}
const CtaReveal: React.FC = () => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const entrance = spring({ frame, fps, config: { damping: 200 } })
const opacity = interpolate(entrance, [0, 1], [0, 1])
return (
<div
style={{
opacity,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 10,
}}
>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 24,
color: brand.textMuted,
}}
>
Try it now
</div>
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 28,
fontWeight: 500,
color: brand.accent,
borderBottom: `2px solid ${brand.accent}60`,
paddingBottom: 4,
}}
>
datapeek.dev
</div>
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 18,
color: brand.textMuted,
}}
>
|
</span>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 28,
fontWeight: 500,
color: '#10b981',
borderBottom: '2px solid #10b98160',
paddingBottom: 4,
}}
>
app.datapeek.dev
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,793 @@
import {
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from 'remotion'
import { brand } from '../../lib/colors'
export const NotebookCellsIllustration: React.FC = () => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const containerEntrance = spring({
frame: frame - 5,
fps,
config: { damping: 200 },
})
const containerOpacity = interpolate(containerEntrance, [0, 1], [0, 1])
const mdEntrance = spring({
frame: frame - 15,
fps,
config: { damping: 200 },
})
const mdOpacity = interpolate(mdEntrance, [0, 1], [0, 1])
const mdTranslateY = interpolate(mdEntrance, [0, 1], [16, 0])
const sqlEntrance = spring({
frame: frame - 35,
fps,
config: { damping: 200 },
})
const sqlOpacity = interpolate(sqlEntrance, [0, 1], [0, 1])
const sqlTranslateY = interpolate(sqlEntrance, [0, 1], [16, 0])
const resultEntrance = spring({
frame: frame - 60,
fps,
config: { damping: 200 },
})
const resultOpacity = interpolate(resultEntrance, [0, 1], [0, 1])
return (
<div
style={{
width: 580,
height: 420,
backgroundColor: brand.surface,
borderRadius: 16,
border: `1px solid ${brand.border}`,
padding: 20,
display: 'flex',
flexDirection: 'column',
gap: 10,
opacity: containerOpacity,
}}
>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textMuted,
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
runbook.dpnb
</div>
<div
style={{
opacity: mdOpacity,
transform: `translateY(${mdTranslateY}px)`,
backgroundColor: brand.surfaceElevated,
borderRadius: 10,
border: `1px solid ${brand.border}`,
borderLeft: `3px solid #6b8cf5`,
padding: '12px 14px',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 10,
color: '#6b8cf5',
padding: '1px 6px',
borderRadius: 4,
backgroundColor: '#6b8cf510',
border: '1px solid #6b8cf530',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
md
</span>
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 10, color: brand.textMuted }}>
markdown cell
</span>
</div>
<div
style={{
fontFamily: 'Geist, system-ui, sans-serif',
fontSize: 16,
fontWeight: 700,
color: brand.textPrimary,
}}
>
# Daily Active Users Report
</div>
<div
style={{
fontFamily: 'Geist, system-ui, sans-serif',
fontSize: 12,
color: brand.textSecondary,
lineHeight: 1.5,
}}
>
Check DAU for the last 7 days. Compare against baseline.
</div>
</div>
<div
style={{
opacity: sqlOpacity,
transform: `translateY(${sqlTranslateY}px)`,
backgroundColor: brand.surfaceElevated,
borderRadius: 10,
border: `1px solid ${brand.border}`,
borderLeft: `3px solid #f59e0b`,
padding: '12px 14px',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 10,
color: '#f59e0b',
padding: '1px 6px',
borderRadius: 4,
backgroundColor: '#f59e0b10',
border: '1px solid #f59e0b30',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
sql
</span>
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 10, color: brand.textMuted }}>
query cell
</span>
</div>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
lineHeight: 1.6,
color: brand.textSecondary,
}}
>
<span style={{ color: '#6b8cf5' }}>SELECT</span>
<span style={{ color: brand.textPrimary }}> date_trunc(</span>
<span style={{ color: '#10b981' }}>'day'</span>
<span style={{ color: brand.textPrimary }}>, created_at) </span>
<span style={{ color: '#6b8cf5' }}>AS</span>
<span style={{ color: brand.textPrimary }}> day,</span>
<br />
<span style={{ color: brand.textMuted }}>{' '}</span>
<span style={{ color: '#f59e0b' }}>count</span>
<span style={{ color: brand.textPrimary }}>(*) </span>
<span style={{ color: '#6b8cf5' }}>AS</span>
<span style={{ color: brand.textPrimary }}> users</span>
<br />
<span style={{ color: '#6b8cf5' }}>FROM</span>
<span style={{ color: brand.textPrimary }}> events </span>
<span style={{ color: '#6b8cf5' }}>GROUP BY</span>
<span style={{ color: brand.textPrimary }}> 1 </span>
<span style={{ color: '#6b8cf5' }}>ORDER BY</span>
<span style={{ color: brand.textPrimary }}> 1 </span>
<span style={{ color: '#6b8cf5' }}>DESC</span>
</div>
</div>
<div
style={{
opacity: resultOpacity,
borderRadius: 8,
overflow: 'hidden',
border: `1px solid ${brand.border}`,
}}
>
<div
style={{
display: 'flex',
padding: '5px 12px',
backgroundColor: brand.surfaceElevated,
borderBottom: `1px solid ${brand.border}`,
}}
>
{['day', 'users'].map((col) => (
<span
key={col}
style={{
flex: 1,
fontFamily: 'Geist Mono, monospace',
fontSize: 11,
color: brand.textMuted,
fontWeight: 500,
}}
>
{col}
</span>
))}
</div>
{[
{ day: '2024-04-12', users: '2,847' },
{ day: '2024-04-11', users: '3,102' },
{ day: '2024-04-10', users: '2,991' },
].map((row, i) => {
const rowEntrance = spring({
frame: frame - 70 - i * 6,
fps,
config: { damping: 200 },
})
const rowOpacity = interpolate(rowEntrance, [0, 1], [0, 1])
return (
<div
key={row.day}
style={{
opacity: rowOpacity,
display: 'flex',
padding: '6px 12px',
borderBottom: `1px solid ${brand.border}`,
}}
>
<span style={{ flex: 1, fontFamily: 'Geist Mono, monospace', fontSize: 12, color: brand.textSecondary }}>
{row.day}
</span>
<span style={{ flex: 1, fontFamily: 'Geist Mono, monospace', fontSize: 12, color: '#10b981' }}>
{row.users}
</span>
</div>
)
})}
</div>
</div>
)
}
export const PinnedResultsIllustration: React.FC = () => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const containerEntrance = spring({
frame: frame - 5,
fps,
config: { damping: 200 },
})
const containerOpacity = interpolate(containerEntrance, [0, 1], [0, 1])
const badgeEntrance = spring({
frame: frame - 25,
fps,
config: { damping: 12, stiffness: 100 },
})
const badgeScale = interpolate(badgeEntrance, [0, 1], [0.7, 1])
const badgeOpacity = interpolate(badgeEntrance, [0, 1], [0, 1])
return (
<div
style={{
width: 580,
height: 400,
backgroundColor: brand.surface,
borderRadius: 16,
border: `1px solid ${brand.border}`,
padding: 24,
display: 'flex',
flexDirection: 'column',
gap: 12,
opacity: containerOpacity,
}}
>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textMuted,
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
pinned query result
</div>
<div
style={{
backgroundColor: brand.surfaceElevated,
borderRadius: 10,
border: `1px solid ${brand.border}`,
borderLeft: `3px solid #f59e0b`,
padding: '10px 14px',
fontFamily: 'Geist Mono, monospace',
fontSize: 13,
color: brand.textSecondary,
}}
>
<span style={{ color: '#6b8cf5' }}>SELECT</span>
{' * '}
<span style={{ color: '#6b8cf5' }}>FROM</span>
{' users '}
<span style={{ color: '#6b8cf5' }}>WHERE</span>
{' plan = '}
<span style={{ color: '#10b981' }}>'pro'</span>
</div>
<div
style={{
opacity: badgeOpacity,
transform: `scale(${badgeScale})`,
transformOrigin: 'left center',
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
backgroundColor: '#f59e0b10',
borderRadius: 8,
border: '1px solid #f59e0b30',
alignSelf: 'flex-start',
}}
>
<div
style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: '#f59e0b',
}}
/>
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: '#f59e0b',
}}
>
Pinned ran Apr 12, 09:14
</span>
</div>
<div
style={{
borderRadius: 8,
overflow: 'hidden',
border: `1px solid ${brand.border}`,
flex: 1,
}}
>
<div
style={{
display: 'flex',
padding: '6px 12px',
backgroundColor: brand.surfaceElevated,
borderBottom: `1px solid ${brand.border}`,
}}
>
{['id', 'email', 'plan', 'mrr'].map((col) => (
<span
key={col}
style={{
flex: 1,
fontFamily: 'Geist Mono, monospace',
fontSize: 11,
color: brand.textMuted,
fontWeight: 500,
}}
>
{col}
</span>
))}
</div>
{[
{ id: '1', email: 'alice@co.com', plan: 'pro', mrr: '$49' },
{ id: '2', email: 'bob@co.com', plan: 'pro', mrr: '$49' },
{ id: '3', email: 'carol@co.com', plan: 'pro', mrr: '$99' },
{ id: '4', email: 'dan@co.com', plan: 'pro', mrr: '$49' },
].map((row, i) => {
const rowEntrance = spring({
frame: frame - 35 - i * 8,
fps,
config: { damping: 200 },
})
const rowOpacity = interpolate(rowEntrance, [0, 1], [0, 1])
return (
<div
key={row.id}
style={{
opacity: rowOpacity,
display: 'flex',
padding: '7px 12px',
borderBottom: `1px solid ${brand.border}`,
}}
>
<span style={{ flex: 1, fontFamily: 'Geist Mono, monospace', fontSize: 12, color: brand.textMuted }}>
{row.id}
</span>
<span style={{ flex: 1, fontFamily: 'Geist Mono, monospace', fontSize: 12, color: brand.textPrimary }}>
{row.email}
</span>
<span
style={{
flex: 1,
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: '#f59e0b',
}}
>
{row.plan}
</span>
<span style={{ flex: 1, fontFamily: 'Geist Mono, monospace', fontSize: 12, color: '#10b981' }}>
{row.mrr}
</span>
</div>
)
})}
</div>
</div>
)
}
export const KeyboardNavIllustration: React.FC = () => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const focusIndex = Math.floor(frame / 28) % 3
const cells = [
{ label: 'SELECT count(*) FROM users', type: 'sql' },
{ label: '## Results look normal', type: 'md' },
{ label: 'SELECT * FROM events LIMIT 20', type: 'sql' },
]
return (
<div
style={{
width: 580,
height: 400,
backgroundColor: brand.surface,
borderRadius: 16,
border: `1px solid ${brand.border}`,
padding: 24,
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textMuted,
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
keyboard navigation
</div>
{cells.map((cell, i) => {
const isFocused = focusIndex === i
const entrance = spring({
frame: frame - 8 - i * 12,
fps,
config: { damping: 200 },
})
const opacity = interpolate(entrance, [0, 1], [0, 1])
const translateY = interpolate(entrance, [0, 1], [12, 0])
const accentColor = cell.type === 'sql' ? '#f59e0b' : '#6b8cf5'
const focusGlow = isFocused
? interpolate(
Math.sin(frame * 0.15),
[-1, 1],
[0.4, 1]
)
: 0.3
return (
<div
key={i}
style={{
opacity,
transform: `translateY(${translateY}px)`,
backgroundColor: brand.surfaceElevated,
borderRadius: 10,
border: `2px solid ${isFocused ? accentColor : brand.border}`,
borderLeft: `4px solid ${accentColor}`,
padding: '12px 14px',
display: 'flex',
alignItems: 'center',
gap: 12,
boxShadow: isFocused ? `0 0 0 2px ${accentColor}30` : 'none',
transition: 'border-color 0.15s',
}}
>
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 10,
color: accentColor,
opacity: isFocused ? 1 : focusGlow,
padding: '1px 6px',
borderRadius: 4,
backgroundColor: `${accentColor}12`,
border: `1px solid ${accentColor}30`,
textTransform: 'uppercase',
letterSpacing: '0.05em',
flexShrink: 0,
}}
>
{cell.type}
</span>
<span
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 13,
color: isFocused ? brand.textPrimary : brand.textSecondary,
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{cell.label}
</span>
{isFocused && (
<span
style={{
marginLeft: 'auto',
fontFamily: 'Geist Mono, monospace',
fontSize: 10,
color: accentColor,
flexShrink: 0,
}}
>
focused
</span>
)}
</div>
)
})}
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginTop: 4 }}>
{['Shift+Enter — run & advance', 'Cmd+J — next cell', 'Cmd+K — prev cell'].map((kb, i) => {
const entrance = spring({
frame: frame - 50 - i * 8,
fps,
config: { damping: 200 },
})
const opacity = interpolate(entrance, [0, 1], [0, 1])
return (
<span
key={kb}
style={{
opacity,
fontFamily: 'Geist Mono, monospace',
fontSize: 10,
color: '#a855f7',
padding: '3px 10px',
borderRadius: 6,
backgroundColor: '#a855f710',
border: '1px solid #a855f725',
}}
>
{kb}
</span>
)
})}
</div>
</div>
)
}
export const ExportShareIllustration: React.FC = () => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const sourceEntrance = spring({
frame: frame - 8,
fps,
config: { damping: 200 },
})
const sourceOpacity = interpolate(sourceEntrance, [0, 1], [0, 1])
const sourceTranslateX = interpolate(sourceEntrance, [0, 1], [-24, 0])
const arrowEntrance = spring({
frame: frame - 30,
fps,
config: { damping: 200 },
})
const arrowOpacity = interpolate(arrowEntrance, [0, 1], [0, 1])
const dpnbEntrance = spring({
frame: frame - 45,
fps,
config: { damping: 12, stiffness: 100 },
})
const dpnbScale = interpolate(dpnbEntrance, [0, 1], [0.7, 1])
const dpnbOpacity = interpolate(dpnbEntrance, [0, 1], [0, 1])
const mdEntrance = spring({
frame: frame - 65,
fps,
config: { damping: 12, stiffness: 100 },
})
const mdScale = interpolate(mdEntrance, [0, 1], [0.7, 1])
const mdOpacity = interpolate(mdEntrance, [0, 1], [0, 1])
return (
<div
style={{
width: 580,
height: 400,
backgroundColor: brand.surface,
borderRadius: 16,
border: `1px solid ${brand.border}`,
padding: 28,
display: 'flex',
flexDirection: 'column',
gap: 24,
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
fontFamily: 'Geist Mono, monospace',
fontSize: 12,
color: brand.textMuted,
textTransform: 'uppercase',
letterSpacing: '0.08em',
alignSelf: 'flex-start',
}}
>
export & share
</div>
<div
style={{
opacity: sourceOpacity,
transform: `translateX(${sourceTranslateX}px)`,
padding: '14px 20px',
backgroundColor: brand.surfaceElevated,
borderRadius: 12,
border: `1px solid ${brand.border}`,
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: 8,
backgroundColor: `${brand.accent}15`,
border: `1px solid ${brand.accent}30`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 14, color: brand.accent }}>
NB
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 14, color: brand.textPrimary, fontWeight: 500 }}>
runbook.dpnb
</span>
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 11, color: brand.textMuted }}>
4 cells · last run Apr 12
</span>
</div>
</div>
<div
style={{
opacity: arrowOpacity,
display: 'flex',
alignItems: 'center',
gap: 40,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
<div style={{ fontFamily: 'Geist Mono, monospace', fontSize: 18, color: brand.textMuted }}></div>
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 11, color: brand.textMuted }}>reimportable</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
<div style={{ fontFamily: 'Geist Mono, monospace', fontSize: 18, color: brand.textMuted }}></div>
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 11, color: brand.textMuted }}>readable anywhere</span>
</div>
</div>
<div style={{ display: 'flex', gap: 24 }}>
<div
style={{
opacity: dpnbOpacity,
transform: `scale(${dpnbScale})`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 10,
padding: '16px 24px',
backgroundColor: brand.surfaceElevated,
borderRadius: 12,
border: `1px solid ${brand.accent}40`,
}}
>
<div
style={{
width: 44,
height: 52,
borderRadius: 8,
backgroundColor: `${brand.accent}15`,
border: `1px solid ${brand.accent}40`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 11, color: brand.accent, fontWeight: 700 }}>
.dpnb
</span>
</div>
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 12, color: brand.accent }}>
Export as .dpnb
</span>
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 10, color: brand.textMuted }}>
data-peek native
</span>
</div>
<div
style={{
opacity: mdOpacity,
transform: `scale(${mdScale})`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 10,
padding: '16px 24px',
backgroundColor: brand.surfaceElevated,
borderRadius: 12,
border: `1px solid #10b98140`,
}}
>
<div
style={{
width: 44,
height: 52,
borderRadius: 8,
backgroundColor: '#10b98115',
border: '1px solid #10b98140',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 11, color: '#10b981', fontWeight: 700 }}>
.md
</span>
</div>
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 12, color: '#10b981' }}>
Export as .md
</span>
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 10, color: brand.textMuted }}>
readable anywhere
</span>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,120 @@
import { AbsoluteFill } from 'remotion'
import { Audio } from '@remotion/media'
import { staticFile, interpolate, useCurrentFrame, useVideoConfig } from 'remotion'
import { TransitionSeries, linearTiming } from '@remotion/transitions'
import { fade } from '@remotion/transitions/fade'
import { slide } from '@remotion/transitions/slide'
import { BookOpen, Pin, Keyboard, Share2 } from 'lucide-react'
import { Fragment } from 'react'
import { Background } from '../../components/Background'
import { FixScene } from '../ReleaseVideo/FixScene'
import { Intro } from './Intro'
import { Outro } from './Outro'
import {
NotebookCellsIllustration,
PinnedResultsIllustration,
KeyboardNavIllustration,
ExportShareIllustration,
} from './illustrations'
import { ensureFonts } from '../../lib/fonts'
ensureFonts()
type ReleaseVideoProps = {
version: string
}
const TRANSITION_DURATION = 12
const fadeTiming = linearTiming({ durationInFrames: TRANSITION_DURATION })
const fadePresentation = fade()
const slidePresentation = slide({ direction: 'from-right' })
const features = [
{
icon: BookOpen,
title: 'SQL Notebooks',
description:
'Mix executable SQL cells with Markdown documentation. Jupyter-style, wired to your database.',
color: '#6b8cf5',
illustration: NotebookCellsIllustration,
},
{
icon: Pin,
title: 'Pin Results',
description:
"Pin query output so it persists across sessions. Your runbook shows what 'normal' looks like.",
color: '#f59e0b',
illustration: PinnedResultsIllustration,
},
{
icon: Keyboard,
title: 'Keyboard-First',
description:
'Shift+Enter to run and advance. Cmd+J/K to navigate. Jupyter muscle memory.',
color: '#a855f7',
illustration: KeyboardNavIllustration,
},
{
icon: Share2,
title: 'Export & Share',
description:
'Export as .dpnb (reimportable) or Markdown (readable anywhere). Same runbook, any connection.',
color: '#10b981',
illustration: ExportShareIllustration,
},
]
export const ReleaseVideo020: React.FC<ReleaseVideoProps> = ({ version }) => {
const frame = useCurrentFrame()
const { fps, durationInFrames } = useVideoConfig()
return (
<AbsoluteFill>
<Background />
<Audio
src={staticFile('audio/bg-music-notebooks.mp3')}
volume={(f) =>
interpolate(
f,
[0, 1 * fps, durationInFrames - 2 * fps, durationInFrames],
[0, 0.15, 0.15, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
)
}
/>
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={100}>
<Intro version={version} />
</TransitionSeries.Sequence>
{features.map((feat, i) => (
<Fragment key={feat.title}>
<TransitionSeries.Transition
presentation={i === 0 ? fadePresentation : slidePresentation}
timing={fadeTiming}
/>
<TransitionSeries.Sequence
durationInFrames={120}
>
<FixScene
icon={feat.icon}
title={feat.title}
description={feat.description}
color={feat.color}
illustration={feat.illustration}
/>
</TransitionSeries.Sequence>
</Fragment>
))}
<TransitionSeries.Transition
presentation={fadePresentation}
timing={fadeTiming}
/>
<TransitionSeries.Sequence durationInFrames={100}>
<Outro version={version} />
</TransitionSeries.Sequence>
</TransitionSeries>
</AbsoluteFill>
)
}

View file

@ -0,0 +1,135 @@
---
title: "Adding Jupyter-Style SQL Notebooks to a Desktop App"
published: false
description: "Why I added Jupyter-style notebooks to data-peek, how they work, and what makes them different from running SQL in a cell-based Python notebook."
tags: electron, sql, typescript, developer-tools
series: "Building data-peek"
cover_image:
---
# Adding Jupyter-Style SQL Notebooks to a Desktop App
A few months ago I added a feature I wasn't sure anyone would ask for: SQL notebooks. Cells of SQL and Markdown mixed together, each cell executable inline, results pinnable to the document. Jupyter for your database, without the kernel management.
The reaction was better than expected. The use case that kept coming up in feedback wasn't what I'd imagined. People weren't using notebooks for exploratory data analysis — they were using them for **team runbooks**.
## The Runbook Problem
Here's the actual problem: your team has a production database, and over time you accumulate a collection of queries that everyone needs occasionally. "How many active users signed up in the last 30 days?" "Which accounts have the broken subscription state?" "What's the current queue depth?"
These queries usually live in:
- A Notion doc where the SQL loses its formatting
- A Slack thread you can never find again
- Someone's personal `queries.sql` file that leaves when they do
What you actually want is a document where the SQL is the document. Not a screenshot of results next to code — the live, runnable query *is* the content. And when someone updates the query, the next person who opens it gets the updated version, not a stale screenshot.
That's the runbook use case. And it's a good fit for a notebook UI.
## How It Works
A notebook in data-peek is a tab that holds a list of cells. Each cell is either a SQL cell or a Markdown cell.
**SQL cells** show the query text. Click to edit, Shift+Enter to run and move to the next cell, Cmd+Enter to run in place. Results appear inline below the query.
**Markdown cells** render as formatted text when not focused — headings, bold, bullet lists. Click to edit, Escape to go back to rendered mode. These are the "runbook" parts: the explanation of what the query does, when to run it, what to look for in the results.
The mix looks like this in practice:
```
## Daily Active Users Check
Run this every morning before standup.
[SQL cell]
SELECT COUNT(DISTINCT user_id)
FROM events
WHERE created_at > NOW() - INTERVAL '24 hours'
AND event_type = 'session_start'
[Markdown cell]
If the number is below 500, check whether the auth service had any
errors overnight. Alert #ops if below 200.
```
The Markdown gives context. The SQL is always current. Anyone on the team can open the notebook and run it without digging through docs.
## Pinning Results
SQL cells have a "pin result" option in the cell menu. Pinning freezes the current result set into the cell and persists it to disk. The pinned result survives closing and reopening the notebook.
This is useful for runbooks because you want to see what the baseline looked like. Pinned results display with a timestamp: "Pinned — ran 3 days ago · 12ms". You can re-run to get fresh numbers, or keep the pin as a reference.
The pin metadata includes `executedAt`, `durationMs`, `rowCount`, and the full result rows. It's stored as a JSON column in SQLite. More on the storage design in the next post.
## The .dpnb Format
Notebooks can be exported as `.dpnb` files (data-peek notebook, JSON) or as Markdown. The `.dpnb` format is straightforward:
```json
{
"version": 1,
"title": "Daily Checks",
"folder": "ops",
"cells": [
{
"type": "markdown",
"content": "## Daily Active Users\n\nRun before standup."
},
{
"type": "sql",
"content": "SELECT COUNT(*) FROM events WHERE ...",
"pinnedResult": {
"columns": ["count"],
"rows": [[1234]],
"rowCount": 1,
"executedAt": 1704067200000,
"durationMs": 12
}
}
]
}
```
This is designed to be committed to a git repo. The cells are plain text, diffable, mergeable. Pinned results make the diffs noisier, but the tradeoff is that you get a historical record of what the query returned.
The Markdown export is intended for sharing outside the tool — paste into Confluence, commit to a wiki. It renders the pinned result as a Markdown table.
## What Makes This Different from Jupyter
The most common question when I describe this feature is "why not just use Jupyter with a PostgreSQL kernel?" Fair question. A few differences:
**Bound to a connection, not a kernel.** Every notebook is created against a specific database connection. You don't manage kernels, environments, or driver installations. Open the notebook, it's connected.
**No execution state.** In Jupyter, cells can define variables and functions that affect later cells. Data-peek notebooks have no shared state between cells — each SQL cell is a standalone query. This is a deliberate simplification. It means notebooks are always reproducible regardless of execution order.
**Instant startup.** No kernel to boot. Opening a notebook is as fast as opening any other tab.
**Desktop-first.** The notebook is a native desktop feature, not a browser tab. It gets native shortcuts, native file dialogs for export, and it lives alongside your other data-peek tabs.
The tradeoff is no Python cells, no pandas, no plotting. If you need that, Jupyter is the right tool. data-peek notebooks are specifically for SQL-first workflows.
## Keyboard Shortcuts
The keyboard model follows Jupyter's conventions where it made sense:
| Shortcut | Action |
|----------|--------|
| Shift+Enter | Run cell and advance to next |
| Cmd+Enter | Run cell, stay in place |
| Escape | Exit cell edit mode |
| Cmd+J | Move focus to next cell |
| Cmd+K | Move focus to previous cell |
| Cmd+Shift+D | Delete focused cell |
The hint bar at the bottom of the editor always shows these. I wanted the feature to be discoverable without reading docs.
## What's Next
Two things I want to add:
**AI cells.** A third cell type that takes a natural language prompt and generates SQL. data-peek already has AI SQL generation in the query editor — bringing that into notebooks would make it easier to build runbooks interactively.
**Cloud sync.** Right now notebooks are local to one machine. For team runbooks you want a shared library. The architecture is already designed for it — notebooks have a `connectionId` but the storage is pluggable. Adding a sync backend is the missing piece.
The runbook use case made me realize data-peek was missing a way to put SQL *in context*. A notebook is just a document where SQL is a first-class citizen. That sounds obvious in retrospect.

View file

@ -0,0 +1,254 @@
---
title: "Designing Local-First Storage for SQL Notebooks with SQLite"
published: false
description: "Why I chose SQLite over JSON files for notebook storage, how I designed the schema, and the tradeoffs involved in storing pinned query results as JSON columns."
tags: sqlite, electron, architecture, typescript
series: "Building data-peek"
cover_image:
---
# Designing Local-First Storage for SQL Notebooks with SQLite
data-peek already uses SQLite for connection configs, query history, and saved queries — all managed through `better-sqlite3` in the Electron main process. When I added notebooks, I had a choice: extend the existing database, or give notebooks their own file.
I went with a separate file: `notebooks.db`. Here's the full reasoning, and the design decisions that followed.
## Why Not electron-store?
The simple path would have been `electron-store`, which is what I use for most app settings. It persists JSON to disk, handles serialization automatically, and has a clean API. For most features it's fine.
Notebooks don't fit the JSON file model for three reasons:
**Relational data.** A notebook has many cells, and operations on cells need to not clobber the whole notebook. With JSON files, every cell edit rewrites the entire notebook object. With SQLite, `UPDATE notebook_cells SET content = ? WHERE id = ?` touches one row.
**Large pinned results.** A pinned query result can be thousands of rows. Storing that inside a JSON file means deserializing the entire notebook on load just to render the title. With SQLite, pinned results are a column in `notebook_cells` — they're fetched only when needed.
**Individual cell writes.** The auto-save pattern I wanted — debounced 500ms writes per cell — doesn't compose well with a JSON file. If two cells are being edited simultaneously (or close in time), the writes race. With SQLite and individual `UPDATE` statements, concurrent cell writes are serialized by the database without data loss.
## The Schema
Two tables, intentionally simple:
```sql
CREATE TABLE IF NOT EXISTS notebooks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
connection_id TEXT NOT NULL,
folder TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS notebook_cells (
id TEXT PRIMARY KEY,
notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK(type IN ('sql', 'markdown')),
content TEXT NOT NULL DEFAULT '',
pinned_result TEXT,
order_index INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notebook_cells_notebook_id
ON notebook_cells(notebook_id);
CREATE INDEX IF NOT EXISTS idx_notebooks_updated_at
ON notebooks(updated_at DESC);
```
A few decisions worth explaining:
**IDs as TEXT UUIDs.** I'm using `randomUUID()` from Node's built-in `crypto` module. This keeps IDs stable across duplication and future sync scenarios — integer auto-increment IDs would collide when merging notebooks from two machines.
**`order_index` as a float.** Not explicitly in the schema (it's `INTEGER DEFAULT 0`), but in practice I use fractional values to insert cells between existing ones without renumbering. When you insert after index 3 and before index 4, the new cell gets `order = 3.5`. This avoids a full reindex on every insertion. The `reorderCells` operation normalizes all indices back to integers using a transaction.
**`updated_at` on notebooks.** This gets bumped by `touchNotebook()` on every cell mutation. The notebook list is sorted by `updated_at DESC`, so recently-edited notebooks float to the top without reading cell data.
**`ON DELETE CASCADE`.** Deleting a notebook deletes all its cells atomically. I chose the database constraint over application-level cleanup to prevent orphaned cells if the app crashes mid-delete.
## Pinned Results as JSON
The `pinned_result` column is `TEXT` — a serialized JSON blob. The `PinnedResult` type:
```typescript
interface PinnedResult {
columns: string[]
rows: unknown[][]
rowCount: number
executedAt: number
durationMs: number
error: string | null
}
```
I considered a separate `pinned_results` table. The argument for it: you could index by `executed_at`, store large result sets more efficiently with compression, and keep the cells table lean.
I went with the JSON column for three reasons:
1. **Query simplicity.** `SELECT * FROM notebook_cells WHERE notebook_id = ?` gives me everything in one query. A join would add complexity without any practical benefit for the access patterns I have.
2. **Notebook portability.** The `.dpnb` export format includes pinned results inline. Having them in a separate table would require a join on export, and the mismatch between storage shape and export shape would create more conversion code.
3. **Result sets are bounded.** The cell renders at most 100 rows in the UI (`MAX_DISPLAY_ROWS = 100`). The export Markdown table caps at 50. So while pinned results can hold more, the practical size is bounded.
The tradeoff: if `pinned_result` JSON is corrupt (truncated write, manual editing), parsing fails silently and returns null. The `parsePinnedResult` function handles this:
```typescript
function parsePinnedResult(raw: string): PinnedResult | null {
try {
return JSON.parse(raw) as PinnedResult
} catch {
log.warn('Corrupt pinned_result JSON, falling back to null')
return null
}
}
```
The notebook still opens and works — you just lose the pinned result. Given that pinned results are non-critical (you can always re-run the query), silent fallback is the right behavior.
## Transactions for Reorder and Duplicate
Two operations require transactions: cell reordering and notebook duplication.
**Reordering** updates `order_index` for every cell in a notebook. Without a transaction, a crash midway through leaves cells with inconsistent ordering. The transaction wraps the entire batch update:
```typescript
reorderCells(notebookId: string, orderedIds: string[]): void {
const reorderInTransaction = this.db.transaction(() => {
for (let i = 0; i < orderedIds.length; i++) {
this.db
.prepare('UPDATE notebook_cells SET order_index = ? WHERE id = ? AND notebook_id = ?')
.run(i, orderedIds[i], notebookId)
}
this.touchNotebook(notebookId)
})
reorderInTransaction()
}
```
**Duplication** creates a new notebook row and copies every cell row atomically:
```typescript
const duplicateInTransaction = this.db.transaction(() => {
const newNotebookId = randomUUID()
// INSERT new notebook...
for (const cell of original.cells) {
const newCellId = randomUUID()
// INSERT new cell with new IDs...
}
return newNotebookId
})
const newId = duplicateInTransaction()
```
`better-sqlite3` exposes `db.transaction()` as a higher-order function that wraps any function in an implicit `BEGIN`/`COMMIT` with automatic rollback on throw. It's synchronous (no `await`), which matches the rest of `better-sqlite3`'s API style.
## WAL Mode
The database is opened with `PRAGMA journal_mode = WAL`. WAL (Write-Ahead Logging) allows readers to proceed concurrently with a writer without blocking. In Electron, this matters because:
The main process handles all database writes via IPC. When a user is editing a cell (generating 500ms debounced writes), and simultaneously the app is loading schema data or running a query in another tab, the concurrent access is safe. In default journal mode, writes would acquire an exclusive lock that blocks all readers.
```typescript
constructor(userDataPath: string) {
const dbPath = join(userDataPath, 'notebooks.db')
this.db = new Database(dbPath)
this.db.pragma('journal_mode = WAL')
this.db.pragma('foreign_keys = ON')
this.init()
}
```
I also enable `foreign_keys = ON` explicitly. SQLite doesn't enforce foreign keys by default — you have to opt in per connection. With it enabled, deleting a notebook cascades to its cells at the database level.
## The Thin Tab + Rich Store Pattern
The tab system in data-peek stores minimal state per tab. A notebook tab holds only a `notebookId` and a `connectionId`:
```typescript
interface NotebookTab {
id: string
type: 'notebook'
notebookId: string
connectionId: string | null
label: string
}
```
When the tab renders, it calls `loadNotebook(notebookId)` which fetches the full `NotebookWithCells` from IPC and drops it into the Zustand store. All cell state, loading state, and save state live in `notebook-store.ts`.
This keeps the tab serialization small (tabs are persisted to electron-store for session restore), and means multiple tabs could theoretically share the same store state — though in practice you'd only have one notebook open at a time per ID.
## How Auto-Save Works
The auto-save flow is split into two operations: an optimistic in-memory update and a debounced IPC write.
When a user types in a cell:
1. `updateCellContent(cellId, content)` fires immediately — a synchronous Zustand mutation that updates the in-memory cell. The UI reflects the change instantly.
2. A 500ms debounce timer is started. If the user keeps typing, the timer resets.
3. When the timer fires, `flushCellContent(cellId, content)` calls `window.api.notebooks.updateCell` via IPC. The main process runs `UPDATE notebook_cells SET content = ? WHERE id = ?`.
4. On success, `lastSavedAt` is updated in the store. The header shows "Saved 3s ago".
```typescript
const handleContentChange = useCallback(
(value: string) => {
updateCellContent(cell.id, value)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
flushCellContent(cell.id, value)
}, 500)
},
[cell.id, updateCellContent, flushCellContent]
)
```
The debounce ref is per-cell (stored on the cell component via `useRef`), so editing multiple cells in quick succession doesn't collapse into a single write — each cell manages its own timer.
## The Export Format
The `.dpnb` format is designed to be portable and git-friendly:
```typescript
export function exportAsDpnb(notebook: NotebookWithCells): string {
const file: DpnbFile = {
version: 1,
title: notebook.title,
folder: notebook.folder,
cells: notebook.cells.map((cell) => ({
type: cell.type,
content: cell.content,
...(cell.pinnedResult !== null ? { pinnedResult: cell.pinnedResult } : {})
}))
}
return JSON.stringify(file, null, 2)
}
```
The `version: 1` field is there for forward compatibility. If I change the format, the importer can detect which version it's reading and apply a migration.
Connection information is intentionally excluded from exports. A `.dpnb` file contains queries and documentation, not credentials. When you import a notebook, you choose which connection to bind it to.
The Markdown export is a second format for sharing outside data-peek. It renders pinned results as pipe tables:
```
# Daily Checks
## Active Users
```sql
SELECT COUNT(*) FROM events WHERE ...
```
| count |
| --- |
| 1234 |
*Last run: 2024-01-01T00:00:00.000Z (12ms, 1 rows)*
```
This makes notebooks useful even for people who don't have data-peek installed.

View file

@ -0,0 +1,297 @@
---
title: "Lazy-Loading Monaco Editor in a Cell-Based Notebook UI"
published: false
description: "How I solved the performance problem of multiple Monaco editor instances in a notebook UI by only mounting the editor on the focused cell."
tags: react, monaco, performance, typescript
series: "Building data-peek"
cover_image:
---
# Lazy-Loading Monaco Editor in a Cell-Based Notebook UI
The first version of the notebook UI mounted a Monaco editor for every SQL cell. It worked for 3 cells. With 10 cells it was sluggish. With 20 it was genuinely bad — slow initial render, janky scrolling, and memory usage that climbed noticeably on older machines.
Monaco is a full code editor. It initializes a worker thread for language services, builds a virtual DOM for the editor viewport, and manages its own event loop. One instance is fine. Twenty concurrent instances is not.
I needed a different approach.
## The Solution: One Live Editor at a Time
The core insight is that you can only type in one cell at a time. A notebook might have 30 cells, but only the focused cell needs a live Monaco instance. Every other cell just needs to show its content.
The implementation splits each SQL cell into two states:
- **Focused and editing**: a `<textarea>` (or Monaco for future enhancement) handles input
- **Unfocused**: a static `<pre>` element renders the content
This is simpler than it sounds, but there are a few subtleties in how focus, state, and keyboard navigation interact.
## Cell Focus Management
Focus state is managed in the parent `NotebookEditor` component, not in individual cells. A single `focusedCellIndex` integer tracks which cell is active:
```typescript
export function NotebookEditor({ tab }: NotebookEditorProps) {
const [focusedCellIndex, setFocusedCellIndex] = useState<number>(0)
// ...
return (
<>
{cells.map((cell, index) => (
<NotebookCell
key={cell.id}
cell={cell}
isFocused={focusedCellIndex === index}
onFocus={() => setFocusedCellIndex(index)}
// ...
/>
))}
</>
)
}
```
Each `NotebookCell` receives `isFocused: boolean` as a prop. When `isFocused` is false, the cell renders its content as a static `<pre>` (for SQL) or rendered Markdown (for Markdown cells). When `isFocused` becomes true, the cell can enter edit mode.
There's a secondary bit of state inside each cell: `isEditing`. Focusing a cell doesn't immediately activate the editor — you have to click or press Enter to start typing. This matches Jupyter's modal behavior: a cell can be "selected" (focused) without being in "edit mode."
```typescript
const NotebookCell = memo(function NotebookCell({ cell, isFocused, onFocus, ... }) {
const [isEditing, setIsEditing] = useState(false)
useEffect(() => {
if (!isFocused) {
setIsEditing(false)
}
}, [isFocused])
// SQL cell render logic:
// isEditing && isFocused → textarea (live editor)
// otherwise → <pre> (static view)
})
```
The `useEffect` on `isFocused` is important: when another cell takes focus, this cell exits edit mode automatically. Without this, you could have a cell stuck in edit mode after focus moved elsewhere.
## The Static Pre Element
When a cell isn't being edited, it renders as a `<pre>` with a click handler:
```typescript
<pre
className={cn(
'font-mono text-sm text-foreground whitespace-pre-wrap break-all cursor-text min-h-[1.5rem]',
!cell.content && 'text-muted-foreground/40 italic'
)}
onClick={(e) => {
e.stopPropagation()
onFocus()
setIsEditing(true)
}}
>
{cell.content || 'Click to edit SQL…'}
</pre>
```
The `cursor-text` class signals that this is editable. Clicking it calls `onFocus()` (updates the parent's `focusedCellIndex`) and sets `isEditing(true)` locally. The two calls happen synchronously in the same event handler, so there's no visible flash.
For Markdown cells, the unfocused state renders through `ReactMarkdown` instead of `<pre>`. The click handler is on the outer `div`:
```typescript
<div
className="prose prose-sm dark:prose-invert max-w-none cursor-text"
onClick={(e) => {
e.stopPropagation()
onFocus()
setIsEditing(true)
}}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{cell.content}</ReactMarkdown>
</div>
```
## The Keyboard Model
The notebook has two layers of keyboard handling:
**Cell-level (inside a textarea):**
```typescript
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
e.preventDefault()
setIsEditing(false)
return
}
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault()
runQuery().then(() => onRunAndAdvance())
return
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
runQuery()
return
}
},
[runQuery, onRunAndAdvance]
)
```
`Shift+Enter` runs the query and calls `onRunAndAdvance`, which increments `focusedCellIndex` in the parent. This moves focus to the next cell automatically — the Jupyter "run and go to next" UX.
`Cmd+Enter` runs the query without moving focus — useful when you're iterating on a single query.
`Escape` exits edit mode, leaving the cell focused but not editing. This is the Jupyter "command mode" equivalent.
**Notebook-level (global keydown listener):**
```typescript
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
if (e.key === 'j') {
e.preventDefault()
setFocusedCellIndex((prev) => Math.min(prev + 1, cells.length - 1))
} else if (e.key === 'k') {
e.preventDefault()
setFocusedCellIndex((prev) => Math.max(prev - 1, 0))
} else if (e.shiftKey && e.key === 'D') {
e.preventDefault()
const cell = cells[focusedCellIndex]
if (cell) handleDeleteCell(cell.id, focusedCellIndex)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [cells, focusedCellIndex, handleDeleteCell])
```
`Cmd+J` and `Cmd+K` navigate between cells. I chose J/K over arrow keys because arrow keys inside a textarea move the cursor, and intercepting them would require tracking cursor position. J/K only fires with the modifier, so they don't interfere with typing.
The global listener has a closure over `cells` and `focusedCellIndex`, so it needs to be re-registered when those change. The cleanup function (`removeEventListener`) ensures there's never more than one active listener.
## Between-Cell Insert Points
One UX problem with a cell list: how do you insert a cell between two existing cells? A button at the bottom only inserts at the end. A button on each cell is visually noisy.
I borrowed the pattern from Notion: insert points appear on hover between cells. They're invisible by default and expand on mouse enter:
```typescript
function InsertPoint({ onInsert }: InsertPointProps) {
const [isHovered, setIsHovered] = useState(false)
return (
<div
className={cn(
'flex items-center gap-2 py-0.5 transition-all duration-150',
isHovered ? 'opacity-100' : 'opacity-0 hover:opacity-100'
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex-1 h-px bg-border/40" />
<div className="flex gap-1">
<button onClick={() => onInsert('sql')}>+ SQL</button>
<button onClick={() => onInsert('markdown')}>+ Note</button>
</div>
<div className="flex-1 h-px bg-border/40" />
</div>
)
}
```
An `InsertPoint` appears before the first cell and after every cell. The `handleAddCell` function in the editor uses the `insertAfterIndex` parameter to calculate `order`:
```typescript
const handleAddCell = useCallback(
(type: 'sql' | 'markdown', insertAfterIndex?: number) => {
if (!activeNotebook) return
const order =
insertAfterIndex !== undefined && cells[insertAfterIndex]
? cells[insertAfterIndex].order + 0.5
: cells.length
addCell(activeNotebook.id, { type, content: '', order })
setFocusedCellIndex(insertAfterIndex !== undefined ? insertAfterIndex + 1 : cells.length)
},
[activeNotebook, cells, addCell]
)
```
The fractional `order` value (`existingOrder + 0.5`) lets new cells land between existing ones without renumbering everything. The next `reorderCells` call (e.g. on drag-drop) normalizes all indices back to integers.
## Auto-Save Integration
The debounced auto-save runs inside each cell's `handleContentChange`:
```typescript
const handleContentChange = useCallback(
(value: string) => {
updateCellContent(cell.id, value)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
flushCellContent(cell.id, value)
}, 500)
},
[cell.id, updateCellContent, flushCellContent]
)
```
`updateCellContent` is a synchronous Zustand mutation — the UI reflects the new content immediately. `flushCellContent` fires 500ms later via IPC and writes to SQLite. The `debounceRef` is local to the cell component via `useRef`, so each cell manages its own debounce timer independently.
One subtlety: the `useCallback` dependency array includes `cell.id`. If a cell is deleted and re-created with the same position but a new ID (which doesn't happen in the current implementation, but is a future risk), the callback will correctly use the new ID because the component re-mounts.
## Result Pinning UX
After running a SQL cell, a "Pin result" option appears in the cell's dropdown menu. Pinning captures the current `liveResult` into a `PinnedResult` object and persists it via IPC:
```typescript
const handlePinResult = useCallback(() => {
if (!liveResult) return
const pinned: PinnedResult = {
columns: liveResult.fields.map((f) => f.name),
rows: liveResult.rows,
rowCount: liveResult.rows.length,
executedAt: Date.now(),
durationMs: durationMs ?? 0,
error: null
}
pinResult(cell.id, pinned)
}, [cell.id, liveResult, durationMs, pinResult])
```
The cell's result area follows this priority logic:
1. If there's a live result (just ran): show it
2. Else if there's a pinned result: show it with a "Pinned" badge and timestamp
3. Else if idle: show the "not yet executed" hint
This means re-running always shows fresh results, but the pinned result is always visible as a fallback. The "Unpin" option appears in the same menu to remove it.
## React.memo for Cell List Performance
Every cell is wrapped in `React.memo`:
```typescript
export const NotebookCell = memo(function NotebookCell({ ... }) {
// ...
})
```
Without memo, every `focusedCellIndex` change would re-render all cells. With memo, cells only re-render when their own props change. The only prop that changes when you navigate between cells is `isFocused` — and that only changes on the two cells involved in the transition (the one losing focus and the one gaining it).
This makes Cmd+J/K navigation fast even with 30+ cells. The alternative — not using memo — would re-render the entire cell list on every keypress.
## What I'd Do Differently
The `textarea` approach works well but lacks SQL syntax highlighting in edit mode. The original goal was to use Monaco for the focused cell and `<pre>` for everything else — one live Monaco instance, swapped between cells on focus. That's still the right architectural direction.
The challenge is that Monaco requires a DOM container with stable dimensions to initialize correctly. If the container isn't visible yet when `monaco.create()` is called, the editor renders incorrectly. Solving this requires either prerendering the container (hidden, not unmounted) or carefully sequencing the focus transition before the Monaco initialization.
For now, the `textarea` approach is good enough — it's lightweight, has no initialization cost, and supports all the keyboard shortcuts. If syntax highlighting becomes a frequent request, the architecture already supports it: swap the `textarea` for a lazily-initialized Monaco instance on the focused cell, keep everything else as-is.

View file

@ -0,0 +1,92 @@
# SQL Notebooks — Demo Script
Recording guide for the SQL Notebooks feature demo video.
## Setup
- Database: `localhost:5433` / `acme_saas` / `demo` / `demo` (Docker container `datapeek-demo`)
- Start container: `docker start datapeek-demo`
- Seed file: `seeds/acme_saas_seed.sql`
- Demo runbook: `seeds/demo-runbook.dpnb` (import this or recreate live)
## Demo Flow (3-4 minutes)
### Scene 1: Create a Notebook (30s)
1. Open data-peek, connect to the ACME SaaS database
2. In the sidebar, show the **Notebooks** section
3. Click **+** to create a new notebook
4. Title it "ACME SaaS Health Check"
5. Show it opens as a new tab
### Scene 2: Add Markdown + SQL Cells (60s)
1. The notebook starts empty — click **+ Markdown cell**
2. Type: `# Platform Overview` and a short description
3. Click away — show it renders as formatted markdown
4. Click **+ SQL cell**
5. Type the platform overview query:
```sql
SELECT o.plan, COUNT(DISTINCT o.id) AS orgs,
COUNT(DISTINCT m.user_id) AS users
FROM organizations o
LEFT JOIN memberships m ON m.organization_id = o.id
GROUP BY o.plan ORDER BY orgs DESC
```
6. Press **Shift+Enter** — results appear inline, focus moves to next position
7. Add another markdown cell: "## Revenue Health" with explanation
8. Add another SQL cell with the subscription query
9. **Shift+Enter** through it — show the run-and-advance flow
### Scene 3: Pin Results (30s)
1. On the platform overview cell, click the **...** menu
2. Click **Pin result**
3. Show the "Pinned — ran [date]" header appears
4. Close the notebook tab
5. Reopen from sidebar — pinned result is still there
### Scene 4: Keyboard Navigation (30s)
1. Press **Cmd+J** / **Cmd+K** — show focus moving between cells
2. Press **Enter** on a SQL cell — enters edit mode
3. Press **Escape** — exits back to cell navigation
4. Press **Cmd+Shift+D** — deletes a cell
5. Show the bottom shortcut bar
### Scene 5: Import the Full Runbook (30s)
1. Delete the notebook you just made (or create a new one)
2. Import `seeds/demo-runbook.dpnb` (File → Import or drag to sidebar)
3. Show the full 7-step health check runbook loads
4. **Cmd+Shift+Enter** (Run All) — watch the execution wave flow through each cell
5. Show results appearing one by one
### Scene 6: Export & Share (30s)
1. From the toolbar **...** menu, click **Export as Markdown**
2. Open the exported `.md` file — show it's readable with SQL blocks and result tables
3. Mention: "Send this to a teammate, they can read it without data-peek"
4. Show **Export as .dpnb** — "Or send this, and they can import it and run it themselves"
### Scene 7: Duplicate to Connection (15s)
1. Right-click the notebook in the sidebar
2. Click **Duplicate to connection...**
3. Pick a different connection (e.g. staging)
4. Show the copy appears with "(copy)" suffix
## Key Moments to Highlight
- **Shift+Enter flow** — the Jupyter muscle memory, run-and-advance
- **Pinned results persisting** — close and reopen, data is still there
- **The runbook use case** — "your Notion doc + SQL client, collapsed into one"
- **Export as Markdown** — readable anywhere, no vendor lock-in
## Recording Tips
- Use dark mode (primary design target)
- Keep the sidebar open to show the Notebooks section
- Zoom to ~125% so cell content is readable in the video
- No narration needed if adding captions — keep it tight
- Target 1080p, 60fps for smooth scroll/transitions

View file

@ -0,0 +1,118 @@
# Release Notes: v0.20.0
This document covers the changes from v0.19.0 to v0.20.0.
---
## SQL Notebooks
SQL Notebooks is a major new feature that brings a Jupyter-style cell-based editor to data-peek. Create documents that mix executable SQL cells and Markdown cells in a single view — the primary use case is team runbooks, like a shared "how to debug stuck payments" guide that actually runs the queries.
---
## What's New
### SQL + Markdown Cells
Notebooks are composed of two cell types:
- **SQL cells** — a Monaco editor with inline results displayed directly below the query. Results show row count, execution duration, and a full scrollable result table.
- **Markdown cells** — rich text with GitHub Flavored Markdown (tables, task lists, strikethrough). Click to edit, click away to render.
Add cells from the toolbar or by hovering between existing cells — a `+` button appears on the dividing line and lets you pick the type.
### Pinned Results
Run a diagnostic query and pin the output. Pinned results persist across sessions in SQLite, so when you reopen a notebook the last known state is always visible — useful for runbooks that track a baseline or show an example of what "broken" looks like.
Pin via the cell overflow menu. An optional notebook-level "Auto-pin results" setting pins every successful run automatically.
### Folders for Organization
Group notebooks into folders from the sidebar. Folders are lightweight — they exist implicitly when any notebook references them. No separate folder management; remove the last notebook from a folder and the folder disappears.
### Export
- **Export as `.dpnb`** — a portable JSON format that includes all cells and pinned results. Reimportable into any data-peek instance. Connection IDs are stripped on export; you pick which connection to bind on import.
- **Export as Markdown** — SQL cells become fenced `sql` code blocks, pinned results become Markdown tables, Markdown cells export as-is. Readable without data-peek.
Import `.dpnb` files from File → Import Notebook, or drag a `.dpnb` file onto the sidebar.
### Duplicate to Connection
Right-click a notebook in the sidebar → "Duplicate to connection..." — pick any of your saved connections. Creates a full copy with all cells and pinned results bound to the new connection. The original is unchanged. Useful for running a production runbook against staging.
### Sidebar Integration
A "Notebooks" section appears below Saved Queries in the sidebar. Each entry shows the notebook title, connected database name, and last-edited timestamp. The existing sidebar search filters notebooks alongside tables and saved queries.
---
## Keyboard Shortcuts
| Shortcut | Action |
|---|---|
| `Shift+Enter` | Run cell and move focus to next cell |
| `Cmd+Enter` | Run cell, stay in place |
| `Cmd+Shift+Enter` | Run All (top to bottom, sequential) |
| `Cmd+J` | Move focus to next cell |
| `Cmd+K` | Move focus to previous cell |
| `Enter` | Enter editor mode on focused cell |
| `Escape` | Exit editor, return to cell-level navigation |
| `Cmd+Shift+D` | Delete focused cell |
Execution stops on the first error — if a diagnostic SELECT fails, the UPDATE cells below won't run.
---
## How to Use
### Quick Start
1. Open the Notebooks section in the sidebar and click `+` to create a notebook.
2. Give it a title. The notebook binds to your active connection.
3. Add a Markdown cell to describe what the notebook does.
4. Add a SQL cell and write your query.
5. Press `Shift+Enter` to run and advance — or `Cmd+Enter` to run in place.
6. Pin a result via the cell `...` menu if you want it to persist across sessions.
### Building a Runbook
A typical runbook pattern:
1. Markdown cell — context and prerequisites ("Run this when payments stop processing")
2. SQL cell — diagnostic query ("Show all payments stuck in `processing` state")
3. Markdown cell — what to look for in the results
4. SQL cell — follow-up query or remediation step
Export as `.dpnb` and share with your team. They import it, pick their connection, and the whole runbook is ready to run.
### Running on Multiple Environments
Right-click a notebook → "Duplicate to connection..." to create a version bound to your staging or dev database. Run the same queries against different environments without maintaining separate notebooks.
---
## Technical Notes
- Cell content auto-saves with a 500ms debounce — no save button needed. The toolbar shows "Saved [time ago]".
- Only the focused cell loads a Monaco instance. Unfocused cells render as static `<pre>` elements — notebooks with many cells stay lightweight.
- Result tables use the same virtualized rendering as query tabs. Large result sets don't slow down the notebook.
- Pinned results are capped at 500 rows per cell.
- Run All executes SQL cells sequentially and skips Markdown cells.
---
## Stats Summary
- New tab type: `notebook`
- New IPC namespace: `window.api.notebooks`
- New SQLite tables: `notebooks`, `notebook_cells`
- New stores: `notebook-store.ts`
- New components: `NotebookTab`, `NotebookCell`, `NotebookSidebar`
---
## Upgrade
Download the latest release from the [releases page](https://github.com/Rohithgilla12/data-peek/releases) or the app will auto-update if you have v0.19.x installed.

View file

@ -0,0 +1,308 @@
# Social Posts — SQL Notebooks (v0.20.0)
Ready-to-post content for the SQL Notebooks launch.
---
## Twitter/X
### Post 1 — Launch Announcement
```
data-peek v0.20.0: SQL Notebooks.
Mix SQL cells and Markdown in a single document. Run queries inline. Pin results so they survive restarts.
The main use case: team runbooks. "Here's how to debug stuck payments" — with the actual queries that run against your actual database.
→ Export as .dpnb (reimportable) or Markdown (readable anywhere)
→ Duplicate a notebook to a different connection (dev → staging → prod)
→ Folders for organization
→ Jupyter shortcuts: Shift+Enter, Cmd+J/K
datapeek.dev
```
### Post 2 — "Here's what it looks like in practice"
```
Here's what a SQL notebook looks like in practice:
Markdown cell: "Run this when payments stop processing. Check for rows stuck in `processing` state for more than 5 minutes."
SQL cell:
SELECT id, amount, status, created_at
FROM payments
WHERE status = 'processing'
AND created_at < NOW() - INTERVAL '5 minutes'
Pin the result. Next time you open the notebook, the last known baseline is there.
Export it as a .dpnb file. Your teammate imports it, picks their connection, and the whole runbook is ready to go.
That's what SQL Notebooks in data-peek does — datapeek.dev
```
### Post 3 — Developer Tip (Keyboard Shortcuts + Pinning)
```
Some things I learned while building SQL Notebooks for data-peek:
Shift+Enter is the right default. Run and advance — same as Jupyter. You don't think, you just run.
Cmd+J / Cmd+K to move between cells without reaching for the mouse. The notebook starts feeling like the terminal.
Pinned results are underrated. Run a query on prod, pin the output, and now every teammate who opens the notebook can see what "normal" looked like — before they run it again.
Cmd+Shift+Enter runs everything top to bottom. Stops on first error so you don't accidentally run an UPDATE if the diagnostic SELECT above failed.
datapeek.dev
```
---
## Reddit
### r/SQL or r/database — "I added Jupyter-style notebooks to my SQL client"
**Title:** `I added Jupyter-style notebooks to my SQL client (data-peek)`
```
I've been building data-peek — a SQL client for developers who find DBeaver too heavy and DataGrip too expensive for quick work. Today I'm shipping v0.20.0: SQL Notebooks.
**What they are**
Notebooks mix SQL cells and Markdown cells in a single document. SQL cells execute inline and show results directly below the query. Markdown cells render documentation, notes, and instructions. It's the same concept as Jupyter, but wired to your database rather than Python.
**Why I built this**
The thing I kept running into: I'd debug a tricky production issue, figure it out, and then write a Notion doc explaining what to do next time. The doc had SQL in code blocks that you had to copy somewhere else to run. That's friction. A notebook collapses the documentation and the execution into the same place.
**The features that I think matter**
- **Pinned results** — after running a query, pin the output. It persists across sessions. Useful for runbooks that show "here's what the data looks like when this is broken."
- **Duplicate to connection** — right-click a notebook, duplicate it to a different connection. Run the same runbook against dev and staging without maintaining two copies.
- **Export as .dpnb or Markdown** — .dpnb is a JSON format you can reimport; Markdown is a .md file you can read without data-peek.
- **Folders** — organize notebooks into groups. Folders are implicit; no separate management UI.
**Keyboard shortcuts** (follow Jupyter conventions)
- `Shift+Enter` — run cell, advance focus
- `Cmd+Enter` — run cell, stay in place
- `Cmd+Shift+Enter` — run all (stops on first error)
- `Cmd+J` / `Cmd+K` — move focus down / up
**Tech note** — only the focused cell loads Monaco. Unfocused cells render as static `<pre>`. Notebooks with 20+ cells don't become sluggish.
**Pricing** — free for personal use, $29 one-time for commercial.
Website: https://datapeek.dev
GitHub: https://github.com/Rohithgilla12/data-peek
Happy to answer questions. What kinds of notebooks or runbooks would you actually use this for?
```
---
### r/programming — "SQL notebooks in a desktop client: technical implementation notes"
**Title:** `SQL notebooks in a desktop Electron app: some implementation notes`
```
I just shipped SQL Notebooks for data-peek (Electron + React SQL client). A few decisions that might be interesting if you're building something similar.
**Cell architecture: thin tab, rich store**
The notebook tab holds only a `notebookId`. All cell state lives in a Zustand store backed by SQLite via IPC. Cell content auto-saves on a 500ms debounce — no explicit save button, toolbar shows "Saved 2m ago". This is the same pattern as saved queries in the app.
**Monaco only on focus**
Loading Monaco for every cell in a notebook would be expensive. Unfocused cells render as syntax-highlighted `<pre>` elements. On focus, Monaco replaces the `<pre>` lazily. This keeps notebooks with many cells fast.
**Pinned results in SQLite**
After executing a SQL cell, you can pin the result. It serializes to JSON and stores in a `notebook_cells.pinned_result` column (capped at 500 rows). On next load, the pinned result renders before any query is run. Useful for runbooks where you want to show the last known state.
**Run All stops on first error**
When you "Run All", SQL cells execute sequentially and Markdown cells are skipped. Execution stops on the first error. This is intentional — if a diagnostic SELECT fails, you don't want an UPDATE to run below it.
**Export formats**
`.dpnb` is a JSON file: version field, title, cells array. Connection IDs are stripped (they're machine-local); on import the user picks which connection to bind. Markdown export renders SQL cells as fenced code blocks and pinned results as Markdown tables.
**Result tables**
Same virtualized rendering as the main query tab — TanStack Virtual. Large result sets in notebooks don't cause DOM bloat.
Stack: Electron, React, TypeScript, Monaco, Zustand, better-sqlite3, TanStack Table + Virtual.
https://datapeek.dev | https://github.com/Rohithgilla12/data-peek
Curious if anyone's done something similar and ran into edge cases I haven't thought about yet.
```
---
## Dev.to / Blog Teaser
**Post title:** `SQL Notebooks in data-peek — mix queries and docs in one place`
```
data-peek v0.20.0 ships SQL Notebooks: a Jupyter-style editor that mixes executable SQL cells and Markdown cells in a single document.
The idea came from a workflow problem. When debugging a production issue, you end up with two separate things: the Notion doc explaining what happened, and the SQL queries you run to diagnose it. The doc has code blocks you copy somewhere else to execute. That's unnecessary friction.
SQL Notebooks collapse them. Write the explanation in a Markdown cell. Put the actual query in a SQL cell right below it. Run it inline. Pin the result so it's there when your teammate opens the notebook.
Key things in this release:
- SQL + Markdown cells with inline results
- Pinned results that persist across sessions
- Export as `.dpnb` (reimportable JSON) or Markdown (plain .md, readable anywhere)
- Duplicate a notebook to a different connection — same runbook, different environment
- Jupyter keyboard shortcuts (Shift+Enter, Cmd+J/K, Cmd+Shift+Enter for Run All)
- Folders for organization
- Monaco lazy-loaded per cell — notebooks with many cells stay fast
I wrote about the implementation decisions in more depth on the blog: [link].
data-peek is free for personal use, $29 one-time for commercial. macOS, Windows, Linux.
datapeek.dev
```
---
## Threads
### Post 1 — Launch (carousel-style thread)
```
SQL Notebooks in data-peek.
Mix SQL and Markdown in one document. Run queries inline. Pin results.
Your debugging runbooks become executable.
```
Reply 1:
```
The use case that made me build this:
Every time I debug a production issue, I write a Notion doc with the steps. The doc has SQL in code blocks that you copy-paste into a query tool.
Why are those two things separate?
Now they're not. The documentation IS the query tool.
```
Reply 2:
```
What "pinning" actually does:
Run a query. See results. Click "Pin."
Close the notebook. Reopen it next week. The pinned result is still there — showing what the data looked like last time.
Your runbook now has a baseline built in.
```
Reply 3:
```
Same runbook, different database:
Right-click → Duplicate to connection.
One notebook for "debug stuck payments." Use it on dev. Use it on staging. Use it on prod.
No copy-paste. No maintaining three versions.
```
Reply 4:
```
Keyboard shortcuts (Jupyter conventions):
Shift+Enter → run cell, move to next
Cmd+Enter → run cell, stay
Cmd+J/K → navigate between cells
Cmd+Shift+Enter → run all (stops on first error)
It feels like a terminal, not a form.
datapeek.dev
```
### Post 2 — Behind the scenes (technical)
```
Building SQL Notebooks taught me a few things about Electron performance:
20 Monaco editors on one page = bad time.
Solution: only the focused cell gets a live Monaco instance. Everything else is a static <pre> with syntax highlighting. Click to activate.
Result: notebooks with 30+ cells scroll smoothly.
```
Reply 1:
```
Storage decision: SQLite over JSON files.
Notebooks have cells. Cells have pinned results (could be 500 rows of data). Updating one cell shouldn't rewrite the entire notebook.
better-sqlite3 with WAL mode. Two tables. Foreign key cascades. Individual cell updates.
```
Reply 2:
```
The export format is a JSON file called .dpnb
It strips the connection ID (that's machine-local). When your teammate imports it, they pick which database to connect.
Or export as Markdown — readable on GitHub, Notion, anywhere. SQL in fenced code blocks, pinned results as tables.
```
### Post 3 — Runbook showcase
```
Here's an actual runbook I use:
"ACME SaaS Health Check"
Step 1: Platform overview (orgs by plan)
Step 2: Revenue health (subscription status + MRR)
Step 3: Unpaid invoices
Step 4: Recent activity (event log)
Step 5: Top orgs by usage
Step 6: Stale API keys
Step 7: Enterprise deep dive
7 SQL cells, 7 Markdown cells explaining what to look for. Pin results as you go. Takes 30 seconds to run through.
This used to be a Notion doc + 7 browser tabs.
```
### Post 4 — Hot take / conversation starter
```
Hot take: most SQL clients are designed for DBAs, not developers.
Developers don't need server management panels. They need to quickly check data, run a query, and get back to their code.
That's why data-peek exists. And SQL Notebooks are the latest example — runbooks for developers, not database reports for managers.
datapeek.dev
```
---
## General Notes
1. Post Reddit threads on weekday mornings (911am US Eastern). r/sql and r/database are lower-traffic; the technical angle in r/programming tends to do better there.
2. Screenshots and a short screen recording of notebook execution will significantly improve engagement on Twitter/X and Threads.
3. The r/programming post is technical by design — that audience responds better to implementation details than feature lists.
4. Don't cross-post identical content across subreddits simultaneously.
5. Engage with every reply in the first hour.
6. **Threads strategy:** Post the carousel-style thread (Post 1) as the launch post. The technical post (Post 2) can go out 2-3 days later to stay in feeds without flooding. The runbook showcase (Post 3) works well as a weekend post. Post 4 is a standalone conversation starter — save it for a slow day.
7. **Threads format:** Each reply in a thread is its own mini-post. Keep each one self-contained — people scroll past the first one and should still understand the reply they land on.

View file

@ -1,3 +1,14 @@
export type {
Notebook,
NotebookCell,
PinnedResult,
NotebookWithCells,
CreateNotebookInput,
UpdateNotebookInput,
AddCellInput,
UpdateCellInput
} from './notebook-types'
export { MAX_PINNED_ROWS } from './notebook-types'
export { PG_TYPE_MAP, resolvePostgresType } from "./type-maps";
export {
escapeSQLValue,

View file

@ -0,0 +1,56 @@
export interface Notebook {
id: string
title: string
connectionId: string
folder: string | null
createdAt: number
updatedAt: number
}
export interface NotebookCell {
id: string
notebookId: string
type: 'sql' | 'markdown'
content: string
pinnedResult: PinnedResult | null
order: number
createdAt: number
updatedAt: number
}
export interface PinnedResult {
columns: string[]
rows: unknown[][]
rowCount: number
executedAt: number
durationMs: number
error: string | null
}
export interface NotebookWithCells extends Notebook {
cells: NotebookCell[]
}
export interface CreateNotebookInput {
title: string
connectionId: string
folder?: string | null
}
export interface UpdateNotebookInput {
title?: string
folder?: string | null
}
export interface AddCellInput {
type: 'sql' | 'markdown'
content: string
order: number
}
export interface UpdateCellInput {
content?: string
pinnedResult?: PinnedResult | null
}
export const MAX_PINNED_ROWS = 500

View file

@ -195,9 +195,15 @@ importers:
react-grid-layout:
specifier: ^2.1.0
version: 2.1.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.7)(react@19.2.0)
recharts:
specifier: ^3.5.1
version: 3.5.1(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react-is@16.13.1)(react@19.2.0)(redux@5.0.1)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
sql-formatter:
specifier: ^15.6.10
version: 15.6.10
@ -449,13 +455,13 @@ importers:
dependencies:
'@clerk/nextjs':
specifier: ^6.35.5
version: 6.35.5(next@16.0.7(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
version: 6.35.5(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.7)(react@19.2.0)
'@sentry/nextjs':
specifier: ^10.47.0
version: 10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(webpack@5.105.0(esbuild@0.25.12))
version: 10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(webpack@5.105.0)
'@vercel/analytics':
specifier: ^2.0.1
version: 2.0.1(next@16.0.7(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
@ -567,7 +573,7 @@ importers:
dependencies:
'@clerk/nextjs':
specifier: ^6.35.5
version: 6.35.5(next@16.0.7(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
version: 6.35.5(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@clerk/themes':
specifier: ^2.4.57
version: 2.4.57(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@ -7104,6 +7110,9 @@ packages:
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@ -9013,6 +9022,12 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-medium-image-zoom@5.4.3:
resolution: {integrity: sha512-cDIwdn35fRUPsGnnj/cG6Pacll+z+Mfv6EWU2wDO5ngbZjg5uLRb2ZhEnh92ufbXCJDFvXHekb8G3+oKqUcv5g==}
peerDependencies:
@ -10895,7 +10910,7 @@ snapshots:
react-dom: 19.2.0(react@19.2.0)
tslib: 2.8.1
'@clerk/nextjs@6.35.5(next@16.0.7(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
'@clerk/nextjs@6.35.5(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@clerk/backend': 2.24.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@clerk/clerk-react': 5.57.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@ -13633,7 +13648,7 @@ snapshots:
'@sentry/core@10.47.0': {}
'@sentry/nextjs@10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(webpack@5.105.0(esbuild@0.25.12))':
'@sentry/nextjs@10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(webpack@5.105.0)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.40.0
@ -13645,7 +13660,7 @@ snapshots:
'@sentry/opentelemetry': 10.47.0(@opentelemetry/api@1.9.1)(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)
'@sentry/react': 10.47.0(react@19.2.0)
'@sentry/vercel-edge': 10.47.0
'@sentry/webpack-plugin': 5.1.1(encoding@0.1.13)(webpack@5.105.0(esbuild@0.25.12))
'@sentry/webpack-plugin': 5.1.1(encoding@0.1.13)(webpack@5.105.0)
next: 16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
rollup: 4.53.3
stacktrace-parser: 0.1.11
@ -13735,11 +13750,11 @@ snapshots:
'@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1)
'@sentry/core': 10.47.0
'@sentry/webpack-plugin@5.1.1(encoding@0.1.13)(webpack@5.105.0(esbuild@0.25.12))':
'@sentry/webpack-plugin@5.1.1(encoding@0.1.13)(webpack@5.105.0)':
dependencies:
'@sentry/bundler-plugin-core': 5.1.1(encoding@0.1.13)
uuid: 9.0.1
webpack: 5.105.0(esbuild@0.25.12)
webpack: 5.105.0(esbuild@0.25.0)
transitivePeerDependencies:
- encoding
- supports-color
@ -17323,6 +17338,8 @@ snapshots:
html-to-image@1.11.13: {}
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
htmlparser2@10.0.0:
@ -19593,6 +19610,24 @@ snapshots:
react-is@16.13.1: {}
react-markdown@10.1.0(@types/react@19.2.7)(react@19.2.0):
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/react': 19.2.7
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.2.0
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.1.0
vfile: 6.0.3
transitivePeerDependencies:
- supports-color
react-medium-image-zoom@5.4.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
@ -20657,16 +20692,6 @@ snapshots:
optionalDependencies:
esbuild: 0.25.0
terser-webpack-plugin@5.4.0(esbuild@0.25.12)(webpack@5.105.0(esbuild@0.25.12)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.46.1
webpack: 5.105.0(esbuild@0.25.12)
optionalDependencies:
esbuild: 0.25.12
terser@5.46.1:
dependencies:
'@jridgewell/source-map': 0.3.11
@ -21230,38 +21255,6 @@ snapshots:
- esbuild
- uglify-js
webpack@5.105.0(esbuild@0.25.12):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
'@types/json-schema': 7.0.15
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.15.0
acorn-import-phases: 1.0.4(acorn@8.15.0)
browserslist: 4.28.1
chrome-trace-event: 1.0.4
enhanced-resolve: 5.20.1
es-module-lexer: 2.0.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.1
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.0
terser-webpack-plugin: 5.4.0(esbuild@0.25.12)(webpack@5.105.0(esbuild@0.25.12))
watchpack: 2.5.1
webpack-sources: 3.3.4
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
whatwg-encoding@3.1.1:
dependencies:
iconv-lite: 0.6.3

71
seeds/demo-runbook.dpnb Normal file
View file

@ -0,0 +1,71 @@
{
"version": 1,
"title": "ACME SaaS Health Check Runbook",
"folder": "runbooks",
"cells": [
{
"type": "markdown",
"content": "# ACME SaaS Health Check Runbook\n\nUse this notebook to diagnose issues with the ACME SaaS platform.\nRun each step in order — if something looks off, the markdown cells explain what to look for."
},
{
"type": "markdown",
"content": "## Step 1: Platform Overview\n\nGet a quick snapshot of the platform — how many orgs, users, and projects we have, broken down by plan."
},
{
"type": "sql",
"content": "SELECT\n o.plan,\n COUNT(DISTINCT o.id) AS orgs,\n COUNT(DISTINCT m.user_id) AS users,\n COUNT(DISTINCT p.id) AS projects\nFROM organizations o\nLEFT JOIN memberships m ON m.organization_id = o.id\nLEFT JOIN projects p ON p.organization_id = o.id\nGROUP BY o.plan\nORDER BY orgs DESC"
},
{
"type": "markdown",
"content": "## Step 2: Revenue Health\n\nCheck subscription status and MRR. Look for:\n- High `past_due` count = payment processing issues\n- Spike in `canceled` = churn problem\n- Low `trialing` = acquisition slowdown"
},
{
"type": "sql",
"content": "SELECT\n s.status,\n COUNT(*) AS count,\n SUM(i.amount_cents) / 100.0 AS total_revenue_usd\nFROM subscriptions s\nLEFT JOIN invoices i ON i.subscription_id = s.id AND i.status = 'paid'\nGROUP BY s.status\nORDER BY count DESC"
},
{
"type": "markdown",
"content": "## Step 3: Unpaid Invoices\n\nFind invoices that are past due or failed. These need immediate attention from the billing team."
},
{
"type": "sql",
"content": "SELECT\n i.id,\n o.name AS organization,\n o.plan,\n i.amount_cents / 100.0 AS amount_usd,\n i.status,\n i.due_date,\n i.created_at\nFROM invoices i\nJOIN organizations o ON o.id = i.organization_id\nWHERE i.status IN ('pending', 'failed')\nORDER BY i.amount_cents DESC\nLIMIT 20"
},
{
"type": "markdown",
"content": "## Step 4: Recent Activity\n\nCheck the event log for the last 24 hours. Look for unusual patterns:\n- Burst of `api_key.created` = possible security concern\n- No `user.created` events = signup flow might be broken\n- Lots of `subscription.canceled` = churn event"
},
{
"type": "sql",
"content": "SELECT\n type,\n COUNT(*) AS count,\n MIN(created_at) AS earliest,\n MAX(created_at) AS latest\nFROM events\nWHERE created_at > NOW() - INTERVAL '24 hours'\nGROUP BY type\nORDER BY count DESC"
},
{
"type": "markdown",
"content": "## Step 5: Top Organizations by Usage\n\nIdentify the heaviest users — these are the accounts most likely to upgrade (or most at risk if something breaks)."
},
{
"type": "sql",
"content": "SELECT\n o.name,\n o.plan,\n COUNT(DISTINCT m.user_id) AS members,\n COUNT(DISTINCT p.id) AS projects,\n COUNT(DISTINCT ak.id) AS api_keys\nFROM organizations o\nLEFT JOIN memberships m ON m.organization_id = o.id\nLEFT JOIN projects p ON p.organization_id = o.id\nLEFT JOIN api_keys ak ON ak.organization_id = o.id AND ak.revoked_at IS NULL\nGROUP BY o.id, o.name, o.plan\nORDER BY members DESC, projects DESC\nLIMIT 10"
},
{
"type": "markdown",
"content": "## Step 6: Stale API Keys\n\nFind API keys that haven't been used in 90+ days or have expired. These should be flagged for rotation or revocation."
},
{
"type": "sql",
"content": "SELECT\n ak.name AS key_name,\n ak.key_prefix,\n o.name AS organization,\n ak.scopes,\n ak.last_used_at,\n ak.expires_at,\n CASE\n WHEN ak.revoked_at IS NOT NULL THEN 'revoked'\n WHEN ak.expires_at < NOW() THEN 'expired'\n WHEN ak.last_used_at < NOW() - INTERVAL '90 days' THEN 'stale'\n WHEN ak.last_used_at IS NULL THEN 'never used'\n ELSE 'active'\n END AS status\nFROM api_keys ak\nJOIN organizations o ON o.id = ak.organization_id\nWHERE ak.revoked_at IS NULL\n AND (ak.last_used_at IS NULL\n OR ak.last_used_at < NOW() - INTERVAL '90 days'\n OR ak.expires_at < NOW())\nORDER BY ak.last_used_at ASC NULLS FIRST"
},
{
"type": "markdown",
"content": "## Step 7: Enterprise Org Deep Dive\n\nCheck the health of enterprise accounts specifically — these are the highest-value customers."
},
{
"type": "sql",
"content": "SELECT\n o.name,\n o.slug,\n o.billing_email,\n s.status AS sub_status,\n s.current_period_end,\n COUNT(DISTINCT m.user_id) AS team_size,\n o.metadata->>'industry' AS industry\nFROM organizations o\nJOIN subscriptions s ON s.organization_id = o.id\nLEFT JOIN memberships m ON m.organization_id = o.id\nWHERE o.plan = 'enterprise'\nGROUP BY o.id, o.name, o.slug, o.billing_email, s.status, s.current_period_end, o.metadata\nORDER BY team_size DESC"
},
{
"type": "markdown",
"content": "---\n\n**All clear?** If no red flags above, the platform is healthy. If anything looks off, escalate to the relevant team:\n- Billing issues → #billing-ops\n- Security concerns → #security-oncall\n- Usage anomalies → #product-eng"
}
]
}