[{"data":1,"prerenderedAt":2880},["ShallowReactive",2],{"navigation_docs_en":3,"-en-storage-mail-storage":342,"-en-storage-mail-storage-surround":2875},[4,45,61,96,132,166,196,217,241,265],{"title":5,"icon":6,"path":7,"stem":8,"children":9,"page":44},"Getting Started","i-lucide-rocket","\u002Fen\u002Fgetting-started","en\u002F1.getting-started",[10,15,20,24,29,34,39],{"title":11,"path":12,"stem":13,"icon":14},"Introduction","\u002Fen\u002Fgetting-started\u002Fintroduction","en\u002F1.getting-started\u002F1.introduction","i-lucide-house",{"title":16,"path":17,"stem":18,"icon":19},"Coming from Laravel","\u002Fen\u002Fgetting-started\u002Fcoming-from-laravel","en\u002F1.getting-started\u002F2.coming-from-laravel","i-lucide-arrow-right-left",{"title":21,"path":22,"stem":23,"icon":19},"Coming from Symfony","\u002Fen\u002Fgetting-started\u002Fcoming-from-symfony","en\u002F1.getting-started\u002F3.coming-from-symfony",{"title":25,"path":26,"stem":27,"icon":28},"Installation","\u002Fen\u002Fgetting-started\u002Finstallation","en\u002F1.getting-started\u002F4.installation","i-lucide-download",{"title":30,"path":31,"stem":32,"icon":33},"Your first module","\u002Fen\u002Fgetting-started\u002Ffirst-module","en\u002F1.getting-started\u002F5.first-module","i-lucide-package-plus",{"title":35,"path":36,"stem":37,"icon":38},"Project structure","\u002Fen\u002Fgetting-started\u002Fproject-structure","en\u002F1.getting-started\u002F6.project-structure","i-lucide-folder-tree",{"title":40,"path":41,"stem":42,"icon":43},"The shop sample application","\u002Fen\u002Fgetting-started\u002Fsample-app","en\u002F1.getting-started\u002F7.sample-app","i-lucide-shopping-cart",false,{"title":46,"icon":47,"path":48,"stem":49,"children":50,"page":44},"Reference","i-lucide-book-open","\u002Fen\u002Freference","en\u002F10.reference",[51,56],{"title":52,"path":53,"stem":54,"icon":55},"Configuration","\u002Fen\u002Freference\u002Fconfiguration","en\u002F10.reference\u002F1.configuration","i-lucide-settings",{"title":57,"path":58,"stem":59,"icon":60},"External dependencies","\u002Fen\u002Freference\u002Fdependencies","en\u002F10.reference\u002F2.dependencies","i-lucide-package",{"title":62,"icon":63,"path":64,"stem":65,"children":66,"page":44},"Concepts","i-lucide-lightbulb","\u002Fen\u002Fconcepts","en\u002F2.concepts",[67,72,77,82,86,91],{"title":68,"path":69,"stem":70,"icon":71},"Architecture","\u002Fen\u002Fconcepts\u002Farchitecture","en\u002F2.concepts\u002F1.architecture","i-lucide-layers",{"title":73,"path":74,"stem":75,"icon":76},"Application lifecycle","\u002Fen\u002Fconcepts\u002Fapplication-lifecycle","en\u002F2.concepts\u002F2.application-lifecycle","i-lucide-power",{"title":78,"path":79,"stem":80,"icon":81},"Error model","\u002Fen\u002Fconcepts\u002Ferror-model","en\u002F2.concepts\u002F3.error-model","i-lucide-shield-alert",{"title":52,"path":83,"stem":84,"icon":85},"\u002Fen\u002Fconcepts\u002Fconfiguration","en\u002F2.concepts\u002F4.configuration","i-lucide-settings-2",{"title":87,"path":88,"stem":89,"icon":90},"Codegen pipeline","\u002Fen\u002Fconcepts\u002Fcodegen-pipeline","en\u002F2.concepts\u002F5.codegen-pipeline","i-lucide-file-json",{"title":92,"path":93,"stem":94,"icon":95},"Design patterns","\u002Fen\u002Fconcepts\u002Fdesign-patterns","en\u002F2.concepts\u002F6.design-patterns","i-lucide-puzzle",{"title":97,"icon":98,"path":99,"stem":100,"children":101,"page":44},"HTTP & Routing","i-lucide-globe","\u002Fen\u002Fhttp","en\u002F3.http",[102,107,112,117,122,127],{"title":103,"path":104,"stem":105,"icon":106},"The server core","\u002Fen\u002Fhttp\u002Fserver","en\u002F3.http\u002F1.server","i-lucide-server",{"title":108,"path":109,"stem":110,"icon":111},"The Fiber engine","\u002Fen\u002Fhttp\u002Ffiber","en\u002F3.http\u002F2.fiber","i-lucide-zap",{"title":113,"path":114,"stem":115,"icon":116},"The chi engine","\u002Fen\u002Fhttp\u002Fchi","en\u002F3.http\u002F3.chi","i-lucide-route",{"title":118,"path":119,"stem":120,"icon":121},"Error responses","\u002Fen\u002Fhttp\u002Ferror-responses","en\u002F3.http\u002F4.error-responses","i-lucide-octagon-alert",{"title":123,"path":124,"stem":125,"icon":126},"Validation","\u002Fen\u002Fhttp\u002Fvalidation","en\u002F3.http\u002F5.validation","i-lucide-badge-check",{"title":128,"path":129,"stem":130,"icon":131},"Pagination","\u002Fen\u002Fhttp\u002Fpagination","en\u002F3.http\u002F6.pagination","i-lucide-list-ordered",{"title":133,"icon":134,"path":135,"stem":136,"children":137,"page":44},"Database","i-lucide-database","\u002Fen\u002Fdatabase","en\u002F4.database",[138,142,147,151,156,161],{"title":139,"path":140,"stem":141,"icon":134},"Overview","\u002Fen\u002Fdatabase\u002Foverview","en\u002F4.database\u002F1.overview",{"title":143,"path":144,"stem":145,"icon":146},"pgx","\u002Fen\u002Fdatabase\u002Fpgx","en\u002F4.database\u002F2.pgx","i-lucide-plug",{"title":148,"path":149,"stem":150,"icon":71},"bun","\u002Fen\u002Fdatabase\u002Fbun","en\u002F4.database\u002F3.bun",{"title":152,"path":153,"stem":154,"icon":155},"Transactions","\u002Fen\u002Fdatabase\u002Ftransactions","en\u002F4.database\u002F4.transactions","i-lucide-git-merge",{"title":157,"path":158,"stem":159,"icon":160},"Migrations","\u002Fen\u002Fdatabase\u002Fmigrations","en\u002F4.database\u002F5.migrations","i-lucide-file-stack",{"title":162,"path":163,"stem":164,"icon":165},"Seeders","\u002Fen\u002Fdatabase\u002Fseeding","en\u002F4.database\u002F6.seeding","i-lucide-sprout",{"title":167,"icon":168,"path":169,"stem":170,"children":171,"page":44},"Async & Background","i-lucide-workflow","\u002Fen\u002Fasync","en\u002F5.async",[172,176,181,186,191],{"title":173,"path":174,"stem":175,"icon":131},"Queue","\u002Fen\u002Fasync\u002Fqueue","en\u002F5.async\u002F1.queue",{"title":177,"path":178,"stem":179,"icon":180},"Events","\u002Fen\u002Fasync\u002Fevents","en\u002F5.async\u002F2.events","i-lucide-radio",{"title":182,"path":183,"stem":184,"icon":185},"Outbox","\u002Fen\u002Fasync\u002Foutbox","en\u002F5.async\u002F3.outbox","i-lucide-inbox",{"title":187,"path":188,"stem":189,"icon":190},"Scheduling","\u002Fen\u002Fasync\u002Fscheduler","en\u002F5.async\u002F4.scheduler","i-lucide-calendar-clock",{"title":192,"path":193,"stem":194,"icon":195},"Worker","\u002Fen\u002Fasync\u002Fworker","en\u002F5.async\u002F5.worker","i-lucide-cog",{"title":197,"icon":198,"path":199,"stem":200,"children":201,"page":44},"Security","i-lucide-shield-check","\u002Fen\u002Fsecurity","en\u002F6.security",[202,207,212],{"title":203,"path":204,"stem":205,"icon":206},"Authentication","\u002Fen\u002Fsecurity\u002Fauthentication","en\u002F6.security\u002F1.authentication","i-lucide-key-round",{"title":208,"path":209,"stem":210,"icon":211},"RBAC","\u002Fen\u002Fsecurity\u002Frbac","en\u002F6.security\u002F2.rbac","i-lucide-users",{"title":213,"path":214,"stem":215,"icon":216},"Policies","\u002Fen\u002Fsecurity\u002Fpolicies","en\u002F6.security\u002F3.policies","i-lucide-gavel",{"title":218,"icon":219,"path":220,"stem":221,"children":222,"page":44},"Observability","i-lucide-activity","\u002Fen\u002Fobservability","en\u002F7.observability",[223,226,231,236],{"title":139,"path":224,"stem":225,"icon":219},"\u002Fen\u002Fobservability\u002Foverview","en\u002F7.observability\u002F1.overview",{"title":227,"path":228,"stem":229,"icon":230},"Logging","\u002Fen\u002Fobservability\u002Flogging","en\u002F7.observability\u002F2.logging","i-lucide-scroll-text",{"title":232,"path":233,"stem":234,"icon":235},"OpenTelemetry","\u002Fen\u002Fobservability\u002Fopentelemetry","en\u002F7.observability\u002F3.opentelemetry","i-lucide-radar",{"title":237,"path":238,"stem":239,"icon":240},"Sentry","\u002Fen\u002Fobservability\u002Fsentry","en\u002F7.observability\u002F4.sentry","i-lucide-bug",{"title":242,"icon":243,"path":244,"stem":245,"children":246,"page":44},"Storage & Mail","i-lucide-hard-drive","\u002Fen\u002Fstorage-mail","en\u002F8.storage-mail",[247,251,256,261],{"title":248,"path":249,"stem":250,"icon":243},"Storage","\u002Fen\u002Fstorage-mail\u002Fstorage","en\u002F8.storage-mail\u002F1.storage",{"title":252,"path":253,"stem":254,"icon":255},"Email","\u002Fen\u002Fstorage-mail\u002Fmail","en\u002F8.storage-mail\u002F2.mail","i-lucide-mail",{"title":257,"path":258,"stem":259,"icon":260},"Notifications","\u002Fen\u002Fstorage-mail\u002Fnotifications","en\u002F8.storage-mail\u002F3.notifications","i-lucide-bell",{"title":262,"path":263,"stem":264,"icon":180},"Realtime","\u002Fen\u002Fstorage-mail\u002Frealtime","en\u002F8.storage-mail\u002F4.realtime",{"title":266,"icon":267,"path":268,"stem":269,"children":270,"page":44},"CLI Reference","i-lucide-terminal","\u002Fen\u002Fcli","en\u002F9.cli",[271,274,279,284,289,294,299,303,308,313,318,323,328,333,337],{"title":139,"path":272,"stem":273,"icon":267},"\u002Fen\u002Fcli\u002Foverview","en\u002F9.cli\u002F1.overview",{"title":275,"path":276,"stem":277,"icon":278},"add db","\u002Fen\u002Fcli\u002Fadd-db","en\u002F9.cli\u002F10.add-db","i-lucide-database-zap",{"title":280,"path":281,"stem":282,"icon":283},"add compose \u002F add docker","\u002Fen\u002Fcli\u002Fadd-compose","en\u002F9.cli\u002F11.add-compose","i-lucide-container",{"title":285,"path":286,"stem":287,"icon":288},"add mail","\u002Fen\u002Fcli\u002Fadd-mail","en\u002F9.cli\u002F12.add-mail","i-lucide-mail-plus",{"title":290,"path":291,"stem":292,"icon":293},"add notification","\u002Fen\u002Fcli\u002Fadd-notification","en\u002F9.cli\u002F13.add-notification","i-lucide-bell-plus",{"title":295,"path":296,"stem":297,"icon":298},"add realtime","\u002Fen\u002Fcli\u002Fadd-realtime","en\u002F9.cli\u002F14.add-realtime","i-lucide-radio-tower",{"title":300,"path":301,"stem":302,"icon":165},"add seeder","\u002Fen\u002Fcli\u002Fadd-seeder","en\u002F9.cli\u002F15.add-seeder",{"title":304,"path":305,"stem":306,"icon":307},"new project","\u002Fen\u002Fcli\u002Fnew-project","en\u002F9.cli\u002F2.new-project","i-lucide-folder-plus",{"title":309,"path":310,"stem":311,"icon":312},"new module","\u002Fen\u002Fcli\u002Fnew-module","en\u002F9.cli\u002F3.new-module","i-lucide-blocks",{"title":314,"path":315,"stem":316,"icon":317},"add surface","\u002Fen\u002Fcli\u002Fadd-surface","en\u002F9.cli\u002F4.add-surface","i-lucide-layers-2",{"title":319,"path":320,"stem":321,"icon":322},"add core","\u002Fen\u002Fcli\u002Fadd-core","en\u002F9.cli\u002F5.add-core","i-lucide-box",{"title":324,"path":325,"stem":326,"icon":327},"add handler","\u002Fen\u002Fcli\u002Fadd-handler","en\u002F9.cli\u002F6.add-handler","i-lucide-webhook",{"title":329,"path":330,"stem":331,"icon":332},"new migration","\u002Fen\u002Fcli\u002Fnew-migration","en\u002F9.cli\u002F7.new-migration","i-lucide-file-plus",{"title":334,"path":335,"stem":336,"icon":195},"worker generators","\u002Fen\u002Fcli\u002Fworker-generators","en\u002F9.cli\u002F8.worker-generators",{"title":338,"path":339,"stem":340,"icon":341},"upgrade templates","\u002Fen\u002Fcli\u002Fupgrade-templates","en\u002F9.cli\u002F9.upgrade-templates","i-lucide-refresh-cw",{"id":343,"title":248,"body":344,"description":2868,"extension":2869,"links":2870,"meta":2871,"navigation":2872,"path":249,"seo":2873,"stem":250,"__hash__":2874},"docs_en\u002Fen\u002F8.storage-mail\u002F1.storage.md",{"type":345,"value":346,"toc":2858},"minimark",[347,369,374,386,430,467,475,479,808,811,876,880,940,946,986,997,1001,1011,1060,1174,1180,1215,1219,1224,1556,1559,2171,2174,2230,2242,2309,2313,2316,2579,2821,2824,2833,2837,2854],[348,349,350,351,355,356,360,361,364,365,368],"p",{},"In the ",[352,353,354],"a",{"href":41},"shop sample app",", the ",[357,358,359],"code",{},"admin"," surface uploads product images to object storage. Business code sees exactly one thing for this: the ",[357,362,363],{},"storage.Store"," interface. Whether AWS S3, MinIO, or RustFS sits behind it, and how the AWS SDK gets configured, is the concern of the wiring in ",[357,366,367],{},"main.go",".",[370,371,373],"h2",{"id":372},"the-one-deliberate-abstraction","The one deliberate abstraction",[348,375,376,377,380,381,385],{},"The kit's design principle is ",[352,378,379],{"href":69},"\"centralize, don't abstract\"",": fasthttp\u002Fchi, pgx, and asynq don't hide behind wrappers of their own. Storage is one of the exceptions where the interface is ",[382,383,384],"strong",{},"deliberate",":",[387,388,389,410,413],"ul",{},[390,391,392,393,395,396,399,400,403,404,407,408,368],"li",{},"The AWS SDK is a large dependency with a configuration world of its own. ",[357,394,363],{}," lives in the kit's ",[357,397,398],{},"storage"," package and ",[382,401,402],{},"imports nothing from the AWS SDK",", so your service layer doesn't have to either. The SDK is isolated in the ",[357,405,406],{},"storage\u002Fs3"," subpackage, imported only by ",[357,409,367],{},[390,411,412],{},"In tests the interface swaps trivially for an in-memory fake: no MinIO container needed for a service unit test.",[390,414,415,416,419,420,422,423,425,426,429],{},"The interface makes the driver ",[382,417,418],{},"replaceable",": if you ever need to switch storage backends (a different cloud, a self-hosted solution, a CDN-based storage), all it takes is a new driver satisfying ",[357,421,363],{},": the service layer stays untouched. Implementing a new driver is deliberately cheap: the interface is only 6 methods, and ",[357,424,406],{}," is the pattern to follow (with a compile-time interface assertion: ",[357,427,428],{},"var _ storage.Store = (*Client)(nil)",").",[348,431,432,433,436,437,439,440,442,443,446,447,450,451,454,455,458,459,462,463,466],{},"This package is ",[382,434,435],{},"entirely opt-in",": the kit doesn't force its use. If you don't want ",[357,438,363],{},", you simply wire your own file-handling solution into your service layer. The rest of the kit works independently of that choice. A project generated with ",[357,441,304],{}," already ships a ",[357,444,445],{},"rustfs"," service in ",[357,448,449],{},"compose.yml"," and matching ",[357,452,453],{},"S3_*"," lines in ",[357,456,457],{},".env.example",", but actually wiring up ",[357,460,461],{},"s3.Config"," (adding the field, calling ",[357,464,465],{},"s3.New",") always stays a decision the developer makes.",[348,468,469,471,472,474],{},[357,470,363],{}," provides a single, uniform interface for object storage, and the ",[357,473,453],{}," env vars configure the connection details for the driver behind it.",[370,476,478],{"id":477},"the-store-interface","The Store interface",[480,481,486],"pre",{"className":482,"code":483,"language":484,"meta":485,"style":485},"language-go shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","import \"github.com\u002Fgp-system\u002Fgpsystem\u002Fstorage\"\n\n\u002F\u002F Store is a flat key\u002Fvalue object store.\ntype Store interface {\n    \u002F\u002F Upload streams an object to the store under key.\n    Upload(ctx context.Context, key string, r io.Reader, contentType string, size int64) error\n    \u002F\u002F NewReader opens the object for streaming reads. The caller must Close it.\n    NewReader(ctx context.Context, key string) (io.ReadCloser, error)\n    \u002F\u002F Delete removes the object; deleting a missing key is not an error.\n    Delete(ctx context.Context, key string) error\n    \u002F\u002F URL returns the public URL for the key (PublicBaseURL + key).\n    URL(ctx context.Context, key string) (string, error)\n    \u002F\u002F SignedURL returns a pre-signed GET URL valid for ttl.\n    SignedURL(ctx context.Context, key string, ttl time.Duration) (string, error)\n    \u002F\u002F PublicBaseURL returns the base under which objects are publicly served.\n    PublicBaseURL() string\n}\n","go","",[357,487,488,508,515,522,537,543,609,615,658,664,690,696,731,737,784,790,802],{"__ignoreMap":485},[489,490,493,497,501,505],"span",{"class":491,"line":492},"line",1,[489,494,496],{"class":495},"s7zQu","import",[489,498,500],{"class":499},"sMK4o"," \"",[489,502,504],{"class":503},"sBMFI","github.com\u002Fgp-system\u002Fgpsystem\u002Fstorage",[489,506,507],{"class":499},"\"\n",[489,509,511],{"class":491,"line":510},2,[489,512,514],{"emptyLinePlaceholder":513},true,"\n",[489,516,518],{"class":491,"line":517},3,[489,519,521],{"class":520},"sHwdD","\u002F\u002F Store is a flat key\u002Fvalue object store.\n",[489,523,525,528,531,534],{"class":491,"line":524},4,[489,526,527],{"class":499},"type",[489,529,530],{"class":503}," Store",[489,532,533],{"class":499}," interface",[489,535,536],{"class":499}," {\n",[489,538,540],{"class":491,"line":539},5,[489,541,542],{"class":520},"    \u002F\u002F Upload streams an object to the store under key.\n",[489,544,546,550,553,557,560,562,565,568,571,575,577,580,583,585,588,590,593,595,597,600,603,606],{"class":491,"line":545},6,[489,547,549],{"class":548},"s2Zo4","    Upload",[489,551,552],{"class":499},"(",[489,554,556],{"class":555},"sHdIc","ctx",[489,558,559],{"class":503}," context",[489,561,368],{"class":499},[489,563,564],{"class":503},"Context",[489,566,567],{"class":499},",",[489,569,570],{"class":555}," key",[489,572,574],{"class":573},"spNyl"," string",[489,576,567],{"class":499},[489,578,579],{"class":555}," r",[489,581,582],{"class":503}," io",[489,584,368],{"class":499},[489,586,587],{"class":503},"Reader",[489,589,567],{"class":499},[489,591,592],{"class":555}," contentType",[489,594,574],{"class":573},[489,596,567],{"class":499},[489,598,599],{"class":555}," size",[489,601,602],{"class":573}," int64",[489,604,605],{"class":499},")",[489,607,608],{"class":573}," error\n",[489,610,612],{"class":491,"line":611},7,[489,613,614],{"class":520},"    \u002F\u002F NewReader opens the object for streaming reads. The caller must Close it.\n",[489,616,618,621,623,625,627,629,631,633,635,637,639,642,645,647,650,652,655],{"class":491,"line":617},8,[489,619,620],{"class":548},"    NewReader",[489,622,552],{"class":499},[489,624,556],{"class":555},[489,626,559],{"class":503},[489,628,368],{"class":499},[489,630,564],{"class":503},[489,632,567],{"class":499},[489,634,570],{"class":555},[489,636,574],{"class":573},[489,638,605],{"class":499},[489,640,641],{"class":499}," (",[489,643,644],{"class":503},"io",[489,646,368],{"class":499},[489,648,649],{"class":503},"ReadCloser",[489,651,567],{"class":499},[489,653,654],{"class":573}," error",[489,656,657],{"class":499},")\n",[489,659,661],{"class":491,"line":660},9,[489,662,663],{"class":520},"    \u002F\u002F Delete removes the object; deleting a missing key is not an error.\n",[489,665,667,670,672,674,676,678,680,682,684,686,688],{"class":491,"line":666},10,[489,668,669],{"class":548},"    Delete",[489,671,552],{"class":499},[489,673,556],{"class":555},[489,675,559],{"class":503},[489,677,368],{"class":499},[489,679,564],{"class":503},[489,681,567],{"class":499},[489,683,570],{"class":555},[489,685,574],{"class":573},[489,687,605],{"class":499},[489,689,608],{"class":573},[489,691,693],{"class":491,"line":692},11,[489,694,695],{"class":520},"    \u002F\u002F URL returns the public URL for the key (PublicBaseURL + key).\n",[489,697,699,702,704,706,708,710,712,714,716,718,720,722,725,727,729],{"class":491,"line":698},12,[489,700,701],{"class":548},"    URL",[489,703,552],{"class":499},[489,705,556],{"class":555},[489,707,559],{"class":503},[489,709,368],{"class":499},[489,711,564],{"class":503},[489,713,567],{"class":499},[489,715,570],{"class":555},[489,717,574],{"class":573},[489,719,605],{"class":499},[489,721,641],{"class":499},[489,723,724],{"class":573},"string",[489,726,567],{"class":499},[489,728,654],{"class":573},[489,730,657],{"class":499},[489,732,734],{"class":491,"line":733},13,[489,735,736],{"class":520},"    \u002F\u002F SignedURL returns a pre-signed GET URL valid for ttl.\n",[489,738,740,743,745,747,749,751,753,755,757,759,761,764,767,769,772,774,776,778,780,782],{"class":491,"line":739},14,[489,741,742],{"class":548},"    SignedURL",[489,744,552],{"class":499},[489,746,556],{"class":555},[489,748,559],{"class":503},[489,750,368],{"class":499},[489,752,564],{"class":503},[489,754,567],{"class":499},[489,756,570],{"class":555},[489,758,574],{"class":573},[489,760,567],{"class":499},[489,762,763],{"class":555}," ttl",[489,765,766],{"class":503}," time",[489,768,368],{"class":499},[489,770,771],{"class":503},"Duration",[489,773,605],{"class":499},[489,775,641],{"class":499},[489,777,724],{"class":573},[489,779,567],{"class":499},[489,781,654],{"class":573},[489,783,657],{"class":499},[489,785,787],{"class":491,"line":786},15,[489,788,789],{"class":520},"    \u002F\u002F PublicBaseURL returns the base under which objects are publicly served.\n",[489,791,793,796,799],{"class":491,"line":792},16,[489,794,795],{"class":548},"    PublicBaseURL",[489,797,798],{"class":499},"()",[489,800,801],{"class":573}," string\n",[489,803,805],{"class":491,"line":804},17,[489,806,807],{"class":499},"}\n",[348,809,810],{},"A few things about the contract:",[387,812,813,839,851,857,870],{},[390,814,815,818,819,822,823,826,827,830,831,834,835,838],{},[357,816,817],{},"Upload"," ",[382,820,821],{},"streams",": it takes an ",[357,824,825],{},"io.Reader",", not a ",[357,828,829],{},"[]byte",", so uploading a large file doesn't fill memory. Passing ",[357,832,833],{},"size"," (when known) helps the client; with ",[357,836,837],{},"0"," or negative it omits the Content-Length.",[390,840,841,844,845,818,848,368],{},[357,842,843],{},"NewReader"," streams as well: the returned ",[357,846,847],{},"io.ReadCloser",[382,849,850],{},"must be closed by the caller",[390,852,853,856],{},[357,854,855],{},"Delete"," is idempotent: deleting a missing key is not an error.",[390,858,859,862,863,866,867,869],{},[357,860,861],{},"URL"," is for public buckets\u002FCDNs: the configured ",[357,864,865],{},"PublicBaseURL"," + key. It errors when no ",[357,868,865],{}," is configured.",[390,871,872,875],{},[357,873,874],{},"SignedURL"," gives a time-limited, signed GET URL for private objects: the client downloads straight from storage, bypassing the backend.",[370,877,879],{"id":878},"storages3-the-built-in-implementation","storage\u002Fs3: the built-in implementation",[480,881,883],{"className":482,"code":882,"language":484,"meta":485,"style":485},"import \"github.com\u002Fgp-system\u002Fgpsystem\u002Fstorage\u002Fs3\"\n\nstore, err := s3.New(ctx, cfg.Storage) \u002F\u002F *s3.Client implements storage.Store\n",[357,884,885,896,900],{"__ignoreMap":485},[489,886,887,889,891,894],{"class":491,"line":492},[489,888,496],{"class":495},[489,890,500],{"class":499},[489,892,893],{"class":503},"github.com\u002Fgp-system\u002Fgpsystem\u002Fstorage\u002Fs3",[489,895,507],{"class":499},[489,897,898],{"class":491,"line":510},[489,899,514],{"emptyLinePlaceholder":513},[489,901,902,906,908,911,914,917,919,922,924,926,928,931,933,935,937],{"class":491,"line":517},[489,903,905],{"class":904},"sTEyZ","store",[489,907,567],{"class":499},[489,909,910],{"class":904}," err ",[489,912,913],{"class":499},":=",[489,915,916],{"class":904}," s3",[489,918,368],{"class":499},[489,920,921],{"class":548},"New",[489,923,552],{"class":499},[489,925,556],{"class":904},[489,927,567],{"class":499},[489,929,930],{"class":904}," cfg",[489,932,368],{"class":499},[489,934,248],{"class":904},[489,936,605],{"class":499},[489,938,939],{"class":520}," \u002F\u002F *s3.Client implements storage.Store\n",[348,941,942,945],{},[357,943,944],{},"s3.New(ctx, cfg)"," works with any S3-compatible service:",[387,947,948,969],{},[390,949,950,953,954,957,958,961,962,965,966,429],{},[382,951,952],{},"AWS S3",": leave ",[357,955,956],{},"Endpoint"," empty; with empty ",[357,959,960],{},"AccessKey","\u002F",[357,963,964],{},"SecretKey"," the default AWS credential chain applies (IAM role, env, ",[357,967,968],{},"~\u002F.aws",[390,970,971,974,975,977,978,981,982,985],{},[382,972,973],{},"MinIO, RustFS, and friends",": set ",[357,976,956],{}," and ",[357,979,980],{},"UsePathStyle: true"," (most self-hosted S3 expects ",[357,983,984],{},"\u002Fbucket\u002Fkey"," addressing instead of virtual hosts), plus static keys.",[348,987,988,989,992,993,996],{},"The client emits an OTel span per S3 API call (",[357,990,991],{},"otelaws"," instrumentation); this is free with a no-op tracer provider. With ",[352,994,995],{"href":233},"telemetry"," enabled, though, the upload sits right there in the trace waterfall next to the DB queries.",[998,999,52],"h3",{"id":1000},"configuration",[348,1002,1003,1004,1006,1007,1010],{},"Compose ",[357,1005,461],{}," into your project config under an ",[357,1008,1009],{},"S3_"," prefix:",[480,1012,1014],{"className":482,"code":1013,"language":484,"meta":485,"style":485},"type Config struct {\n    \u002F\u002F ...\n    Storage s3.Config `envPrefix:\"S3_\"`\n}\n",[357,1015,1016,1028,1033,1056],{"__ignoreMap":485},[489,1017,1018,1020,1023,1026],{"class":491,"line":492},[489,1019,527],{"class":499},[489,1021,1022],{"class":503}," Config",[489,1024,1025],{"class":499}," struct",[489,1027,536],{"class":499},[489,1029,1030],{"class":491,"line":510},[489,1031,1032],{"class":520},"    \u002F\u002F ...\n",[489,1034,1035,1038,1041,1043,1046,1049,1053],{"class":491,"line":517},[489,1036,1037],{"class":904},"    Storage ",[489,1039,1040],{"class":503},"s3",[489,1042,368],{"class":499},[489,1044,1045],{"class":503},"Config",[489,1047,1048],{"class":499}," `",[489,1050,1052],{"class":1051},"sfazB","envPrefix:\"S3_\"",[489,1054,1055],{"class":499},"`\n",[489,1057,1058],{"class":491,"line":524},[489,1059,807],{"class":499},[1061,1062,1063,1079],"table",{},[1064,1065,1066],"thead",{},[1067,1068,1069,1073,1076],"tr",{},[1070,1071,1072],"th",{},"Variable",[1070,1074,1075],{},"Default",[1070,1077,1078],{},"Meaning",[1080,1081,1082,1096,1110,1124,1140,1158],"tbody",{},[1067,1083,1084,1090,1093],{},[1085,1086,1087],"td",{},[357,1088,1089],{},"S3_ENDPOINT",[1085,1091,1092],{},"empty",[1085,1094,1095],{},"empty → AWS; for MinIO\u002FRustFS the service address",[1067,1097,1098,1103,1108],{},[1085,1099,1100],{},[357,1101,1102],{},"S3_REGION",[1085,1104,1105],{},[357,1106,1107],{},"us-east-1",[1085,1109],{},[1067,1111,1112,1117,1122],{},[1085,1113,1114],{},[357,1115,1116],{},"S3_BUCKET",[1085,1118,1119],{},[382,1120,1121],{},"required",[1085,1123],{},[1067,1125,1126,1135,1137],{},[1085,1127,1128,1131,1132],{},[357,1129,1130],{},"S3_ACCESS_KEY"," \u002F ",[357,1133,1134],{},"S3_SECRET_KEY",[1085,1136,1092],{},[1085,1138,1139],{},"empty → default AWS credential chain",[1067,1141,1142,1147,1152],{},[1085,1143,1144],{},[357,1145,1146],{},"S3_USE_PATH_STYLE",[1085,1148,1149],{},[357,1150,1151],{},"false",[1085,1153,1154,1157],{},[357,1155,1156],{},"true"," for most self-hosted S3",[1067,1159,1160,1165,1167],{},[1085,1161,1162],{},[357,1163,1164],{},"S3_PUBLIC_BASE_URL",[1085,1166,1092],{},[1085,1168,1169,1170,1173],{},"base for ",[357,1171,1172],{},"URL()"," (CDN or public bucket endpoint)",[348,1175,1176,1177,368],{},"The full list lives in the ",[352,1178,1179],{"href":53},"configuration reference",[1181,1182,1183,1184,1189,1190,1192,1193,1195,1196,1198,1199,1201,1202,1204,1205,1208,1209,1211,1212,1214],"note",{},"A project generated with ",[352,1185,1187],{"href":1186},"\u002Fen\u002Fcli\u002Fnew-project#the-compose-dev-stack",[357,1188,304],{}," already runs a ",[357,1191,445],{}," service (self-hosted, S3-compatible) in ",[357,1194,449],{},", and ",[357,1197,457],{}," ships the matching ",[357,1200,453],{}," lines, but wiring ",[357,1203,461],{}," (adding the field to ",[357,1206,1207],{},"config.go",", calling ",[357,1210,465],{}," in ",[357,1213,367],{},") stays opt-in, per the pattern above.",[370,1216,1218],{"id":1217},"in-the-shop-product-image-upload","In the shop: product image upload",[348,1220,1221,1222,385],{},"The admin surface's image upload follows the usual layering: the handler only extracts the multipart file, and the business logic (key building, upload, URL persistence) lives in the service, which depends on ",[357,1223,363],{},[480,1225,1228],{"className":482,"code":1226,"filename":1227,"language":484,"meta":485,"style":485},"type ProductImageService struct {\n    store storage.Store\n    repo  ProductRepository\n}\n\nfunc (s *ProductImageService) Upload(\n    ctx context.Context, productID string,\n    r io.Reader, contentType string, size int64,\n) (string, error) {\n    key := \"products\u002F\" + productID + \"\u002Fimage\"\n    if err := s.store.Upload(ctx, key, r, contentType, size); err != nil {\n        return \"\", err\n    }\n    url, err := s.store.URL(ctx, key)\n    if err != nil {\n        return \"\", err\n    }\n    return url, s.repo.SetImageURL(ctx, productID, url)\n}\n","internal\u002Fmodules\u002Fshop\u002Fsurfaces\u002Fadmin\u002Fservice\u002Fproduct_image.go",[357,1229,1230,1241,1253,1261,1265,1269,1293,1314,1339,1355,1386,1439,1452,1457,1488,1500,1510,1514,1551],{"__ignoreMap":485},[489,1231,1232,1234,1237,1239],{"class":491,"line":492},[489,1233,527],{"class":499},[489,1235,1236],{"class":503}," ProductImageService",[489,1238,1025],{"class":499},[489,1240,536],{"class":499},[489,1242,1243,1246,1248,1250],{"class":491,"line":510},[489,1244,1245],{"class":904},"    store ",[489,1247,398],{"class":503},[489,1249,368],{"class":499},[489,1251,1252],{"class":503},"Store\n",[489,1254,1255,1258],{"class":491,"line":517},[489,1256,1257],{"class":904},"    repo  ",[489,1259,1260],{"class":503},"ProductRepository\n",[489,1262,1263],{"class":491,"line":524},[489,1264,807],{"class":499},[489,1266,1267],{"class":491,"line":539},[489,1268,514],{"emptyLinePlaceholder":513},[489,1270,1271,1274,1276,1279,1282,1285,1287,1290],{"class":491,"line":545},[489,1272,1273],{"class":499},"func",[489,1275,641],{"class":499},[489,1277,1278],{"class":555},"s ",[489,1280,1281],{"class":499},"*",[489,1283,1284],{"class":503},"ProductImageService",[489,1286,605],{"class":499},[489,1288,1289],{"class":548}," Upload",[489,1291,1292],{"class":499},"(\n",[489,1294,1295,1298,1300,1302,1304,1306,1309,1311],{"class":491,"line":611},[489,1296,1297],{"class":555},"    ctx",[489,1299,559],{"class":503},[489,1301,368],{"class":499},[489,1303,564],{"class":503},[489,1305,567],{"class":499},[489,1307,1308],{"class":555}," productID",[489,1310,574],{"class":573},[489,1312,1313],{"class":499},",\n",[489,1315,1316,1319,1321,1323,1325,1327,1329,1331,1333,1335,1337],{"class":491,"line":617},[489,1317,1318],{"class":555},"    r",[489,1320,582],{"class":503},[489,1322,368],{"class":499},[489,1324,587],{"class":503},[489,1326,567],{"class":499},[489,1328,592],{"class":555},[489,1330,574],{"class":573},[489,1332,567],{"class":499},[489,1334,599],{"class":555},[489,1336,602],{"class":573},[489,1338,1313],{"class":499},[489,1340,1341,1343,1345,1347,1349,1351,1353],{"class":491,"line":660},[489,1342,605],{"class":499},[489,1344,641],{"class":499},[489,1346,724],{"class":573},[489,1348,567],{"class":499},[489,1350,654],{"class":573},[489,1352,605],{"class":499},[489,1354,536],{"class":499},[489,1356,1357,1360,1362,1364,1367,1370,1373,1376,1379,1381,1384],{"class":491,"line":666},[489,1358,1359],{"class":904},"    key ",[489,1361,913],{"class":499},[489,1363,500],{"class":499},[489,1365,1366],{"class":1051},"products\u002F",[489,1368,1369],{"class":499},"\"",[489,1371,1372],{"class":499}," +",[489,1374,1375],{"class":904}," productID ",[489,1377,1378],{"class":499},"+",[489,1380,500],{"class":499},[489,1382,1383],{"class":1051},"\u002Fimage",[489,1385,507],{"class":499},[489,1387,1388,1391,1393,1395,1398,1400,1402,1404,1406,1408,1410,1412,1414,1416,1418,1420,1422,1424,1426,1429,1431,1434,1437],{"class":491,"line":692},[489,1389,1390],{"class":495},"    if",[489,1392,910],{"class":904},[489,1394,913],{"class":499},[489,1396,1397],{"class":904}," s",[489,1399,368],{"class":499},[489,1401,905],{"class":904},[489,1403,368],{"class":499},[489,1405,817],{"class":548},[489,1407,552],{"class":499},[489,1409,556],{"class":904},[489,1411,567],{"class":499},[489,1413,570],{"class":904},[489,1415,567],{"class":499},[489,1417,579],{"class":904},[489,1419,567],{"class":499},[489,1421,592],{"class":904},[489,1423,567],{"class":499},[489,1425,599],{"class":904},[489,1427,1428],{"class":499},");",[489,1430,910],{"class":904},[489,1432,1433],{"class":499},"!=",[489,1435,1436],{"class":499}," nil",[489,1438,536],{"class":499},[489,1440,1441,1444,1447,1449],{"class":491,"line":698},[489,1442,1443],{"class":495},"        return",[489,1445,1446],{"class":499}," \"\"",[489,1448,567],{"class":499},[489,1450,1451],{"class":904}," err\n",[489,1453,1454],{"class":491,"line":733},[489,1455,1456],{"class":499},"    }\n",[489,1458,1459,1462,1464,1466,1468,1470,1472,1474,1476,1478,1480,1482,1484,1486],{"class":491,"line":739},[489,1460,1461],{"class":904},"    url",[489,1463,567],{"class":499},[489,1465,910],{"class":904},[489,1467,913],{"class":499},[489,1469,1397],{"class":904},[489,1471,368],{"class":499},[489,1473,905],{"class":904},[489,1475,368],{"class":499},[489,1477,861],{"class":548},[489,1479,552],{"class":499},[489,1481,556],{"class":904},[489,1483,567],{"class":499},[489,1485,570],{"class":904},[489,1487,657],{"class":499},[489,1489,1490,1492,1494,1496,1498],{"class":491,"line":786},[489,1491,1390],{"class":495},[489,1493,910],{"class":904},[489,1495,1433],{"class":499},[489,1497,1436],{"class":499},[489,1499,536],{"class":499},[489,1501,1502,1504,1506,1508],{"class":491,"line":792},[489,1503,1443],{"class":495},[489,1505,1446],{"class":499},[489,1507,567],{"class":499},[489,1509,1451],{"class":904},[489,1511,1512],{"class":491,"line":804},[489,1513,1456],{"class":499},[489,1515,1517,1520,1523,1525,1527,1529,1532,1534,1537,1539,1541,1543,1545,1547,1549],{"class":491,"line":1516},18,[489,1518,1519],{"class":495},"    return",[489,1521,1522],{"class":904}," url",[489,1524,567],{"class":499},[489,1526,1397],{"class":904},[489,1528,368],{"class":499},[489,1530,1531],{"class":904},"repo",[489,1533,368],{"class":499},[489,1535,1536],{"class":548},"SetImageURL",[489,1538,552],{"class":499},[489,1540,556],{"class":904},[489,1542,567],{"class":499},[489,1544,1308],{"class":904},[489,1546,567],{"class":499},[489,1548,1522],{"class":904},[489,1550,657],{"class":499},[489,1552,1554],{"class":491,"line":1553},19,[489,1555,807],{"class":499},[348,1557,1558],{},"Extracting the multipart file is the single engine-specific point:",[1560,1561,1562,1869],"code-group",{},[480,1563,1566],{"className":482,"code":1564,"filename":1565,"language":484,"meta":485,"style":485},"func (h *Handler) UploadProductImage(c fiber.Ctx, productId string) error {\n    fh, err := c.FormFile(\"image\")\n    if err != nil {\n        return err\n    }\n    f, err := fh.Open()\n    if err != nil {\n        return err\n    }\n    defer f.Close()\n\n    url, err := h.images.Upload(c.Context(), productId,\n        f, fh.Header.Get(\"Content-Type\"), fh.Size)\n    if err != nil {\n        return err\n    }\n    return c.JSON(fiber.Map{\"url\": url})\n}\n","Fiber",[357,1567,1568,1613,1643,1655,1661,1665,1687,1699,1705,1709,1724,1728,1765,1805,1817,1823,1827,1865],{"__ignoreMap":485},[489,1569,1570,1572,1574,1577,1579,1582,1584,1587,1589,1592,1595,1597,1600,1602,1605,1607,1609,1611],{"class":491,"line":492},[489,1571,1273],{"class":499},[489,1573,641],{"class":499},[489,1575,1576],{"class":555},"h ",[489,1578,1281],{"class":499},[489,1580,1581],{"class":503},"Handler",[489,1583,605],{"class":499},[489,1585,1586],{"class":548}," UploadProductImage",[489,1588,552],{"class":499},[489,1590,1591],{"class":555},"c",[489,1593,1594],{"class":503}," fiber",[489,1596,368],{"class":499},[489,1598,1599],{"class":503},"Ctx",[489,1601,567],{"class":499},[489,1603,1604],{"class":555}," productId",[489,1606,574],{"class":573},[489,1608,605],{"class":499},[489,1610,654],{"class":573},[489,1612,536],{"class":499},[489,1614,1615,1618,1620,1622,1624,1627,1629,1632,1634,1636,1639,1641],{"class":491,"line":510},[489,1616,1617],{"class":904},"    fh",[489,1619,567],{"class":499},[489,1621,910],{"class":904},[489,1623,913],{"class":499},[489,1625,1626],{"class":904}," c",[489,1628,368],{"class":499},[489,1630,1631],{"class":548},"FormFile",[489,1633,552],{"class":499},[489,1635,1369],{"class":499},[489,1637,1638],{"class":1051},"image",[489,1640,1369],{"class":499},[489,1642,657],{"class":499},[489,1644,1645,1647,1649,1651,1653],{"class":491,"line":517},[489,1646,1390],{"class":495},[489,1648,910],{"class":904},[489,1650,1433],{"class":499},[489,1652,1436],{"class":499},[489,1654,536],{"class":499},[489,1656,1657,1659],{"class":491,"line":524},[489,1658,1443],{"class":495},[489,1660,1451],{"class":904},[489,1662,1663],{"class":491,"line":539},[489,1664,1456],{"class":499},[489,1666,1667,1670,1672,1674,1676,1679,1681,1684],{"class":491,"line":545},[489,1668,1669],{"class":904},"    f",[489,1671,567],{"class":499},[489,1673,910],{"class":904},[489,1675,913],{"class":499},[489,1677,1678],{"class":904}," fh",[489,1680,368],{"class":499},[489,1682,1683],{"class":548},"Open",[489,1685,1686],{"class":499},"()\n",[489,1688,1689,1691,1693,1695,1697],{"class":491,"line":611},[489,1690,1390],{"class":495},[489,1692,910],{"class":904},[489,1694,1433],{"class":499},[489,1696,1436],{"class":499},[489,1698,536],{"class":499},[489,1700,1701,1703],{"class":491,"line":617},[489,1702,1443],{"class":495},[489,1704,1451],{"class":904},[489,1706,1707],{"class":491,"line":660},[489,1708,1456],{"class":499},[489,1710,1711,1714,1717,1719,1722],{"class":491,"line":666},[489,1712,1713],{"class":495},"    defer",[489,1715,1716],{"class":904}," f",[489,1718,368],{"class":499},[489,1720,1721],{"class":548},"Close",[489,1723,1686],{"class":499},[489,1725,1726],{"class":491,"line":692},[489,1727,514],{"emptyLinePlaceholder":513},[489,1729,1730,1732,1734,1736,1738,1741,1743,1746,1748,1750,1752,1754,1756,1758,1761,1763],{"class":491,"line":698},[489,1731,1461],{"class":904},[489,1733,567],{"class":499},[489,1735,910],{"class":904},[489,1737,913],{"class":499},[489,1739,1740],{"class":904}," h",[489,1742,368],{"class":499},[489,1744,1745],{"class":904},"images",[489,1747,368],{"class":499},[489,1749,817],{"class":548},[489,1751,552],{"class":499},[489,1753,1591],{"class":904},[489,1755,368],{"class":499},[489,1757,564],{"class":548},[489,1759,1760],{"class":499},"(),",[489,1762,1604],{"class":904},[489,1764,1313],{"class":499},[489,1766,1767,1770,1772,1774,1776,1779,1781,1784,1786,1788,1791,1793,1796,1798,1800,1803],{"class":491,"line":733},[489,1768,1769],{"class":904},"        f",[489,1771,567],{"class":499},[489,1773,1678],{"class":904},[489,1775,368],{"class":499},[489,1777,1778],{"class":904},"Header",[489,1780,368],{"class":499},[489,1782,1783],{"class":548},"Get",[489,1785,552],{"class":499},[489,1787,1369],{"class":499},[489,1789,1790],{"class":1051},"Content-Type",[489,1792,1369],{"class":499},[489,1794,1795],{"class":499},"),",[489,1797,1678],{"class":904},[489,1799,368],{"class":499},[489,1801,1802],{"class":904},"Size",[489,1804,657],{"class":499},[489,1806,1807,1809,1811,1813,1815],{"class":491,"line":739},[489,1808,1390],{"class":495},[489,1810,910],{"class":904},[489,1812,1433],{"class":499},[489,1814,1436],{"class":499},[489,1816,536],{"class":499},[489,1818,1819,1821],{"class":491,"line":786},[489,1820,1443],{"class":495},[489,1822,1451],{"class":904},[489,1824,1825],{"class":491,"line":792},[489,1826,1456],{"class":499},[489,1828,1829,1831,1833,1835,1838,1840,1843,1845,1848,1851,1853,1856,1858,1860,1862],{"class":491,"line":804},[489,1830,1519],{"class":495},[489,1832,1626],{"class":904},[489,1834,368],{"class":499},[489,1836,1837],{"class":548},"JSON",[489,1839,552],{"class":499},[489,1841,1842],{"class":503},"fiber",[489,1844,368],{"class":499},[489,1846,1847],{"class":503},"Map",[489,1849,1850],{"class":499},"{",[489,1852,1369],{"class":499},[489,1854,1855],{"class":1051},"url",[489,1857,1369],{"class":499},[489,1859,385],{"class":499},[489,1861,1522],{"class":904},[489,1863,1864],{"class":499},"})\n",[489,1866,1867],{"class":491,"line":1516},[489,1868,807],{"class":499},[480,1870,1873],{"className":482,"code":1871,"filename":1872,"language":484,"meta":485,"style":485},"func (h *Handler) UploadProductImage(w http.ResponseWriter, r *http.Request, productId string) {\n    f, fh, err := r.FormFile(\"image\")\n    if err != nil {\n        h.error(w, r, err)\n        return\n    }\n    defer f.Close()\n\n    url, err := h.images.Upload(r.Context(), productId,\n        f, fh.Header.Get(\"Content-Type\"), fh.Size)\n    if err != nil {\n        h.error(w, r, err)\n        return\n    }\n    writeJSON(w, map[string]string{\"url\": url})\n}\n","chi",[357,1874,1875,1929,1959,1971,1996,2001,2005,2017,2021,2056,2090,2102,2124,2128,2132,2167],{"__ignoreMap":485},[489,1876,1877,1879,1881,1883,1885,1887,1889,1891,1893,1896,1899,1901,1904,1906,1908,1911,1914,1916,1919,1921,1923,1925,1927],{"class":491,"line":492},[489,1878,1273],{"class":499},[489,1880,641],{"class":499},[489,1882,1576],{"class":555},[489,1884,1281],{"class":499},[489,1886,1581],{"class":503},[489,1888,605],{"class":499},[489,1890,1586],{"class":548},[489,1892,552],{"class":499},[489,1894,1895],{"class":555},"w",[489,1897,1898],{"class":503}," http",[489,1900,368],{"class":499},[489,1902,1903],{"class":503},"ResponseWriter",[489,1905,567],{"class":499},[489,1907,579],{"class":555},[489,1909,1910],{"class":499}," *",[489,1912,1913],{"class":503},"http",[489,1915,368],{"class":499},[489,1917,1918],{"class":503},"Request",[489,1920,567],{"class":499},[489,1922,1604],{"class":555},[489,1924,574],{"class":573},[489,1926,605],{"class":499},[489,1928,536],{"class":499},[489,1930,1931,1933,1935,1937,1939,1941,1943,1945,1947,1949,1951,1953,1955,1957],{"class":491,"line":510},[489,1932,1669],{"class":904},[489,1934,567],{"class":499},[489,1936,1678],{"class":904},[489,1938,567],{"class":499},[489,1940,910],{"class":904},[489,1942,913],{"class":499},[489,1944,579],{"class":904},[489,1946,368],{"class":499},[489,1948,1631],{"class":548},[489,1950,552],{"class":499},[489,1952,1369],{"class":499},[489,1954,1638],{"class":1051},[489,1956,1369],{"class":499},[489,1958,657],{"class":499},[489,1960,1961,1963,1965,1967,1969],{"class":491,"line":517},[489,1962,1390],{"class":495},[489,1964,910],{"class":904},[489,1966,1433],{"class":499},[489,1968,1436],{"class":499},[489,1970,536],{"class":499},[489,1972,1973,1976,1978,1981,1983,1985,1987,1989,1991,1994],{"class":491,"line":524},[489,1974,1975],{"class":904},"        h",[489,1977,368],{"class":499},[489,1979,1980],{"class":548},"error",[489,1982,552],{"class":499},[489,1984,1895],{"class":904},[489,1986,567],{"class":499},[489,1988,579],{"class":904},[489,1990,567],{"class":499},[489,1992,1993],{"class":904}," err",[489,1995,657],{"class":499},[489,1997,1998],{"class":491,"line":539},[489,1999,2000],{"class":495},"        return\n",[489,2002,2003],{"class":491,"line":545},[489,2004,1456],{"class":499},[489,2006,2007,2009,2011,2013,2015],{"class":491,"line":611},[489,2008,1713],{"class":495},[489,2010,1716],{"class":904},[489,2012,368],{"class":499},[489,2014,1721],{"class":548},[489,2016,1686],{"class":499},[489,2018,2019],{"class":491,"line":617},[489,2020,514],{"emptyLinePlaceholder":513},[489,2022,2023,2025,2027,2029,2031,2033,2035,2037,2039,2041,2043,2046,2048,2050,2052,2054],{"class":491,"line":660},[489,2024,1461],{"class":904},[489,2026,567],{"class":499},[489,2028,910],{"class":904},[489,2030,913],{"class":499},[489,2032,1740],{"class":904},[489,2034,368],{"class":499},[489,2036,1745],{"class":904},[489,2038,368],{"class":499},[489,2040,817],{"class":548},[489,2042,552],{"class":499},[489,2044,2045],{"class":904},"r",[489,2047,368],{"class":499},[489,2049,564],{"class":548},[489,2051,1760],{"class":499},[489,2053,1604],{"class":904},[489,2055,1313],{"class":499},[489,2057,2058,2060,2062,2064,2066,2068,2070,2072,2074,2076,2078,2080,2082,2084,2086,2088],{"class":491,"line":666},[489,2059,1769],{"class":904},[489,2061,567],{"class":499},[489,2063,1678],{"class":904},[489,2065,368],{"class":499},[489,2067,1778],{"class":904},[489,2069,368],{"class":499},[489,2071,1783],{"class":548},[489,2073,552],{"class":499},[489,2075,1369],{"class":499},[489,2077,1790],{"class":1051},[489,2079,1369],{"class":499},[489,2081,1795],{"class":499},[489,2083,1678],{"class":904},[489,2085,368],{"class":499},[489,2087,1802],{"class":904},[489,2089,657],{"class":499},[489,2091,2092,2094,2096,2098,2100],{"class":491,"line":692},[489,2093,1390],{"class":495},[489,2095,910],{"class":904},[489,2097,1433],{"class":499},[489,2099,1436],{"class":499},[489,2101,536],{"class":499},[489,2103,2104,2106,2108,2110,2112,2114,2116,2118,2120,2122],{"class":491,"line":698},[489,2105,1975],{"class":904},[489,2107,368],{"class":499},[489,2109,1980],{"class":548},[489,2111,552],{"class":499},[489,2113,1895],{"class":904},[489,2115,567],{"class":499},[489,2117,579],{"class":904},[489,2119,567],{"class":499},[489,2121,1993],{"class":904},[489,2123,657],{"class":499},[489,2125,2126],{"class":491,"line":733},[489,2127,2000],{"class":495},[489,2129,2130],{"class":491,"line":739},[489,2131,1456],{"class":499},[489,2133,2134,2137,2139,2141,2143,2146,2148,2151,2153,2155,2157,2159,2161,2163,2165],{"class":491,"line":786},[489,2135,2136],{"class":548},"    writeJSON",[489,2138,552],{"class":499},[489,2140,1895],{"class":904},[489,2142,567],{"class":499},[489,2144,2145],{"class":499}," map[",[489,2147,724],{"class":573},[489,2149,2150],{"class":499},"]",[489,2152,724],{"class":573},[489,2154,1850],{"class":499},[489,2156,1369],{"class":499},[489,2158,1855],{"class":1051},[489,2160,1369],{"class":499},[489,2162,385],{"class":499},[489,2164,1522],{"class":904},[489,2166,1864],{"class":499},[489,2168,2169],{"class":491,"line":792},[489,2170,807],{"class":499},[348,2172,2173],{},"For private content (say, an order export), hand out a signed, expiring link instead of a public URL:",[480,2175,2177],{"className":482,"code":2176,"language":484,"meta":485,"style":485},"url, err := s.store.SignedURL(ctx, \"exports\u002Forders-2026-07.csv\", 15*time.Minute)\n",[357,2178,2179],{"__ignoreMap":485},[489,2180,2181,2183,2185,2187,2189,2191,2193,2195,2197,2199,2201,2203,2205,2207,2210,2212,2214,2218,2220,2223,2225,2228],{"class":491,"line":492},[489,2182,1855],{"class":904},[489,2184,567],{"class":499},[489,2186,910],{"class":904},[489,2188,913],{"class":499},[489,2190,1397],{"class":904},[489,2192,368],{"class":499},[489,2194,905],{"class":904},[489,2196,368],{"class":499},[489,2198,874],{"class":548},[489,2200,552],{"class":499},[489,2202,556],{"class":904},[489,2204,567],{"class":499},[489,2206,500],{"class":499},[489,2208,2209],{"class":1051},"exports\u002Forders-2026-07.csv",[489,2211,1369],{"class":499},[489,2213,567],{"class":499},[489,2215,2217],{"class":2216},"sbssI"," 15",[489,2219,1281],{"class":499},[489,2221,2222],{"class":904},"time",[489,2224,368],{"class":499},[489,2226,2227],{"class":904},"Minute",[489,2229,657],{"class":499},[348,2231,2232,2233,2235,2236,2239,2240,385],{},"The wiring happens in ",[357,2234,367],{},": the client is constructed there and enters ",[357,2237,2238],{},"Dependencies"," as a ",[357,2241,363],{},[480,2243,2245],{"className":482,"code":2244,"language":484,"meta":485,"style":485},"store := must(s3.New(ctx, cfg.Storage))\ndeps := shop.Dependencies{ \u002F* ..., *\u002F Storage: store }\n",[357,2246,2247,2280],{"__ignoreMap":485},[489,2248,2249,2252,2254,2257,2259,2261,2263,2265,2267,2269,2271,2273,2275,2277],{"class":491,"line":492},[489,2250,2251],{"class":904},"store ",[489,2253,913],{"class":499},[489,2255,2256],{"class":548}," must",[489,2258,552],{"class":499},[489,2260,1040],{"class":904},[489,2262,368],{"class":499},[489,2264,921],{"class":548},[489,2266,552],{"class":499},[489,2268,556],{"class":904},[489,2270,567],{"class":499},[489,2272,930],{"class":904},[489,2274,368],{"class":499},[489,2276,248],{"class":904},[489,2278,2279],{"class":499},"))\n",[489,2281,2282,2285,2287,2290,2292,2294,2296,2299,2302,2304,2307],{"class":491,"line":510},[489,2283,2284],{"class":904},"deps ",[489,2286,913],{"class":499},[489,2288,2289],{"class":503}," shop",[489,2291,368],{"class":499},[489,2293,2238],{"class":503},[489,2295,1850],{"class":499},[489,2297,2298],{"class":520}," \u002F* ..., *\u002F",[489,2300,2301],{"class":904}," Storage",[489,2303,385],{"class":499},[489,2305,2306],{"class":904}," store ",[489,2308,807],{"class":499},[370,2310,2312],{"id":2311},"testing-an-in-memory-fake","Testing: an in-memory fake",[348,2314,2315],{},"Because the service depends only on the interface, the test gets a few-line fake: no AWS SDK, no container:",[480,2317,2319],{"className":482,"code":2318,"language":484,"meta":485,"style":485},"type fakeStore struct{ objects map[string][]byte }\n\nfunc (f *fakeStore) Upload(_ context.Context, key string, r io.Reader, _ string, _ int64) error {\n    b, err := io.ReadAll(r)\n    if err != nil {\n        return err\n    }\n    f.objects[key] = b\n    return nil\n}\n\nfunc (f *fakeStore) URL(_ context.Context, key string) (string, error) {\n    return \"https:\u002F\u002Fcdn.test\u002F\" + key, nil\n}\n\n\u002F\u002F NewReader, Delete, SignedURL, PublicBaseURL: analogous...\n",[357,2320,2321,2348,2352,2416,2440,2452,2458,2462,2485,2492,2496,2500,2547,2566,2570,2574],{"__ignoreMap":485},[489,2322,2323,2325,2328,2331,2334,2337,2339,2342,2345],{"class":491,"line":492},[489,2324,527],{"class":499},[489,2326,2327],{"class":503}," fakeStore",[489,2329,2330],{"class":499}," struct{",[489,2332,2333],{"class":904}," objects ",[489,2335,2336],{"class":499},"map[",[489,2338,724],{"class":573},[489,2340,2341],{"class":499},"][]",[489,2343,2344],{"class":573},"byte",[489,2346,2347],{"class":499}," }\n",[489,2349,2350],{"class":491,"line":510},[489,2351,514],{"emptyLinePlaceholder":513},[489,2353,2354,2356,2358,2361,2363,2366,2368,2370,2372,2375,2377,2379,2381,2383,2385,2387,2389,2391,2393,2395,2397,2399,2402,2404,2406,2408,2410,2412,2414],{"class":491,"line":517},[489,2355,1273],{"class":499},[489,2357,641],{"class":499},[489,2359,2360],{"class":555},"f ",[489,2362,1281],{"class":499},[489,2364,2365],{"class":503},"fakeStore",[489,2367,605],{"class":499},[489,2369,1289],{"class":548},[489,2371,552],{"class":499},[489,2373,2374],{"class":555},"_",[489,2376,559],{"class":503},[489,2378,368],{"class":499},[489,2380,564],{"class":503},[489,2382,567],{"class":499},[489,2384,570],{"class":555},[489,2386,574],{"class":573},[489,2388,567],{"class":499},[489,2390,579],{"class":555},[489,2392,582],{"class":503},[489,2394,368],{"class":499},[489,2396,587],{"class":503},[489,2398,567],{"class":499},[489,2400,2401],{"class":555}," _",[489,2403,574],{"class":573},[489,2405,567],{"class":499},[489,2407,2401],{"class":555},[489,2409,602],{"class":573},[489,2411,605],{"class":499},[489,2413,654],{"class":573},[489,2415,536],{"class":499},[489,2417,2418,2421,2423,2425,2427,2429,2431,2434,2436,2438],{"class":491,"line":524},[489,2419,2420],{"class":904},"    b",[489,2422,567],{"class":499},[489,2424,910],{"class":904},[489,2426,913],{"class":499},[489,2428,582],{"class":904},[489,2430,368],{"class":499},[489,2432,2433],{"class":548},"ReadAll",[489,2435,552],{"class":499},[489,2437,2045],{"class":904},[489,2439,657],{"class":499},[489,2441,2442,2444,2446,2448,2450],{"class":491,"line":539},[489,2443,1390],{"class":495},[489,2445,910],{"class":904},[489,2447,1433],{"class":499},[489,2449,1436],{"class":499},[489,2451,536],{"class":499},[489,2453,2454,2456],{"class":491,"line":545},[489,2455,1443],{"class":495},[489,2457,1451],{"class":904},[489,2459,2460],{"class":491,"line":611},[489,2461,1456],{"class":499},[489,2463,2464,2466,2468,2471,2474,2477,2479,2482],{"class":491,"line":617},[489,2465,1669],{"class":904},[489,2467,368],{"class":499},[489,2469,2470],{"class":904},"objects",[489,2472,2473],{"class":499},"[",[489,2475,2476],{"class":904},"key",[489,2478,2150],{"class":499},[489,2480,2481],{"class":499}," =",[489,2483,2484],{"class":904}," b\n",[489,2486,2487,2489],{"class":491,"line":660},[489,2488,1519],{"class":495},[489,2490,2491],{"class":499}," nil\n",[489,2493,2494],{"class":491,"line":666},[489,2495,807],{"class":499},[489,2497,2498],{"class":491,"line":692},[489,2499,514],{"emptyLinePlaceholder":513},[489,2501,2502,2504,2506,2508,2510,2512,2514,2517,2519,2521,2523,2525,2527,2529,2531,2533,2535,2537,2539,2541,2543,2545],{"class":491,"line":698},[489,2503,1273],{"class":499},[489,2505,641],{"class":499},[489,2507,2360],{"class":555},[489,2509,1281],{"class":499},[489,2511,2365],{"class":503},[489,2513,605],{"class":499},[489,2515,2516],{"class":548}," URL",[489,2518,552],{"class":499},[489,2520,2374],{"class":555},[489,2522,559],{"class":503},[489,2524,368],{"class":499},[489,2526,564],{"class":503},[489,2528,567],{"class":499},[489,2530,570],{"class":555},[489,2532,574],{"class":573},[489,2534,605],{"class":499},[489,2536,641],{"class":499},[489,2538,724],{"class":573},[489,2540,567],{"class":499},[489,2542,654],{"class":573},[489,2544,605],{"class":499},[489,2546,536],{"class":499},[489,2548,2549,2551,2553,2556,2558,2560,2562,2564],{"class":491,"line":733},[489,2550,1519],{"class":495},[489,2552,500],{"class":499},[489,2554,2555],{"class":1051},"https:\u002F\u002Fcdn.test\u002F",[489,2557,1369],{"class":499},[489,2559,1372],{"class":499},[489,2561,570],{"class":904},[489,2563,567],{"class":499},[489,2565,2491],{"class":499},[489,2567,2568],{"class":491,"line":739},[489,2569,807],{"class":499},[489,2571,2572],{"class":491,"line":786},[489,2573,514],{"emptyLinePlaceholder":513},[489,2575,2576],{"class":491,"line":792},[489,2577,2578],{"class":520},"\u002F\u002F NewReader, Delete, SignedURL, PublicBaseURL: analogous...\n",[480,2580,2582],{"className":482,"code":2581,"language":484,"meta":485,"style":485},"func TestUploadStoresImageAndURL(t *testing.T) {\n    store := &fakeStore{objects: map[string][]byte{}}\n    svc := service.NewProductImageService(store, repo)\n\n    url, err := svc.Upload(ctx, \"p42\", strings.NewReader(\"png-bytes\"), \"image\u002Fpng\", 9)\n\n    require.NoError(t, err)\n    require.Equal(t, \"https:\u002F\u002Fcdn.test\u002Fproducts\u002Fp42\u002Fimage\", url)\n    require.Contains(t, store.objects, \"products\u002Fp42\u002Fimage\")\n}\n",[357,2583,2584,2610,2638,2664,2668,2732,2736,2756,2784,2817],{"__ignoreMap":485},[489,2585,2586,2588,2591,2593,2596,2598,2601,2603,2606,2608],{"class":491,"line":492},[489,2587,1273],{"class":499},[489,2589,2590],{"class":548}," TestUploadStoresImageAndURL",[489,2592,552],{"class":499},[489,2594,2595],{"class":555},"t",[489,2597,1910],{"class":499},[489,2599,2600],{"class":503},"testing",[489,2602,368],{"class":499},[489,2604,2605],{"class":503},"T",[489,2607,605],{"class":499},[489,2609,536],{"class":499},[489,2611,2612,2614,2616,2619,2621,2623,2625,2627,2629,2631,2633,2635],{"class":491,"line":510},[489,2613,1245],{"class":904},[489,2615,913],{"class":499},[489,2617,2618],{"class":499}," &",[489,2620,2365],{"class":503},[489,2622,1850],{"class":499},[489,2624,2470],{"class":904},[489,2626,385],{"class":499},[489,2628,2145],{"class":499},[489,2630,724],{"class":573},[489,2632,2341],{"class":499},[489,2634,2344],{"class":573},[489,2636,2637],{"class":499},"{}}\n",[489,2639,2640,2643,2645,2648,2650,2653,2655,2657,2659,2662],{"class":491,"line":517},[489,2641,2642],{"class":904},"    svc ",[489,2644,913],{"class":499},[489,2646,2647],{"class":904}," service",[489,2649,368],{"class":499},[489,2651,2652],{"class":548},"NewProductImageService",[489,2654,552],{"class":499},[489,2656,905],{"class":904},[489,2658,567],{"class":499},[489,2660,2661],{"class":904}," repo",[489,2663,657],{"class":499},[489,2665,2666],{"class":491,"line":524},[489,2667,514],{"emptyLinePlaceholder":513},[489,2669,2670,2672,2674,2676,2678,2681,2683,2685,2687,2689,2691,2693,2696,2698,2700,2703,2705,2707,2709,2711,2714,2716,2718,2720,2723,2725,2727,2730],{"class":491,"line":539},[489,2671,1461],{"class":904},[489,2673,567],{"class":499},[489,2675,910],{"class":904},[489,2677,913],{"class":499},[489,2679,2680],{"class":904}," svc",[489,2682,368],{"class":499},[489,2684,817],{"class":548},[489,2686,552],{"class":499},[489,2688,556],{"class":904},[489,2690,567],{"class":499},[489,2692,500],{"class":499},[489,2694,2695],{"class":1051},"p42",[489,2697,1369],{"class":499},[489,2699,567],{"class":499},[489,2701,2702],{"class":904}," strings",[489,2704,368],{"class":499},[489,2706,843],{"class":548},[489,2708,552],{"class":499},[489,2710,1369],{"class":499},[489,2712,2713],{"class":1051},"png-bytes",[489,2715,1369],{"class":499},[489,2717,1795],{"class":499},[489,2719,500],{"class":499},[489,2721,2722],{"class":1051},"image\u002Fpng",[489,2724,1369],{"class":499},[489,2726,567],{"class":499},[489,2728,2729],{"class":2216}," 9",[489,2731,657],{"class":499},[489,2733,2734],{"class":491,"line":545},[489,2735,514],{"emptyLinePlaceholder":513},[489,2737,2738,2741,2743,2746,2748,2750,2752,2754],{"class":491,"line":611},[489,2739,2740],{"class":904},"    require",[489,2742,368],{"class":499},[489,2744,2745],{"class":548},"NoError",[489,2747,552],{"class":499},[489,2749,2595],{"class":904},[489,2751,567],{"class":499},[489,2753,1993],{"class":904},[489,2755,657],{"class":499},[489,2757,2758,2760,2762,2765,2767,2769,2771,2773,2776,2778,2780,2782],{"class":491,"line":617},[489,2759,2740],{"class":904},[489,2761,368],{"class":499},[489,2763,2764],{"class":548},"Equal",[489,2766,552],{"class":499},[489,2768,2595],{"class":904},[489,2770,567],{"class":499},[489,2772,500],{"class":499},[489,2774,2775],{"class":1051},"https:\u002F\u002Fcdn.test\u002Fproducts\u002Fp42\u002Fimage",[489,2777,1369],{"class":499},[489,2779,567],{"class":499},[489,2781,1522],{"class":904},[489,2783,657],{"class":499},[489,2785,2786,2788,2790,2793,2795,2797,2799,2802,2804,2806,2808,2810,2813,2815],{"class":491,"line":660},[489,2787,2740],{"class":904},[489,2789,368],{"class":499},[489,2791,2792],{"class":548},"Contains",[489,2794,552],{"class":499},[489,2796,2595],{"class":904},[489,2798,567],{"class":499},[489,2800,2801],{"class":904}," store",[489,2803,368],{"class":499},[489,2805,2470],{"class":904},[489,2807,567],{"class":499},[489,2809,500],{"class":499},[489,2811,2812],{"class":1051},"products\u002Fp42\u002Fimage",[489,2814,1369],{"class":499},[489,2816,657],{"class":499},[489,2818,2819],{"class":491,"line":666},[489,2820,807],{"class":499},[348,2822,2823],{},"At the integration level, the kit's own tests spin up MinIO from Docker. You can do the same in your project when you want the full S3 path covered.",[1181,2825,2826,2827,2830,2831,368],{},"Media processing (image conversion, thumbnail generation, validation pipelines) is deliberately ",[382,2828,2829],{},"not"," part of the kit. It tends to be project-specific. Build it on top of ",[357,2832,363],{},[370,2834,2836],{"id":2835},"patterns-used","Patterns used",[348,2838,2839,2842,2843,2845,2846,2849,2850,368],{},[382,2840,2841],{},"Interface segregation \u002F dependency inversion"," (the split between the driver-free ",[357,2844,363],{}," and the AWS-SDK-wrapping ",[357,2847,2848],{},"storage\u002Fs3.Client",", with a compile-time interface assertion). This is one of the kit's three stated exceptions; see ",[352,2851,2853],{"href":2852},"\u002Fen\u002Fconcepts\u002Fdesign-patterns#architectural-principles","Design patterns: Architectural principles",[2855,2856,2857],"style",{},"html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}",{"title":485,"searchDepth":510,"depth":510,"links":2859},[2860,2861,2862,2865,2866,2867],{"id":372,"depth":510,"text":373},{"id":477,"depth":510,"text":478},{"id":878,"depth":510,"text":879,"children":2863},[2864],{"id":1000,"depth":517,"text":52},{"id":1217,"depth":510,"text":1218},{"id":2311,"depth":510,"text":2312},{"id":2835,"depth":510,"text":2836},"Object storage through the storage.Store interface, with an S3-compatible implementation, without importing the AWS SDK.","md",null,{},{"icon":243},{"title":248,"description":2868},"yrpGVuZLTlQNhPHmjA6qMJpv0dUGkpkNpWKi1rcuW_Y",[2876,2878],{"title":237,"path":238,"stem":239,"description":2877,"icon":240,"children":-1},"Env-gated error reporting on top of OTel: with errs-code issue grouping and full trace linking.",{"title":252,"path":253,"stem":254,"description":2879,"icon":255,"children":-1},"Email sending: fluent Message builder, MJML templates, a memory mailer for tests.",1784668710438]