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
1613import { PGlite } from '@electric-sql/pglite'
1714import { 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
1921type PGliteInstance = InstanceType < typeof PGlite >
2022type 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
3034type DatabaseStatus = 'idle' | 'loading' | 'ready' | 'error'
31-
32- type InitializedDatabase = {
33- db : PGliteInstance
34- manifest : ExplorerManifest
35- }
35+ type InitializedDatabase = { db : PGliteInstance ; manifest : ExplorerManifest }
3636
3737let 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