77
88import * as fs from 'fs' ;
99import * as path from 'path' ;
10+ import { getContentType , isTextFile , readFileForUpload } from './content-type.js' ;
11+
12+ /**
13+ * Binary files (images, fonts, archives, etc.) cannot be sent through the
14+ * /_atomic JSON endpoint because their bytes don't survive UTF-8 encoding.
15+ * Route them through individual POST uploads (which use octet-stream).
16+ */
17+ function isBinaryFile ( file : FileToUpload ) : boolean {
18+ return ! isTextFile ( getContentType ( file . relativePath ) ) ;
19+ }
20+
21+ const ATOMIC_SOURCE_EXTENSIONS = new Set ( [
22+ '.gts' ,
23+ '.ts' ,
24+ '.tsx' ,
25+ '.js' ,
26+ '.jsx' ,
27+ '.mjs' ,
28+ '.cjs' ,
29+ '.css' ,
30+ '.scss' ,
31+ '.less' ,
32+ '.sass' ,
33+ '.html' ,
34+ ] ) ;
35+
36+ /**
37+ * The /_atomic endpoint only accepts 'card' and 'source' resource types.
38+ * Plain text files (.md, .txt, .csv, .yaml, etc.) are neither cards nor
39+ * compilable source modules — the realm's module compiler rejects them as
40+ * invalid source. Route them through individual POST uploads so they are
41+ * stored as raw files with their correct Content-Type.
42+ */
43+ function isAtomicIncompatible ( file : FileToUpload ) : boolean {
44+ if ( file . relativePath . endsWith ( '.json' ) ) return false ;
45+ const ext = path . extname ( file . relativePath ) . toLowerCase ( ) ;
46+ return ! ATOMIC_SOURCE_EXTENSIONS . has ( ext ) ;
47+ }
1048
1149// ANSI color codes
1250const FG_GREEN = '\x1b[32m' ;
@@ -19,7 +57,7 @@ const RESET = '\x1b[0m';
1957export interface FileToUpload {
2058 relativePath : string ;
2159 localPath : string ;
22- content ?: string ;
60+ content ?: string | Buffer ;
2361 operation : 'add' | 'update' ;
2462}
2563
@@ -65,6 +103,23 @@ const DEFAULT_OPTIONS: BatchOptions = {
65103 verbose : false ,
66104} ;
67105
106+ function getTextContent ( file : FileToUpload ) : string {
107+ if ( typeof file . content === 'string' ) {
108+ return file . content ;
109+ }
110+ const content = fs . readFileSync ( file . localPath , 'utf8' ) ;
111+ file . content = content ;
112+ return content ;
113+ }
114+
115+ function getUploadPayload ( file : FileToUpload ) : { content : string | Buffer ; contentType : string } {
116+ if ( typeof file . content === 'string' || Buffer . isBuffer ( file . content ) ) {
117+ return { content : file . content , contentType : getContentType ( file . relativePath ) } ;
118+ }
119+
120+ return readFileForUpload ( file . relativePath , file . localPath ) ;
121+ }
122+
68123// Verbose logging helper
69124function verbose ( opts : Partial < BatchOptions > , message : string , ...args : unknown [ ] ) : void {
70125 if ( opts . verbose ) {
@@ -73,17 +128,121 @@ function verbose(opts: Partial<BatchOptions>, message: string, ...args: unknown[
73128}
74129
75130/**
76- * Sort files so definitions (.gts) come before instances (.json)
131+ * Sort files so definitions (.gts) come before instances (.json),
132+ * and within .gts files, sort by dependency order (least dependent first).
133+ *
134+ * Dependency detection: scans import statements in .gts files to determine
135+ * which files import others. Files with no local imports come first (FieldDefs,
136+ * base types), then files that import those, etc.
77137 */
78138export function sortDefinitionsFirst ( files : FileToUpload [ ] ) : FileToUpload [ ] {
79- return [ ...files ] . sort ( ( a , b ) => {
80- const aIsDefinition = a . relativePath . endsWith ( '.gts' ) ;
81- const bIsDefinition = b . relativePath . endsWith ( '.gts' ) ;
139+ const definitions = files . filter ( f => f . relativePath . endsWith ( '.gts' ) ) ;
140+ const instances = files . filter ( f => ! f . relativePath . endsWith ( '.gts' ) ) ;
82141
83- if ( aIsDefinition && ! bIsDefinition ) return - 1 ;
84- if ( ! aIsDefinition && bIsDefinition ) return 1 ;
85- return a . relativePath . localeCompare ( b . relativePath ) ;
86- } ) ;
142+ // Build dependency graph for .gts files
143+ const depOrder = sortByDependency ( definitions ) ;
144+
145+ // Definitions first (in dependency order), then instances alphabetically
146+ return [
147+ ...depOrder ,
148+ ...instances . sort ( ( a , b ) => a . relativePath . localeCompare ( b . relativePath ) ) ,
149+ ] ;
150+ }
151+
152+ /**
153+ * Sort .gts files by dependency order using topological sort.
154+ * Files that import nothing local come first; files that import others come later.
155+ */
156+ function sortByDependency ( files : FileToUpload [ ] ) : FileToUpload [ ] {
157+ // Map filename (without extension) to file
158+ const byName = new Map < string , FileToUpload > ( ) ;
159+ for ( const f of files ) {
160+ const name = path . basename ( f . relativePath , '.gts' ) ;
161+ byName . set ( name , f ) ;
162+ }
163+
164+ // Parse imports to build adjacency list
165+ const deps = new Map < string , Set < string > > ( ) ;
166+ for ( const f of files ) {
167+ const name = path . basename ( f . relativePath , '.gts' ) ;
168+ const content =
169+ typeof f . content === 'string'
170+ ? f . content
171+ : fs . existsSync ( f . localPath )
172+ ? fs . readFileSync ( f . localPath , 'utf8' )
173+ : '' ;
174+ if ( content ) {
175+ f . content = content ;
176+ }
177+
178+ const localImports = new Set < string > ( ) ;
179+ // Match: import { X } from './name' or from './name.gts'
180+ const importRegex = / f r o m \s + [ ' " ] \. \/ ( [ ^ ' " ] + ) [ ' " ] / g;
181+ let match ;
182+ while ( ( match = importRegex . exec ( content ) ) !== null ) {
183+ const imported = match [ 1 ] . replace ( / \. g t s $ / , '' ) ;
184+ if ( byName . has ( imported ) ) {
185+ localImports . add ( imported ) ;
186+ }
187+ }
188+ deps . set ( name , localImports ) ;
189+ }
190+
191+ // Topological sort (Kahn's algorithm)
192+ const inDegree = new Map < string , number > ( ) ;
193+ for ( const name of byName . keys ( ) ) inDegree . set ( name , 0 ) ;
194+ for ( const [ , depSet ] of deps ) {
195+ for ( const dep of depSet ) {
196+ inDegree . set ( dep , ( inDegree . get ( dep ) ?? 0 ) + 1 ) ;
197+ }
198+ }
199+
200+ // Note: we want files with NO dependents (leaf nodes) first
201+ // Actually, we want files that nothing depends ON first (no incoming edges
202+ // in the "is imported by" graph), which means files that import nothing.
203+ // Kahn's on the dependency graph: start with nodes that have no dependencies.
204+ const inDeg = new Map < string , number > ( ) ;
205+ for ( const name of byName . keys ( ) ) inDeg . set ( name , 0 ) ;
206+ for ( const [ name , depSet ] of deps ) {
207+ inDeg . set ( name , depSet . size ) ;
208+ }
209+
210+ const queue : string [ ] = [ ] ;
211+ for ( const [ name , deg ] of inDeg ) {
212+ if ( deg === 0 ) queue . push ( name ) ;
213+ }
214+
215+ const sorted : FileToUpload [ ] = [ ] ;
216+ const visited = new Set < string > ( ) ;
217+
218+ while ( queue . length > 0 ) {
219+ queue . sort ( ) ; // deterministic order
220+ const name = queue . shift ( ) ! ;
221+ if ( visited . has ( name ) ) continue ;
222+ visited . add ( name ) ;
223+
224+ const file = byName . get ( name ) ;
225+ if ( file ) sorted . push ( file ) ;
226+
227+ // Find files that depend on this one and decrement their in-degree
228+ for ( const [ other , depSet ] of deps ) {
229+ if ( depSet . has ( name ) ) {
230+ const newDeg = ( inDeg . get ( other ) ?? 1 ) - 1 ;
231+ inDeg . set ( other , newDeg ) ;
232+ if ( newDeg === 0 && ! visited . has ( other ) ) {
233+ queue . push ( other ) ;
234+ }
235+ }
236+ }
237+ }
238+
239+ // Add any remaining files (circular deps)
240+ for ( const f of files ) {
241+ const name = path . basename ( f . relativePath , '.gts' ) ;
242+ if ( ! visited . has ( name ) ) sorted . push ( f ) ;
243+ }
244+
245+ return sorted ;
87246}
88247
89248/**
@@ -99,9 +258,8 @@ export function createBatches(
99258 const maxPayloadBytes = options . maxPayloadKB * 1024 ;
100259
101260 for ( const file of files ) {
102- const content = file . content || fs . readFileSync ( file . localPath , 'utf8' ) ;
261+ const content = getTextContent ( file ) ;
103262 const fileSize = Buffer . byteLength ( content , 'utf8' ) ;
104- file . content = content ; // Cache for later use
105263
106264 // If single file exceeds max payload, give it its own batch
107265 if ( fileSize > maxPayloadBytes ) {
@@ -146,7 +304,7 @@ export function buildAtomicRequest(
146304 realmUrl : string
147305) : AtomicRequest {
148306 const operations : AtomicOperation [ ] = files . map ( file => {
149- const content = file . content || fs . readFileSync ( file . localPath , 'utf8' ) ;
307+ const content = getTextContent ( file ) ;
150308 const isCard = file . relativePath . endsWith ( '.json' ) ;
151309
152310 if ( isCard ) {
@@ -171,15 +329,17 @@ export function buildAtomicRequest(
171329 op : file . operation ,
172330 href : `${ realmUrl } ${ file . relativePath } ` ,
173331 data : {
174- type : 'file ' ,
332+ type : 'source ' ,
175333 attributes : {
176334 content : content ,
177335 } ,
178336 } ,
179337 } ;
180338 }
181339 } else {
182- // For source files (.gts, etc.), send as content
340+ // For source code (.gts, .ts, .css, .html, etc.), send as source module.
341+ // Non-source-code text files are filtered out of the atomic batch by
342+ // isAtomicIncompatible() and uploaded via individual POST instead.
183343 return {
184344 op : file . operation ,
185345 href : `${ realmUrl } ${ file . relativePath } ` ,
@@ -326,25 +486,32 @@ export async function uploadSingleFile(
326486 } ;
327487 }
328488
329- const content = file . content || fs . readFileSync ( file . localPath , 'utf8' ) ;
489+ const { content, contentType } = getUploadPayload ( file ) ;
330490 const url = `${ realmUrl } ${ file . relativePath } ` ;
331491
492+ // Accept: compilable source types expect 'application/vnd.card+source' back
493+ // from the realm; binary + plain-text files want the raw bytes returned as-is.
494+ const acceptHeader = isTextFile ( contentType ) && ! isAtomicIncompatible ( file )
495+ ? 'application/vnd.card+source'
496+ : '*/*' ;
497+
332498 try {
333499 const response = await fetch ( url , {
334500 method : 'POST' ,
335501 headers : {
336- 'Content-Type' : 'text/plain;charset=UTF-8' ,
502+ 'Content-Type' : contentType ,
337503 'Authorization' : jwt ,
338- 'Accept' : 'application/vnd.card+source' ,
504+ 'Accept' : acceptHeader ,
339505 } ,
340506 body : content ,
341507 } ) ;
342508
343509 if ( ! response . ok ) {
510+ const body = await response . text ( ) . catch ( ( ) => '' ) ;
344511 return {
345512 success : false ,
346513 filesUploaded : 0 ,
347- errors : [ { path : file . relativePath , error : `HTTP ${ response . status } ` } ] ,
514+ errors : [ { path : file . relativePath , error : `HTTP ${ response . status } : ${ body . slice ( 0 , 200 ) } ` } ] ,
348515 timeMs : Date . now ( ) - startTime ,
349516 } ;
350517 }
@@ -389,11 +556,21 @@ export async function uploadWithBatching(
389556 verbose ( opts , `uploadWithBatching called with ${ files . length } files` ) ;
390557 verbose ( opts , `Options: batchSize=${ opts . batchSize } , definitionsFirst=${ opts . definitionsFirst } , quiet=${ opts . quiet } ` ) ;
391558
559+ // Split out files that cannot go through /_atomic:
560+ // - binary files (bytes don't survive UTF-8 stringification)
561+ // - plain text files like .md/.txt/.csv (not valid source modules,
562+ // rejected by the realm's module compiler)
563+ // Both kinds route through individual POST uploads with their correct
564+ // Content-Type so the server stores them as raw files.
565+ const individualFiles = files . filter ( f => isBinaryFile ( f ) || isAtomicIncompatible ( f ) ) ;
566+ const textFiles = files . filter ( f => ! isBinaryFile ( f ) && ! isAtomicIncompatible ( f ) ) ;
567+ verbose ( opts , `Split: ${ textFiles . length } atomic-compatible, ${ individualFiles . length } individual` ) ;
568+
392569 // Sort definitions first if requested
393- let sortedFiles = opts . definitionsFirst ? sortDefinitionsFirst ( files ) : files ;
570+ let sortedFiles = opts . definitionsFirst ? sortDefinitionsFirst ( textFiles ) : textFiles ;
394571 verbose ( opts , `After sorting: ${ sortedFiles . map ( f => f . relativePath ) . join ( ', ' ) } ` ) ;
395572
396- // Create batches
573+ // Create batches (text only)
397574 const batches = createBatches ( sortedFiles , opts ) ;
398575 verbose ( opts , `Created ${ batches . length } batches` ) ;
399576
@@ -404,11 +581,33 @@ export async function uploadWithBatching(
404581
405582 if ( ! opts . quiet ) {
406583 const totalSize = sortedFiles . reduce ( ( sum , f ) => {
407- const content = f . content || fs . readFileSync ( f . localPath , 'utf8' ) ;
408- f . content = content ;
584+ const content = getTextContent ( f ) ;
409585 return sum + Buffer . byteLength ( content , 'utf8' ) ;
410586 } , 0 ) ;
411- log ( `\n${ FG_CYAN } Uploading ${ files . length } files in ${ batches . length } batch(es)${ RESET } ${ DIM } (${ Math . round ( totalSize / 1024 ) } KB total)${ RESET } ` ) ;
587+ const individualNote = individualFiles . length > 0
588+ ? ` ${ DIM } + ${ individualFiles . length } file(s) individually${ RESET } `
589+ : '' ;
590+ log ( `\n${ FG_CYAN } Uploading ${ textFiles . length } files in ${ batches . length } batch(es)${ RESET } ${ DIM } (${ Math . round ( totalSize / 1024 ) } KB total)${ RESET } ${ individualNote } ` ) ;
591+ }
592+
593+ // Upload individual files first — binary files are typically referenced
594+ // by cards (e.g. Product → image links), and plain text files (.md etc.)
595+ // are not sources the realm indexes.
596+ for ( const file of individualFiles ) {
597+ const singleResult = await uploadSingleFile ( file , realmUrl , jwt , opts ) ;
598+ if ( singleResult . success ) {
599+ totalUploaded ++ ;
600+ if ( ! opts . quiet ) {
601+ const tag = isBinaryFile ( file ) ? 'binary' : 'file' ;
602+ log ( ` ${ FG_GREEN } ✓${ RESET } ${ file . relativePath } ${ DIM } (${ tag } , ${ singleResult . timeMs } ms)${ RESET } ` ) ;
603+ }
604+ } else {
605+ totalFailed ++ ;
606+ allErrors . push ( ...singleResult . errors ) ;
607+ if ( ! opts . quiet ) {
608+ log ( ` ${ FG_RED } ✗${ RESET } ${ file . relativePath } : ${ singleResult . errors [ 0 ] ?. error } ` ) ;
609+ }
610+ }
412611 }
413612
414613 for ( let i = 0 ; i < batches . length ; i ++ ) {
0 commit comments