@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
22import { existsSync } from "node:fs" ;
33import { cp , mkdir , readFile , readdir , rm , writeFile } from "node:fs/promises" ;
44import path from "node:path" ;
5+ import { gunzipSync } from "node:zlib" ;
56import { fileURLToPath , pathToFileURL } from "node:url" ;
67
78import { parse as parseYaml } from "yaml" ;
@@ -15,6 +16,7 @@ const manifestPath = path.join(cacheRoot, "manifest.json");
1516const lineageCacheRoot = path . join ( cacheRoot , "lineage" ) ;
1617const defaultRepoUrl = "https://github.com/tuva-health/tuva.git" ;
1718const defaultGithubRef = "main" ;
19+ const staticSeedPreviewRowLimit = Number ( process . env . TUVA_DAG_SEED_PREVIEW_ROW_LIMIT || 1000 ) || 1000 ;
1820
1921async function main ( ) {
2022 const sourceRoot = await resolveSourceRoot ( ) ;
@@ -281,14 +283,18 @@ async function exportLineage(sourceRoot) {
281283
282284 const lineageModule = await import ( pathToFileURL ( path . join ( scriptDir , "build-lineage.mjs" ) ) . href ) ;
283285 const targets = await lineageModule . listTargetConfigs ( ) ;
286+ const seedPreviewNodes = new Map ( ) ;
284287
285288 for ( const target of targets ) {
286289 const payload = await lineageModule . buildLineagePayload ( { targetKey : target . key } ) ;
287290 const staticResponse = buildStaticResponse ( { payload, targets } ) ;
288291 const outputPath = path . join ( distRoot , "data" , `${ target . key } -lineage.json` ) ;
289292
290293 await writeFile ( outputPath , `${ JSON . stringify ( staticResponse , null , 2 ) } \n` , "utf8" ) ;
294+ collectSeedPreviewNodes ( seedPreviewNodes , payload ) ;
291295 }
296+
297+ await exportStaticSeedPreviews ( seedPreviewNodes ) ;
292298}
293299
294300function buildStaticResponse ( { payload, targets } ) {
@@ -314,6 +320,151 @@ function buildStaticResponse({ payload, targets }) {
314320 } ;
315321}
316322
323+ function collectSeedPreviewNodes ( seedPreviewNodes , payload ) {
324+ for ( const node of payload . nodes || [ ] ) {
325+ if ( node ?. resourceType !== "seed" || seedPreviewNodes . has ( node . id ) ) {
326+ continue ;
327+ }
328+
329+ seedPreviewNodes . set ( node . id , {
330+ nodeId : node . id ,
331+ name : node . name ,
332+ csvPath : node . paths ?. sql || null ,
333+ seedViewer : node . seedViewer || null ,
334+ columns : node . columns || [ ]
335+ } ) ;
336+ }
337+ }
338+
339+ async function exportStaticSeedPreviews ( seedPreviewNodes ) {
340+ const outputRoot = path . join ( distRoot , "data" , "seed-previews" ) ;
341+ await mkdir ( outputRoot , { recursive : true } ) ;
342+
343+ for ( const seedNode of seedPreviewNodes . values ( ) ) {
344+ const snapshot = await buildStaticSeedPreviewSnapshot ( seedNode ) ;
345+ const outputPath = path . join ( outputRoot , `${ seedNode . nodeId } .json` ) ;
346+ await writeFile ( outputPath , `${ JSON . stringify ( snapshot , null , 2 ) } \n` , "utf8" ) ;
347+ }
348+ }
349+
350+ async function buildStaticSeedPreviewSnapshot ( seedNode ) {
351+ const fallbackHeaders = seedNode . columns . map ( ( column ) => column . name ) . filter ( Boolean ) ;
352+ let preview = null ;
353+ let sourceError = null ;
354+
355+ if ( seedNode . seedViewer ?. downloadUrl ) {
356+ try {
357+ preview = await readCsvPreviewFromUrl ( seedNode . seedViewer . downloadUrl , staticSeedPreviewRowLimit , fallbackHeaders ) ;
358+ } catch ( error ) {
359+ sourceError = error instanceof Error ? error . message : String ( error ) ;
360+ }
361+ }
362+
363+ if ( ! preview && seedNode . csvPath && existsSync ( seedNode . csvPath ) ) {
364+ try {
365+ preview = await readCsvPreviewFromFile ( seedNode . csvPath , staticSeedPreviewRowLimit , fallbackHeaders ) ;
366+ } catch ( error ) {
367+ sourceError = sourceError || ( error instanceof Error ? error . message : String ( error ) ) ;
368+ }
369+ }
370+
371+ return {
372+ nodeId : seedNode . nodeId ,
373+ name : seedNode . name ,
374+ generatedAt : new Date ( ) . toISOString ( ) ,
375+ sourceUrl : seedNode . seedViewer ?. downloadUrl || null ,
376+ sourceError,
377+ headers : preview ?. headers ?. length ? preview . headers : fallbackHeaders ,
378+ rows : preview ?. rows || [ ] ,
379+ cachedRows : preview ?. rows ?. length || 0 ,
380+ totalRows : preview ?. totalRows ?? null ,
381+ rowLimit : staticSeedPreviewRowLimit ,
382+ truncated : Boolean ( preview ?. truncated )
383+ } ;
384+ }
385+
386+ async function readCsvPreviewFromUrl ( url , rowLimit , preferredHeaders = [ ] ) {
387+ const response = await fetch ( url ) ;
388+
389+ if ( ! response . ok ) {
390+ throw new Error ( `CSV source returned ${ response . status } ` ) ;
391+ }
392+
393+ const sourceBuffer = Buffer . from ( await response . arrayBuffer ( ) ) ;
394+ const csvBuffer = isGzipBuffer ( sourceBuffer ) ? gunzipSync ( sourceBuffer ) : sourceBuffer ;
395+ return readCsvPreviewFromText ( csvBuffer . toString ( "utf8" ) , rowLimit , preferredHeaders ) ;
396+ }
397+
398+ async function readCsvPreviewFromFile ( filePath , rowLimit , preferredHeaders = [ ] ) {
399+ return readCsvPreviewFromText ( await readFile ( filePath , "utf8" ) , rowLimit , preferredHeaders ) ;
400+ }
401+
402+ function readCsvPreviewFromText ( csvText , rowLimit , preferredHeaders = [ ] ) {
403+ const lines = csvText . split ( / \r ? \n / ) ;
404+ const firstLine = lines . length ? parseCsvLine ( lines [ 0 ] . replace ( / ^ \uFEFF / , "" ) ) : [ ] ;
405+ const hasPreferredHeaders = preferredHeaders . length > 0 ;
406+ const headers = hasPreferredHeaders ? preferredHeaders : firstLine ;
407+ const firstLineIsHeader = hasPreferredHeaders
408+ ? doCsvHeadersMatch ( firstLine , preferredHeaders )
409+ : firstLine . length > 0 ;
410+ const dataLines = lines . slice ( firstLineIsHeader ? 1 : 0 ) . filter ( ( line ) => line . length ) ;
411+ const rows = dataLines . slice ( 0 , rowLimit ) . map ( parseCsvLine ) ;
412+
413+ return {
414+ headers,
415+ rows,
416+ totalRows : dataLines . length ,
417+ truncated : dataLines . length > rowLimit
418+ } ;
419+ }
420+
421+ function doCsvHeadersMatch ( csvHeaders , preferredHeaders ) {
422+ if ( csvHeaders . length !== preferredHeaders . length ) {
423+ return false ;
424+ }
425+
426+ return csvHeaders . every ( ( header , index ) => normalizeCsvHeader ( header ) === normalizeCsvHeader ( preferredHeaders [ index ] ) ) ;
427+ }
428+
429+ function normalizeCsvHeader ( header ) {
430+ return String ( header || "" ) . trim ( ) . toLowerCase ( ) ;
431+ }
432+
433+ function isGzipBuffer ( buffer ) {
434+ return buffer . length >= 2 && buffer [ 0 ] === 0x1f && buffer [ 1 ] === 0x8b ;
435+ }
436+
437+ function parseCsvLine ( line ) {
438+ const cells = [ ] ;
439+ let cell = "" ;
440+ let inQuotes = false ;
441+
442+ for ( let index = 0 ; index < line . length ; index += 1 ) {
443+ const character = line [ index ] ;
444+
445+ if ( character === "\"" ) {
446+ if ( inQuotes && line [ index + 1 ] === "\"" ) {
447+ cell += "\"" ;
448+ index += 1 ;
449+ } else {
450+ inQuotes = ! inQuotes ;
451+ }
452+ continue ;
453+ }
454+
455+ if ( character === "," && ! inQuotes ) {
456+ cells . push ( cell ) ;
457+ cell = "" ;
458+ continue ;
459+ }
460+
461+ cell += character ;
462+ }
463+
464+ cells . push ( cell ) ;
465+ return cells ;
466+ }
467+
317468async function walkSelectedSourceFiles ( sourceRoot ) {
318469 const roots = [ "models" , "seeds" ] ;
319470 const files = [ ] ;
0 commit comments