@@ -34,6 +34,7 @@ use rusqlite::{
3434 CachedStatement , Connection , OpenFlags , OptionalExtension , Params , Row , ToSql , Transaction ,
3535 TransactionBehavior , named_params, types:: ToSqlOutput ,
3636} ;
37+ use sha2:: { Digest as _, Sha256 } ;
3738use std:: {
3839 cmp:: max,
3940 collections:: VecDeque ,
@@ -303,6 +304,30 @@ CREATE TABLE IF NOT EXISTS t_wasm_backtrace (
303304) STRICT
304305" ;
305306
307+ // Content-addressed store for source file text. Content hash is SHA-256 of the UTF-8 content.
308+ const CREATE_TABLE_T_SOURCE_FILE : & str = r"
309+ CREATE TABLE IF NOT EXISTS t_source_file (
310+ content_hash BLOB NOT NULL,
311+ content TEXT NOT NULL,
312+
313+ PRIMARY KEY (content_hash)
314+ ) STRICT
315+ " ;
316+ // Maps (component_digest, frame_key, is_suffix) to a source file.
317+ // frame_key is the exact frame symbol path (is_suffix=0) or a '/'-prefixed suffix (is_suffix=1).
318+ const CREATE_TABLE_T_COMPONENT_SOURCE : & str = r"
319+ CREATE TABLE IF NOT EXISTS t_component_source (
320+ component_digest BLOB NOT NULL,
321+ frame_key TEXT NOT NULL,
322+ is_suffix INTEGER NOT NULL,
323+ content_hash BLOB NOT NULL,
324+
325+ PRIMARY KEY (component_digest, frame_key, is_suffix),
326+ FOREIGN KEY (content_hash)
327+ REFERENCES t_source_file(content_hash)
328+ ) STRICT
329+ " ;
330+
306331/// Stores logs and std stream output of execution runs. Append only.
307332/// Logs have `level` and `message` null.
308333/// Std streams have `stream_type`, `payload` not null.
@@ -779,6 +804,9 @@ impl SqlitePool {
779804 conn_execute ( & conn, CREATE_TABLE_T_EXECUTION_BACKTRACE , [ ] ) ?;
780805 conn_execute ( & conn, IDX_T_EXECUTION_BACKTRACE_EXECUTION_ID_VERSION , [ ] ) ?;
781806 conn_execute ( & conn, CREATE_TABLE_T_WASM_BACKTRACE , [ ] ) ?;
807+ // source files
808+ conn_execute ( & conn, CREATE_TABLE_T_SOURCE_FILE , [ ] ) ?;
809+ conn_execute ( & conn, CREATE_TABLE_T_COMPONENT_SOURCE , [ ] ) ?;
782810 // t_log
783811 conn_execute ( & conn, CREATE_TABLE_T_LOG , [ ] ) ?;
784812 conn_execute ( & conn, IDX_T_LOG_EXECUTION_ID_RUN_ID_CREATED_AT , [ ] ) ?;
@@ -3862,6 +3890,92 @@ impl DbExternalApi for SqlitePool {
38623890 ) . await
38633891 }
38643892
3893+ #[ instrument( skip_all) ]
3894+ async fn upsert_source_file (
3895+ & self ,
3896+ component_digest : & ComponentDigest ,
3897+ frame_key : & str ,
3898+ is_suffix : bool ,
3899+ content : & str ,
3900+ ) -> Result < ( ) , DbErrorWrite > {
3901+ let content_hash: [ u8 ; 32 ] = Sha256 :: digest ( content. as_bytes ( ) ) . into ( ) ;
3902+ let component_digest = component_digest. clone ( ) ;
3903+ let frame_key = frame_key. to_owned ( ) ;
3904+ let content = content. to_owned ( ) ;
3905+ self . transaction (
3906+ move |tx| {
3907+ tx. prepare (
3908+ "INSERT OR IGNORE INTO t_source_file (content_hash, content) \
3909+ VALUES (:content_hash, :content)",
3910+ ) ?
3911+ . execute ( named_params ! {
3912+ ":content_hash" : content_hash,
3913+ ":content" : content,
3914+ } ) ?;
3915+ tx. prepare (
3916+ "INSERT OR IGNORE INTO t_component_source \
3917+ (component_digest, frame_key, is_suffix, content_hash) \
3918+ VALUES (:component_digest, :frame_key, :is_suffix, :content_hash)",
3919+ ) ?
3920+ . execute ( named_params ! {
3921+ ":component_digest" : component_digest,
3922+ ":frame_key" : frame_key,
3923+ ":is_suffix" : is_suffix,
3924+ ":content_hash" : content_hash,
3925+ } ) ?;
3926+ Ok ( ( ) )
3927+ } ,
3928+ TxType :: Other ,
3929+ "upsert_source_file" ,
3930+ )
3931+ . await
3932+ }
3933+
3934+ #[ instrument( skip_all) ]
3935+ async fn get_source_file (
3936+ & self ,
3937+ component_digest : & ComponentDigest ,
3938+ file : & str ,
3939+ ) -> Result < Option < String > , DbErrorRead > {
3940+ let component_digest = component_digest. clone ( ) ;
3941+ let file = file. to_owned ( ) ;
3942+ self . transaction (
3943+ move |tx| {
3944+ let mut stmt = tx. prepare (
3945+ "SELECT s.content \
3946+ FROM t_component_source cs \
3947+ JOIN t_source_file s ON cs.content_hash = s.content_hash \
3948+ WHERE cs.component_digest = :component_digest \
3949+ AND ( \
3950+ (cs.is_suffix = 0 AND cs.frame_key = :file) \
3951+ OR (cs.is_suffix = 1 AND \
3952+ substr(:file, length(:file) - length(cs.frame_key) + 1) = cs.frame_key) \
3953+ )",
3954+ ) ?;
3955+ let rows: Vec < String > = stmt
3956+ . query_map (
3957+ named_params ! {
3958+ ":component_digest" : component_digest,
3959+ ":file" : file,
3960+ } ,
3961+ |row| row. get ( 0 ) ,
3962+ ) ?
3963+ . collect :: < Result < _ , _ > > ( ) ?;
3964+ match rows. len ( ) {
3965+ 0 => Ok ( None ) ,
3966+ 1 => Ok ( Some ( rows. into_iter ( ) . next ( ) . unwrap ( ) ) ) ,
3967+ _ => {
3968+ warn ! ( "Multiple suffix matches for '{file}', returning None" ) ;
3969+ Ok ( None )
3970+ }
3971+ }
3972+ } ,
3973+ TxType :: Other ,
3974+ "get_source_file" ,
3975+ )
3976+ . await
3977+ }
3978+
38653979 #[ instrument( skip( self ) ) ]
38663980 async fn list_executions (
38673981 & self ,
0 commit comments