1+ /**
2+ * SQLite filesystem adapter for isomorphic-git
3+ * One DO = one Git repo, stored directly in SQLite
4+ *
5+ * Limits:
6+ * - Cloudflare DO SQLite: 10GB total storage
7+ * - Max parameter size: ~1MB per SQL statement parameter
8+ * - Git objects are base64-encoded to safely store binary data
9+ */
10+
11+ export interface SqlExecutor {
12+ < T = unknown > ( query : TemplateStringsArray , ...values : ( string | number | boolean | null ) [ ] ) : T [ ] ;
13+ }
14+
15+ // 1MB limit for Cloudflare DO SQL parameters, leave some headroom
16+ const MAX_OBJECT_SIZE = 900 * 1024 ; // 900KB
17+
18+ export class SqliteFS {
19+ constructor ( private sql : SqlExecutor ) { }
20+
21+ /**
22+ * Get storage statistics for observability
23+ */
24+ getStorageStats ( ) : { totalObjects : number ; totalBytes : number ; largestObject : { path : string ; size : number } | null } {
25+ const objects = this . sql < { path : string ; data : string } > `SELECT path, data FROM git_objects` ;
26+
27+ if ( ! objects || objects . length === 0 ) {
28+ return { totalObjects : 0 , totalBytes : 0 , largestObject : null } ;
29+ }
30+
31+ let totalBytes = 0 ;
32+ let largestObject : { path : string ; size : number } | null = null ;
33+
34+ for ( const obj of objects ) {
35+ const size = obj . data . length ; // Base64 encoded size
36+ totalBytes += size ;
37+
38+ if ( ! largestObject || size > largestObject . size ) {
39+ largestObject = { path : obj . path , size } ;
40+ }
41+ }
42+
43+ return {
44+ totalObjects : objects . length ,
45+ totalBytes,
46+ largestObject
47+ } ;
48+ }
49+
50+ init ( ) : void {
51+ this . sql `
52+ CREATE TABLE IF NOT EXISTS git_objects (
53+ path TEXT PRIMARY KEY,
54+ data TEXT NOT NULL,
55+ mtime INTEGER NOT NULL
56+ )
57+ ` ;
58+
59+ // Create index for efficient directory listings
60+ this . sql `CREATE INDEX IF NOT EXISTS idx_git_objects_path ON git_objects(path)` ;
61+ }
62+
63+ readFile ( path : string , options ?: { encoding ?: 'utf8' } ) : Uint8Array | string {
64+ // Normalize path (remove leading slashes)
65+ const normalized = path . replace ( / ^ \/ + / , '' ) ;
66+ const result = this . sql < { data : string } > `SELECT data FROM git_objects WHERE path = ${ normalized } ` ;
67+ if ( ! result [ 0 ] ) throw new Error ( `ENOENT: ${ path } ` ) ;
68+
69+ const base64Data = result [ 0 ] . data ;
70+
71+ // Decode from base64
72+ const binaryString = atob ( base64Data ) ;
73+ const bytes = new Uint8Array ( binaryString . length ) ;
74+ for ( let i = 0 ; i < binaryString . length ; i ++ ) {
75+ bytes [ i ] = binaryString . charCodeAt ( i ) ;
76+ }
77+
78+ return options ?. encoding === 'utf8' ? new TextDecoder ( ) . decode ( bytes ) : bytes ;
79+ }
80+
81+ writeFile ( path : string , data : Uint8Array | string ) : void {
82+ // Normalize path (remove leading slashes)
83+ const normalized = path . replace ( / ^ \/ + / , '' ) ;
84+
85+ // Convert to Uint8Array if string
86+ const bytes = typeof data === 'string' ? new TextEncoder ( ) . encode ( data ) : data ;
87+
88+ // Check size limit
89+ if ( bytes . length > MAX_OBJECT_SIZE ) {
90+ throw new Error ( `File too large: ${ path } (${ bytes . length } bytes, max ${ MAX_OBJECT_SIZE } )` ) ;
91+ }
92+
93+ // Encode to base64 for safe storage
94+ let binaryString = '' ;
95+ for ( let i = 0 ; i < bytes . length ; i ++ ) {
96+ binaryString += String . fromCharCode ( bytes [ i ] ) ;
97+ }
98+ const base64Content = btoa ( binaryString ) ;
99+
100+ this . sql `INSERT OR REPLACE INTO git_objects (path, data, mtime) VALUES (${ normalized } , ${ base64Content } , ${ Date . now ( ) } )` ;
101+
102+ // Only log if approaching size limit (no overhead for normal files)
103+ if ( bytes . length > MAX_OBJECT_SIZE * 0.8 ) {
104+ console . warn ( `[Git Storage] Large file: ${ normalized } is ${ ( bytes . length / 1024 ) . toFixed ( 1 ) } KB (limit: ${ ( MAX_OBJECT_SIZE / 1024 ) . toFixed ( 1 ) } KB)` ) ;
105+ }
106+ }
107+
108+ unlink ( path : string ) : void {
109+ // Normalize path (remove leading slashes)
110+ const normalized = path . replace ( / ^ \/ + / , '' ) ;
111+ this . sql `DELETE FROM git_objects WHERE path = ${ normalized } ` ;
112+ }
113+
114+ readdir ( path : string ) : string [ ] {
115+ // Normalize path (remove leading/trailing slashes)
116+ const normalized = path . replace ( / ^ \/ + | \/ + $ / g, '' ) ;
117+
118+ let result ;
119+ if ( normalized === '' ) {
120+ // Root directory - get all paths
121+ result = this . sql < { path : string } > `SELECT path FROM git_objects` ;
122+ } else {
123+ // Subdirectory - match prefix
124+ result = this . sql < { path : string } > `SELECT path FROM git_objects WHERE path LIKE ${ normalized + '/%' } ` ;
125+ }
126+
127+ if ( ! result || result . length === 0 ) return [ ] ;
128+
129+ const children = new Set < string > ( ) ;
130+ const prefixLen = normalized ? normalized . length + 1 : 0 ;
131+
132+ for ( const row of result ) {
133+ const relativePath = normalized ? row . path . substring ( prefixLen ) : row . path ;
134+ const first = relativePath . split ( '/' ) [ 0 ] ;
135+ if ( first ) children . add ( first ) ;
136+ }
137+
138+ return Array . from ( children ) ;
139+ }
140+
141+ mkdir ( _path : string ) : void {
142+ // No-op: directories are implicit in Git
143+ }
144+
145+ rmdir ( path : string ) : void {
146+ // Normalize path (remove leading/trailing slashes)
147+ const normalized = path . replace ( / ^ \/ + | \/ + $ / g, '' ) ;
148+ this . sql `DELETE FROM git_objects WHERE path LIKE ${ normalized + '%' } ` ;
149+ }
150+
151+ stat ( path : string ) : { type : 'file' | 'dir' ; mode : number ; size : number ; mtimeMs : number } {
152+ // Normalize path (remove leading slashes)
153+ const normalized = path . replace ( / ^ \/ + / , '' ) ;
154+ const result = this . sql < { data : string ; mtime : number } > `SELECT data, mtime FROM git_objects WHERE path = ${ normalized } ` ;
155+ if ( ! result [ 0 ] ) throw new Error ( `ENOENT: ${ path } ` ) ;
156+
157+ const row = result [ 0 ] ;
158+ return { type : 'file' , mode : 0o100644 , size : row . data . length , mtimeMs : row . mtime } ;
159+ }
160+
161+ lstat ( path : string ) {
162+ return this . stat ( path ) ;
163+ }
164+
165+ symlink ( target : string , path : string ) : void {
166+ this . writeFile ( path , target ) ;
167+ }
168+
169+ readlink ( path : string ) : string {
170+ return this . readFile ( path , { encoding : 'utf8' } ) as string ;
171+ }
172+ }
0 commit comments