Skip to content

Commit 1fe0f21

Browse files
tonyxiaoclaude
andcommitted
Generate Stripe schema in-browser from OpenAPI spec
Remove all static files and build steps. pglite.ts now fetches the Stripe OpenAPI spec from GitHub at runtime, runs SpecParser to get table definitions, and builds CREATE TABLE DDL directly in the browser. The spec (~3 MB) is cached in sessionStorage after the first load. - Delete scripts/explorer-generate.ts (no longer needed) - Remove public/explorer-data/ entirely (no static assets) - Remove explorer:build and visualizer:with-data root scripts - pglite.ts: inline browser-compatible DDL builder (no node: deps) - Add @stripe/sync-source-stripe workspace dep to visualizer - Export SpecParser, OPENAPI_RESOURCE_TABLE_ALIASES, ParsedResourceTable from source-stripe's public index Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude
1 parent a8d9a62 commit 1fe0f21

7 files changed

Lines changed: 267 additions & 315 deletions

File tree

apps/visualizer/.gitignore

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,3 @@
22
.next
33
.env*.local
44
tsconfig.tsbuildinfo
5-
public/explorer-data/bootstrap.sql
6-
public/explorer-data/manifest.json

apps/visualizer/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"lint": "next lint"
1010
},
1111
"dependencies": {
12+
"@stripe/sync-source-stripe": "workspace:*",
1213
"@codemirror/lang-sql": "^6.7.0",
1314
"@codemirror/state": "^6.4.0",
1415
"@codemirror/view": "^6.26.0",

apps/visualizer/src/lib/pglite.ts

Lines changed: 133 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,38 @@
1+
'use client'
2+
13
/**
24
* PGlite Database Hydration Hook
35
*
4-
* Provides client-side Postgres database powered by PGlite (WASM).
5-
* Hydrates from static JSON/SQL artifacts in the public directory.
6-
*
7-
* Usage:
8-
* const { db, status, error, query } = usePGlite();
9-
*
10-
* if (status === 'loading') return <div>Loading database...</div>;
11-
* if (status === 'error') return <div>Error: {error}</div>;
6+
* Fetches the Stripe OpenAPI spec from GitHub, generates CREATE TABLE DDL
7+
* in-browser using SpecParser, and loads it into a PGlite (WASM Postgres)
8+
* instance. No static files, no build step.
129
*
13-
* const result = await query('SELECT * FROM stripe.customers LIMIT 10');
10+
* The spec (~3 MB) is cached in sessionStorage after the first fetch.
1411
*/
1512

1613
import { PGlite } from '@electric-sql/pglite'
1714
import { useEffect, useState, useCallback, useRef } from 'react'
15+
import {
16+
SpecParser,
17+
OPENAPI_RESOURCE_TABLE_ALIASES,
18+
type ParsedResourceTable,
19+
} from '@stripe/sync-source-stripe'
1820

1921
type PGliteInstance = InstanceType<typeof PGlite>
2022
type QueryResult = Awaited<ReturnType<PGliteInstance['query']>>
2123

22-
// Manifest structure from scripts/explorer-generate.ts
23-
interface ExplorerManifest {
24-
generatedAt: string
24+
const STRIPE_OPENAPI_URL =
25+
'https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json'
26+
const SESSION_CACHE_KEY = 'stripe-explorer-schema-v1'
27+
28+
export interface ExplorerManifest {
2529
apiVersion: string
2630
totalTables: number
2731
tables: string[]
2832
}
2933

3034
type DatabaseStatus = 'idle' | 'loading' | 'ready' | 'error'
31-
32-
type InitializedDatabase = {
33-
db: PGliteInstance
34-
manifest: ExplorerManifest
35-
}
35+
type InitializedDatabase = { db: PGliteInstance; manifest: ExplorerManifest }
3636

3737
let sharedDatabasePromise: Promise<InitializedDatabase> | null = null
3838

@@ -50,28 +50,25 @@ export function usePGlite(): UsePGliteResult {
5050
const [status, setStatus] = useState<DatabaseStatus>('idle')
5151
const [error, setError] = useState<string | null>(null)
5252
const [manifest, setManifest] = useState<ExplorerManifest | null>(null)
53-
5453
const currentPromiseRef = useRef<Promise<InitializedDatabase> | null>(null)
5554

5655
useEffect(() => {
5756
let cancelled = false
58-
5957
setStatus('loading')
6058
setError(null)
6159

6260
currentPromiseRef.current ??= getOrCreateDatabase()
63-
6461
currentPromiseRef.current
6562
.then(({ db: initializedDb, manifest: initializedManifest }) => {
6663
if (cancelled) return
67-
setManifest(initializedManifest)
6864
setDb(initializedDb)
65+
setManifest(initializedManifest)
6966
setStatus('ready')
7067
})
7168
.catch((err) => {
7269
console.error('[PGlite] Initialization error:', err)
7370
if (cancelled) return
74-
setError(err instanceof Error ? err.message : 'Unknown error during initialization')
71+
setError(err instanceof Error ? err.message : 'Unknown initialization error')
7572
setStatus('error')
7673
})
7774

@@ -82,180 +79,154 @@ export function usePGlite(): UsePGliteResult {
8279

8380
const query = useCallback(
8481
async (sql: string, params?: unknown[]): Promise<QueryResult> => {
85-
if (status !== 'ready' || !db) {
86-
throw new Error('Database not ready. Current status: ' + status)
87-
}
88-
89-
try {
90-
const result = await db.query(sql, params)
91-
return result
92-
} catch (err) {
93-
console.error('[PGlite] Query error:', err)
94-
throw err
95-
}
82+
if (status !== 'ready' || !db) throw new Error('Database not ready: ' + status)
83+
return db.query(sql, params)
9684
},
9785
[db, status]
9886
)
9987

10088
const exec = useCallback(
10189
async (sql: string): Promise<void> => {
102-
if (status !== 'ready' || !db) {
103-
throw new Error('Database not ready. Current status: ' + status)
104-
}
105-
106-
try {
107-
await db.exec(sql)
108-
} catch (err) {
109-
console.error('[PGlite] Exec error:', err)
110-
throw err
111-
}
90+
if (status !== 'ready' || !db) throw new Error('Database not ready: ' + status)
91+
await db.exec(sql)
11292
},
11393
[db, status]
11494
)
11595

116-
return {
117-
db,
118-
status,
119-
error,
120-
query,
121-
exec,
122-
manifest,
123-
}
96+
return { db, status, error, query, exec, manifest }
12497
}
12598

126-
async function hydrateSqlBootstrap(db: PGliteInstance, sqlPath: string): Promise<void> {
127-
console.log(`[PGlite] Fetching SQL bootstrap from ${sqlPath}`)
128-
129-
const response = await fetch(sqlPath)
130-
if (!response.ok) {
131-
throw new Error(`Failed to fetch SQL bootstrap: ${response.status} ${response.statusText}`)
132-
}
133-
134-
const sqlContent = await response.text()
135-
console.log(`[PGlite] SQL bootstrap size: ${(sqlContent.length / 1024).toFixed(2)} KB`)
136-
137-
try {
138-
console.log(`[PGlite] Attempting to execute SQL as single multi-statement block...`)
139-
await db.exec(sqlContent)
140-
console.log('[PGlite] SQL bootstrap executed successfully')
141-
return
142-
} catch (err) {
143-
console.warn(
144-
'[PGlite] Multi-statement execution failed, falling back to statement-by-statement execution'
145-
)
146-
console.warn('[PGlite] Error was:', err instanceof Error ? err.message : String(err))
147-
}
148-
149-
const cleanedSql = sqlContent
150-
.split('\n')
151-
.filter((line) => {
152-
const trimmed = line.trim()
153-
return trimmed && !trimmed.startsWith('--')
99+
async function getOrCreateDatabase(): Promise<InitializedDatabase> {
100+
if (!sharedDatabasePromise) {
101+
sharedDatabasePromise = buildDatabase().catch((err) => {
102+
sharedDatabasePromise = null
103+
throw err
154104
})
155-
.join('\n')
156-
157-
const statements = cleanedSql
158-
.split(';')
159-
.map((s) => s.trim())
160-
.filter((s) => s.length > 0)
161-
.map((s) => s + ';')
105+
}
106+
return sharedDatabasePromise
107+
}
162108

163-
console.log(`[PGlite] Executing ${statements.length} SQL statements individually...`)
109+
async function buildDatabase(): Promise<InitializedDatabase> {
110+
// Try sessionStorage cache first (avoids re-fetching the 3 MB spec on reload)
111+
let sql: string
112+
let manifest: ExplorerManifest
113+
const cached =
114+
typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(SESSION_CACHE_KEY) : null
164115

165-
for (let i = 0; i < statements.length; i++) {
166-
const statement = statements[i]
116+
if (cached) {
117+
console.log('[PGlite] Using cached schema from sessionStorage')
118+
;({ sql, manifest } = JSON.parse(cached) as { sql: string; manifest: ExplorerManifest })
119+
} else {
120+
console.log('[PGlite] Fetching Stripe OpenAPI spec...')
121+
const response = await fetch(STRIPE_OPENAPI_URL)
122+
if (!response.ok) throw new Error(`Failed to fetch OpenAPI spec: ${response.status}`)
123+
const spec = await response.json()
124+
console.log('[PGlite] Parsing schema...')
125+
;({ sql, manifest } = generateSchema(spec))
167126
try {
168-
await db.exec(statement)
169-
170-
if ((i + 1) % 100 === 0) {
171-
console.log(`[PGlite] Progress: ${i + 1}/${statements.length} statements`)
172-
}
173-
} catch (err) {
174-
const preview = statement.length > 200 ? statement.substring(0, 200) + '...' : statement
175-
console.error(`[PGlite] Error executing statement ${i + 1}:`, preview)
176-
console.error('[PGlite] Full error:', err)
177-
throw new Error(
178-
`Failed to execute SQL statement ${i + 1}: ${err instanceof Error ? err.message : String(err)}`
179-
)
127+
sessionStorage.setItem(SESSION_CACHE_KEY, JSON.stringify({ sql, manifest }))
128+
} catch {
129+
// sessionStorage full — continue without caching
180130
}
181131
}
182132

183-
console.log('[PGlite] SQL bootstrap executed successfully (statement-by-statement mode)')
133+
console.log(`[PGlite] Creating database (${manifest.totalTables} tables)...`)
134+
const db = await PGlite.create()
135+
await db.exec(sql)
136+
console.log('[PGlite] Ready')
137+
return { db, manifest }
184138
}
185139

186-
async function hydrateJsonBootstrap(
187-
db: PGliteInstance,
188-
jsonPath: string,
189-
_manifest: ExplorerManifest
190-
): Promise<void> {
191-
console.log(`[PGlite] Fetching JSON bootstrap from ${jsonPath}`)
140+
// ---------------------------------------------------------------------------
141+
// Schema generation — runs in the browser, no Node.js deps
142+
// ---------------------------------------------------------------------------
192143

193-
const response = await fetch(jsonPath)
194-
if (!response.ok) {
195-
throw new Error(`Failed to fetch JSON bootstrap: ${response.status} ${response.statusText}`)
144+
function generateSchema(spec: Record<string, unknown>): {
145+
sql: string
146+
manifest: ExplorerManifest
147+
} {
148+
const schemas =
149+
(spec as { components?: { schemas?: Record<string, unknown> } }).components?.schemas ?? {}
150+
151+
// Discover all table names from x-resourceId fields
152+
const allTableNames = new Set<string>()
153+
for (const schemaDef of Object.values(schemas)) {
154+
const resourceId = (schemaDef as Record<string, unknown>)['x-resourceId']
155+
if (!resourceId || typeof resourceId !== 'string') continue
156+
const alias = OPENAPI_RESOURCE_TABLE_ALIASES[resourceId]
157+
if (alias) {
158+
allTableNames.add(alias)
159+
} else {
160+
const normalized = resourceId.toLowerCase().replace(/\./g, '_')
161+
allTableNames.add(normalized.endsWith('s') ? normalized : `${normalized}s`)
162+
}
196163
}
197164

198-
const jsonData = await response.json()
199-
console.log(
200-
`[PGlite] JSON bootstrap size: ${(JSON.stringify(jsonData).length / 1024).toFixed(2)} KB`
201-
)
202-
203-
const tables = Object.keys(jsonData)
204-
console.log(`[PGlite] Hydrating ${tables.length} tables...`)
165+
const parser = new SpecParser()
166+
const parsed = parser.parse(spec as Parameters<SpecParser['parse']>[0], {
167+
allowedTables: Array.from(allTableNames),
168+
})
205169

206-
for (const tableName of tables) {
207-
const rows = jsonData[tableName]
208-
if (!Array.isArray(rows) || rows.length === 0) {
209-
console.log(`[PGlite] Skipping empty table: ${tableName}`)
210-
continue
211-
}
170+
const lines: string[] = [`CREATE SCHEMA IF NOT EXISTS "stripe";`, '']
171+
for (const table of parsed.tables) {
172+
lines.push(buildTableSql('stripe', table))
173+
lines.push('')
174+
}
212175

213-
console.log(`[PGlite] Inserting ${rows.length} rows into stripe.${tableName}...`)
214-
215-
for (const row of rows) {
216-
try {
217-
await db.query(`INSERT INTO stripe.${tableName} (_raw_data, _account_id) VALUES ($1, $2)`, [
218-
JSON.stringify(row._raw_data),
219-
row._account_id,
220-
])
221-
} catch (err) {
222-
console.error(`[PGlite] Error inserting row into ${tableName}:`, err)
223-
}
224-
}
176+
const manifest: ExplorerManifest = {
177+
apiVersion: parsed.apiVersion,
178+
totalTables: parsed.tables.length,
179+
tables: parsed.tables.map((t) => t.tableName),
225180
}
226181

227-
console.log('[PGlite] JSON bootstrap loaded successfully')
182+
return { sql: lines.join('\n'), manifest }
228183
}
229184

230-
export async function createPGliteDatabase(): Promise<{
231-
db: PGliteInstance
232-
manifest: ExplorerManifest
233-
}> {
234-
const manifestResponse = await fetch('/explorer-data/manifest.json')
235-
if (!manifestResponse.ok) {
236-
throw new Error(`Failed to fetch manifest: ${manifestResponse.status}`)
185+
function buildTableSql(schema: string, table: ParsedResourceTable): string {
186+
const qt = (s: string) => `"${s.replaceAll('"', '""')}"`
187+
188+
const cols: string[] = [
189+
'"_raw_data" jsonb NOT NULL',
190+
'"_last_synced_at" timestamptz',
191+
'"_updated_at" timestamptz NOT NULL DEFAULT now()',
192+
'"_account_id" text NOT NULL',
193+
`"id" text GENERATED ALWAYS AS ((_raw_data->>'id')::text) STORED`,
194+
]
195+
196+
for (const col of table.columns) {
197+
const p = col.name.replace(/'/g, "''")
198+
const pg = scalartypeToPg(col.type)
199+
let expr: string
200+
if (col.expandableReference) {
201+
expr = `CASE WHEN jsonb_typeof(_raw_data->'${p}') = 'object' AND _raw_data->'${p}' ? 'id' THEN (_raw_data->'${p}'->>'id') ELSE (_raw_data->>'${p}') END`
202+
} else if (pg === 'jsonb') {
203+
expr = `(_raw_data->'${p}')::jsonb`
204+
} else if (pg === 'text') {
205+
expr = `(_raw_data->>'${p}')::text`
206+
} else {
207+
expr = `(NULLIF(_raw_data->>'${p}', ''))::${pg}`
208+
}
209+
cols.push(`${qt(col.name)} ${pg} GENERATED ALWAYS AS (${expr}) STORED`)
237210
}
238-
const manifest: ExplorerManifest = await manifestResponse.json()
239-
240-
const db = await PGlite.create()
241211

242-
const sqlCheckResponse = await fetch('/explorer-data/bootstrap.sql', { method: 'HEAD' })
243-
if (sqlCheckResponse.ok) {
244-
await hydrateSqlBootstrap(db, '/explorer-data/bootstrap.sql')
245-
} else {
246-
await hydrateJsonBootstrap(db, '/explorer-data/bootstrap.json', manifest)
247-
}
212+
cols.push('PRIMARY KEY ("id")')
248213

249-
return { db, manifest }
214+
return `CREATE TABLE IF NOT EXISTS ${qt(schema)}.${qt(table.tableName)} (\n ${cols.join(',\n ')}\n);`
250215
}
251216

252-
async function getOrCreateDatabase(): Promise<InitializedDatabase> {
253-
if (!sharedDatabasePromise) {
254-
sharedDatabasePromise = createPGliteDatabase().catch((error) => {
255-
sharedDatabasePromise = null
256-
throw error
257-
})
217+
function scalartypeToPg(type: string): string {
218+
switch (type) {
219+
case 'bigint':
220+
return 'bigint'
221+
case 'numeric':
222+
return 'numeric'
223+
case 'boolean':
224+
return 'boolean'
225+
case 'json':
226+
return 'jsonb'
227+
case 'timestamptz':
228+
return 'timestamptz'
229+
default:
230+
return 'text'
258231
}
259-
260-
return sharedDatabasePromise
261232
}

0 commit comments

Comments
 (0)