@@ -3,7 +3,13 @@ import { Effect } from "effect";
33
44import { StorageError } from "./fuma-runtime" ;
55
6- import { makeInMemoryBlobStore , pluginBlobStore , type OwnerPartitions } from "./blob" ;
6+ import {
7+ makeInMemoryBlobStore ,
8+ makeR2BlobStore ,
9+ pluginBlobStore ,
10+ type OwnerPartitions ,
11+ type R2BucketLike ,
12+ } from "./blob" ;
713
814// v2: owner partitions instead of a scope stack. Reads fall through
915// [user, org] (user = innermost); writes/deletes name an explicit owner.
@@ -100,6 +106,106 @@ describe("pluginBlobStore", () => {
100106 ) ;
101107} ) ;
102108
109+ // Minimal in-memory R2 bucket double — same observable surface as the
110+ // `R2BucketLike` slice of a real binding.
111+ const makeFakeBucket = ( ) : { bucket : R2BucketLike ; objects : Map < string , string > } => {
112+ const objects = new Map < string , string > ( ) ;
113+ return {
114+ objects,
115+ bucket : {
116+ get : async ( key ) => {
117+ const value = objects . get ( key ) ;
118+ return value === undefined ? null : { text : async ( ) => value } ;
119+ } ,
120+ put : async ( key , value ) => {
121+ objects . set ( key , value ) ;
122+ } ,
123+ delete : async ( key ) => {
124+ objects . delete ( key ) ;
125+ } ,
126+ head : async ( key ) => ( objects . has ( key ) ? { } : null ) ,
127+ } ,
128+ } ;
129+ } ;
130+
131+ describe ( "makeR2BlobStore" , ( ) => {
132+ it . effect ( "round-trips a value and names objects `namespace/key`" , ( ) =>
133+ Effect . gen ( function * ( ) {
134+ const { bucket, objects } = makeFakeBucket ( ) ;
135+ const store = makeR2BlobStore ( bucket ) ;
136+
137+ yield * store . put ( "o:t1/openapi" , "spec/abc123" , "spec-text" ) ;
138+ expect ( yield * store . get ( "o:t1/openapi" , "spec/abc123" ) ) . toBe ( "spec-text" ) ;
139+ expect ( objects . has ( "o:t1/openapi/spec/abc123" ) ) . toBe ( true ) ;
140+ } ) ,
141+ ) ;
142+
143+ it . effect ( "get returns null for a missing object" , ( ) =>
144+ Effect . gen ( function * ( ) {
145+ const { bucket } = makeFakeBucket ( ) ;
146+ const store = makeR2BlobStore ( bucket ) ;
147+ expect ( yield * store . get ( "o:t1/openapi" , "missing" ) ) . toBeNull ( ) ;
148+ } ) ,
149+ ) ;
150+
151+ it . effect ( "getMany returns hits keyed by namespace" , ( ) =>
152+ Effect . gen ( function * ( ) {
153+ const { bucket } = makeFakeBucket ( ) ;
154+ const store = makeR2BlobStore ( bucket ) ;
155+ yield * store . put ( "u:t1:s1/openapi" , "k" , "user-value" ) ;
156+ yield * store . put ( "o:t1/openapi" , "k" , "org-value" ) ;
157+
158+ const hits = yield * store . getMany ( [ "u:t1:s1/openapi" , "o:t1/openapi" , "o:t2/openapi" ] , "k" ) ;
159+ expect ( hits . size ) . toBe ( 2 ) ;
160+ expect ( hits . get ( "u:t1:s1/openapi" ) ) . toBe ( "user-value" ) ;
161+ expect ( hits . get ( "o:t1/openapi" ) ) . toBe ( "org-value" ) ;
162+ } ) ,
163+ ) ;
164+
165+ it . effect ( "delete and has agree" , ( ) =>
166+ Effect . gen ( function * ( ) {
167+ const { bucket } = makeFakeBucket ( ) ;
168+ const store = makeR2BlobStore ( bucket ) ;
169+ yield * store . put ( "ns/p" , "k" , "v" ) ;
170+ expect ( yield * store . has ( "ns/p" , "k" ) ) . toBe ( true ) ;
171+ yield * store . delete ( "ns/p" , "k" ) ;
172+ expect ( yield * store . has ( "ns/p" , "k" ) ) . toBe ( false ) ;
173+ expect ( yield * store . get ( "ns/p" , "k" ) ) . toBeNull ( ) ;
174+ } ) ,
175+ ) ;
176+
177+ it . effect ( "bucket failures surface as StorageError" , ( ) =>
178+ Effect . gen ( function * ( ) {
179+ const failing : R2BucketLike = {
180+ get : async ( ) => {
181+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- test double simulating a bucket outage
182+ throw { name : "R2Error" , message : "bucket unavailable" } ;
183+ } ,
184+ put : async ( ) => { } ,
185+ delete : async ( ) => { } ,
186+ head : async ( ) => null ,
187+ } ;
188+ const store = makeR2BlobStore ( failing ) ;
189+ const err = yield * store . get ( "ns" , "k" ) . pipe ( Effect . flip ) ;
190+ expect ( err ) . toBeInstanceOf ( StorageError ) ;
191+ } ) ,
192+ ) ;
193+
194+ it . effect ( "works behind pluginBlobStore owner partitioning" , ( ) =>
195+ Effect . gen ( function * ( ) {
196+ const { bucket } = makeFakeBucket ( ) ;
197+ const store = makeR2BlobStore ( bucket ) ;
198+ const plugin = pluginBlobStore ( store , partitions ( "o:t1" , "u:t1:s1" ) , "openapi" ) ;
199+
200+ yield * plugin . put ( "spec/h1" , "org-spec" , { owner : "org" } ) ;
201+ expect ( yield * plugin . get ( "spec/h1" ) ) . toBe ( "org-spec" ) ;
202+
203+ yield * plugin . put ( "spec/h1" , "user-spec" , { owner : "user" } ) ;
204+ expect ( yield * plugin . get ( "spec/h1" ) ) . toBe ( "user-spec" ) ;
205+ } ) ,
206+ ) ;
207+ } ) ;
208+
103209describe ( "BlobStore.getMany" , ( ) => {
104210 it . effect ( "returns hits keyed by namespace" , ( ) =>
105211 Effect . gen ( function * ( ) {
0 commit comments