@@ -79,6 +79,8 @@ const executionHistoryPlugin = definePlugin(() => ({
7979 owner,
8080 entries : keys . map ( ( key ) => ( { collection : toolCalls . name , key } ) ) ,
8181 } ) ,
82+ listByPrefix : ( keyPrefix : string ) =>
83+ ctx . pluginStorage . list ( { collection : toolCalls . name , keyPrefix } ) ,
8284 get : ( key : string ) => ctx . storage . toolCalls . get ( { key } ) ,
8385 getForOwner : ( owner : Owner , key : string ) => ctx . storage . toolCalls . getForOwner ( { owner, key } ) ,
8486 query : ( input ?: PluginStorageCollectionQueryInput < typeof toolCalls > ) =>
@@ -163,6 +165,39 @@ const failPluginStorageBulkWriteAfterFirstRow = (db: FumaDb): FumaDb => {
163165 return wrap ( db ) ;
164166} ;
165167
168+ // Records how many `plugin_storage` rows each adapter read hands back, so a
169+ // test can tell a prefix applied in SQL from one applied after loading every
170+ // row of the collection into memory.
171+ const countPluginStorageReads = ( db : FumaDb , rowCounts : number [ ] ) : FumaDb => {
172+ const wrap = ( source : FumaDb ) : FumaDb =>
173+ new Proxy ( source , {
174+ get ( target , property , receiver ) {
175+ if ( property === "withContext" ) {
176+ const withContext = target . withContext ;
177+ return withContext === undefined
178+ ? undefined
179+ : ( context : unknown ) => wrap ( withContext ( context ) ) ;
180+ }
181+ if ( property === "transaction" ) {
182+ const transaction : FumaDb [ "transaction" ] = ( run ) =>
183+ target . transaction ( ( transactionDb ) => run ( wrap ( transactionDb ) ) ) ;
184+ return transaction ;
185+ }
186+ if ( property === "findMany" ) {
187+ const findMany : FumaDb [ "findMany" ] = async ( table , options ) => {
188+ const rows = await target . findMany ( table , options ) ;
189+ if ( table === "plugin_storage" ) rowCounts . push ( rows . length ) ;
190+ return rows ;
191+ } ;
192+ return findMany ;
193+ }
194+ return Reflect . get ( target , property , receiver ) ;
195+ } ,
196+ } ) ;
197+
198+ return wrap ( db ) ;
199+ } ;
200+
166201describe ( "plugin storage collections" , ( ) => {
167202 it . effect ( "queries declared indexes through the executor's SQLite FumaDB target" , ( ) =>
168203 Effect . gen ( function * ( ) {
@@ -524,4 +559,72 @@ describe("plugin storage collections", () => {
524559 } ) ;
525560 } ) ,
526561 ) ;
562+
563+ it . effect ( "narrows key-prefix reads in storage and keeps the result exact" , ( ) =>
564+ Effect . gen ( function * ( ) {
565+ const config = makeTestConfig ( {
566+ backend : "sqlite" ,
567+ plugins : [ executionHistoryPlugin ] as const ,
568+ } ) ;
569+ const rowCounts : number [ ] = [ ] ;
570+ const executor = yield * Effect . acquireRelease (
571+ createExecutor ( { ...config , db : countPluginStorageReads ( config . db , rowCounts ) } ) ,
572+ ( instance ) =>
573+ instance
574+ . close ( )
575+ . pipe (
576+ Effect . ignore ,
577+ Effect . andThen ( Effect . promise ( ( ) => config . testDb . close ( ) ) . pipe ( Effect . ignore ) ) ,
578+ ) ,
579+ ) ;
580+
581+ const keys = [
582+ "op.abc.1" ,
583+ "op.abc.2" ,
584+ "op.abd.3" ,
585+ // `_` and `%` are LIKE wildcards and SQLite LIKE ignores ASCII case, so
586+ // each exact key below has look-alikes a naive pushdown would return.
587+ "cloudflare_com.a" ,
588+ "cloudflareXcom.b" ,
589+ "CLOUDFLARE_COM.c" ,
590+ "cloudflare%com.d" ,
591+ "cloudflare-com.e" ,
592+ // A backslash is Postgres LIKE's default escape character.
593+ "back\\slash.f" ,
594+ "backslash.g" ,
595+ ...Array . from ( { length : 40 } , ( _ , index ) => `filler-${ String ( index ) . padStart ( 2 , "0" ) } ` ) ,
596+ ] ;
597+ yield * executor . executionHistory . recordMany (
598+ "org" ,
599+ keys . map ( ( key , index ) => ( {
600+ key,
601+ data : call ( {
602+ runId : "run-prefix" ,
603+ toolId : key ,
604+ status : "ok" ,
605+ startedAt : new Date ( Date . UTC ( 2026 , 4 , 29 , 13 , 0 , index ) ) . toISOString ( ) ,
606+ } ) ,
607+ } ) ) ,
608+ ) ;
609+
610+ const listed = ( keyPrefix : string ) =>
611+ executor . executionHistory
612+ . listByPrefix ( keyPrefix )
613+ . pipe ( Effect . map ( ( rows ) => rows . map ( ( row ) => row . key ) . sort ( ) ) ) ;
614+
615+ rowCounts . length = 0 ;
616+ expect ( yield * listed ( "op.abc." ) ) . toEqual ( [ "op.abc.1" , "op.abc.2" ] ) ;
617+ // Only the matching rows left storage; the other 48 were never loaded.
618+ expect ( rowCounts ) . toEqual ( [ 2 ] ) ;
619+
620+ expect ( yield * listed ( "cloudflare_com." ) ) . toEqual ( [ "cloudflare_com.a" ] ) ;
621+ expect ( yield * listed ( "cloudflare%com." ) ) . toEqual ( [ "cloudflare%com.d" ] ) ;
622+ expect ( yield * listed ( "back\\slash." ) ) . toEqual ( [ "back\\slash.f" ] ) ;
623+
624+ rowCounts . length = 0 ;
625+ const queried = yield * executor . executionHistory . query ( { keyPrefix : "op.abc." } ) ;
626+ expect ( queried . map ( ( entry ) => entry . key ) . sort ( ) ) . toEqual ( [ "op.abc.1" , "op.abc.2" ] ) ;
627+ expect ( rowCounts ) . toEqual ( [ 2 ] ) ;
628+ } ) ,
629+ ) ;
527630} ) ;
0 commit comments