storage
v0.1.20Object storage across AWS S3, GCS, Cloudflare R2, and a native local filesystem backend. Streamed uploads, signed URLs, and object change triggers.
- macOS: arm64
- Linux: arm64 · armv7 · x64
- Windows: arm64 · x64
exact versions are immutable; binary and bundle artifacts are digest-pinned.
full markdown
/workers/storage.md?version=0.1.20. paste it into an llm prompt or pipe it through curl from a worker.install
configuration
- buckets:
scratch:
provider: local
providers:
local:
data_dir: data/storage
http:
bind_address: 127.0.0.1:0dependencies
readme
storage
Object storage for the iii engine over S3, GCS, R2, and a native local
filesystem backend. Streamed uploads, signed downloads, and object-created /
object-deleted triggers — all behind one bucket: name regardless of
the cloud underneath.
Install
iii trigger compose::add worker=storageiii trigger compose::add resolves the worker and its dependencies, writes
exact declarations to worker-compose.yaml, and reconciles the Compose project.
Quickstart
Use inline RPCs only for genuinely small values. Normal files should go directly between the client and object storage through a signed upload or download endpoint.
import { registerWorker } from 'iii-sdk'
const iii = registerWorker(process.env.III_URL ?? 'ws://127.0.0.1:49134')
await iii.trigger({
function_id: 'storage::putObject',
payload: {
bucket: 'uploads',
key: 'u/1/profile.jpg',
body_base64: tinyValueBase64, // small values only; 10 MiB hard limit
content_type: 'image/jpeg',
},
})
const { url, expires_at } = await iii.trigger({
function_id: 'storage::presignUrl',
payload: {
bucket: 'uploads',
key: 'u/1/next.jpg',
method: 'PUT',
expires_in_seconds: 600,
content_type: 'image/jpeg', // pinned into the signature
},
})
await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'image/jpeg' },
body: file,
})
const { body_base64, content_type } = await iii.trigger({
function_id: 'storage::getObject',
payload: {
bucket: 'uploads',
key: 'u/1/profile.jpg',
},
})
await iii.trigger({
function_id: 'storage::deleteObject',
payload: {
bucket: 'uploads',
key: 'u/1/profile.jpg',
},
}) // idempotent: returns { deleted: false } if absent
const { content_type, etag, size, last_modified } = await iii.trigger({
function_id: 'storage::headObject',
payload: {
bucket: 'uploads',
key: 'u/1/profile.jpg',
},
}) // fetches metadata only — no body downloadFor a multipart upload to a native local bucket, use presignPost:
const { url, fields } = await iii.trigger({
function_id: 'storage::presignPost',
payload: {
bucket: 'scratch',
key: 'videos/demo.mp4',
content_type: 'video/mp4',
expires_in_seconds: 600,
},
})
const form = new FormData()
for (const [name, value] of Object.entries(fields)) form.append(name, value)
form.append('file', file)
await fetch(url, { method: 'POST', body: form })
putObjectandgetObjectserialize bytes as base64 inside an engine message. They buffer the complete value and base64 adds roughly 33% overhead. Both enforce a 10 MiB decoded hard limit, but they are intended for truly small values, not general file transfer. UsepresignPost/a signed PUT for uploads and a signed GET frompresignUrlfor downloads.
From a Rust worker:
use iii_sdk::{register_worker, InitOptions, TriggerRequest};
use serde_json::json;
let iii = register_worker("ws://localhost:49134", InitOptions::default());
iii.trigger(TriggerRequest {
function_id: "storage::putObject".into(),
payload: json!({
"bucket": "uploads",
"key": "u/1/profile.jpg",
"body_base64": file_b64,
"content_type": "image/jpeg",
}),
action: None,
timeout_ms: Some(5_000),
}).await?;Configuration
The storage worker gets its live configuration from the configuration worker,
not from a local file. On startup it:
- Registers its config schema with the
configurationworker (configuration::register, idstorage). - Fetches the live, env-expanded config (
configuration::get). - Subscribes to
configuration:updatedevents and hot-reloads.
--config is an optional seed: when given, the file is loaded and
sent as initial_value the first time the schema is registered (no stored value
yet). It is not the live source of truth — once a value exists in the
configuration worker, that value wins.
Hot-reload scope
On a configuration:updated event the worker re-fetches the authoritative
config from the configuration worker (it does not trust the event
payload). Every setting is live: bucket additions/removals, providers,
credentials, underlying names, notification sources, local data directory,
and the local HTTP listener/public URL all apply without restarting the worker.
Application is transactional. The worker first prepares the candidate backends, notification clients, local service generation, and—when the bind address changes—the new TCP listener. Only after those fallible steps succeed does it publish the new runtime and gracefully retire the old listener and pollers. A failed backend build, authentication probe, or port bind leaves the previous configuration serving requests.
A fresh install with no configured buckets runs with zero backends until a bucket is configured.
Config shape
Each bucket pins a provider (s3 | gcs | r2 | local) and the
credentials for that provider. Buckets without notifications: work fine for
RPCs; they just don't fire triggers.
providers:
local:
data_dir: data/storage # relative to III_COMPOSE_DIR
http:
bind_address: 0.0.0.0:49200 # omit `http` to disable direct transfers
public_url: http://10.0.0.42:49200 # browser-visible LAN/VPN/proxy URL
buckets:
uploads:
provider: s3
bucket: my-app-uploads # underlying cloud bucket
region: us-east-1
notifications:
sqs_queue_url: https://sqs.us-east-1.amazonaws.com/123/my-app-uploads-events
documents:
provider: gcs
bucket: my-app-documents
# credentials_file: /etc/iii/gcs-sa.json # required for presignUrl
avatars:
provider: r2
bucket: avatars
account_id: ${R2_ACCOUNT_ID}
access_key_id: ${R2_ACCESS_KEY_ID}
secret_access_key: ${R2_SECRET_ACCESS_KEY}
scratch:
provider: local
bucket: scratchThe map key (uploads) is the worker-facing bucket name handlers
reference; the nested bucket: is the underlying cloud bucket. They can
differ.
Per-provider notes
- S3 — defaults to the AWS credential chain (env,
~/.aws, IMDS, IRSA). Override withaccess_key_id/secret_access_key/session_tokenonly if the default chain doesn't fit. - GCS — defaults to ADC (
GOOGLE_APPLICATION_CREDENTIALS, GCE metadata,gcloud auth application-default login).presignUrlrequires a service-account JSON with a private key — supplycredentials_fileexplicitly when running on metadata-server-only sources (e.g., GKE Workload Identity), otherwise GCS presigns returnPRESIGN_UNSUPPORTED. - R2 — required:
account_id,access_key_id,secret_access_key. Endpoint URL is derived automatically ashttps://{account_id}.r2.cloudflarestorage.com. - local — implemented directly by the worker. Object bytes and JSON
metadata are persisted under
data_dir; no sidecar or external binary is required.httpis optional: omit it when only the small inline RPCs are needed. When enabled,bind_addresscontrols the listener andpublic_urlcontrols the base URL returned to clients. Setpublic_urlwhenever clients reach the worker through a LAN address, VPN, container port mapping, or reverse proxy. With port0and nopublic_url, the worker chooses a free loopback port and returns that address. The native listener includes browser CORS support for these signed transfers; cloud buckets need equivalent CORS rules on the provider when uploads originate in the Console.
Existing rustfs data directories are not imported automatically: the native backend uses its own object/metadata layout under
data_dir. Export or copy existing objects before upgrading, then upload them into the native bucket.
Custom endpoints
S3, R2, and GCS bucket configs accept an optional endpoint_url field
for self-hosted S3-compatible stores (MinIO, Ceph, SeaweedFS), staging
environments, or local testing against fake-gcs-server.
buckets:
scratch-self-hosted:
provider: s3
region: us-east-1
endpoint_url: https://s3.internal.example.com
bucket: scratchR2 with endpoint_url set emits a tracing::warn! at startup — the
field is fully functional but production R2 should omit it and let the
worker derive the endpoint automatically.
Wiring notifications
| Provider | Config field(s) | Setup |
|---|---|---|
| S3 | notifications.sqs_queue_url |
SQS queue + bucket event config for s3:ObjectCreated:* / s3:ObjectRemoved:* + sqs:ReceiveMessage,DeleteMessage IAM on the queue ARN. |
| GCS | notifications.pubsub_subscription |
gsutil notification create -t TOPIC -e OBJECT_FINALIZE,OBJECT_DELETE gs:// + roles/pubsub.subscriber on the subscription. |
| R2 | notifications.queue_id + notifications.api_token |
Cloudflare Queue + R2 event notifications on the bucket + API token scoped queue:consume. |
| local | (none) | The worker dispatches events immediately after a native local write/delete commits. |
Other config keys and their defaults live in
src/config.rs; wire-stable error codes returned by
every RPC live in src/error.rs.
Custom trigger types
| Trigger type | Fires when | Payload to subscribers |
|---|---|---|
storage::object-created |
An object is written (PUT, multipart complete, copy-in). | { bucket, key, size, content_type, etag, version_id?, event_time } |
storage::object-deleted |
An object (or version, on versioned buckets) is removed. | { bucket, key, version_id?, event_time } |
Delivery is at-least-once. Handlers must return { ack: true }; false,
panic, or timeout (handler_timeout_ms, default 60 s) leaves the
message in the upstream queue for redelivery.
triggers:
- type: storage::object-created
config:
bucket: uploads
# event_types: [ObjectCreated:Put, ObjectCreated:CompleteMultipartUpload] # optional filter
# handler_timeout_ms: 60000
- type: storage::object-deleted
config:
bucket: uploadsR2 trigger v1 caveat: the Cloudflare Queues consume-from-outside REST API is the youngest of the four upstreams. The worker probes the consume endpoint at startup and surfaces
CF_QUEUE_AUTH_FAILEDfor 401/403, so token misconfiguration is visible immediately. If you hit redelivery or auth-scope edge cases in production, file an issue — v1.1 will finalize the consume path.
Console explorer
When the worker is connected, it injects a storage page into the Console.
Wide panels use three simultaneous levels—buckets, folders/objects, and object
details—while narrow panels show one level at a time. The explorer supports
folder breadcrumbs, pagination, metadata inspection, direct upload, signed
download, copy-key, and delete actions. Uploads and downloads use the direct
transfer endpoints instead of moving file bodies through an inline RPC.
For native local uploads, enable providers.local.http. Cloud uploads also
require the provider bucket to permit the Console origin through CORS.
The page's configure action opens a storage-specific editor while the Console continues to own validation, dirty tracking, save, reset, and the unsaved-change guard. The editor separates native-local runtime settings from bucket mappings and presents only the fields relevant to Local, S3, GCS, or R2. Saved changes are applied live; no storage setting requires a worker restart.
RPC reference notes
Inline object calls are intentionally limited
storage::putObject and storage::getObject are convenience functions for
small configuration fragments, thumbnails, test fixtures, and similar values.
They are not streaming APIs: the full object crosses the iii engine as base64
and is held in memory. The decoded body is capped at 10 MiB. For files—even
when a file happens to fit below that cap—prefer direct transfer endpoints:
- upload:
storage::presignPostfor local multipart POST, orstorage::presignUrlwithmethod: "PUT"; - download:
storage::presignUrlwithmethod: "GET".
storage::presignPost — multipart upload
Returns { url, fields, expires_at }. Add every returned field to a
multipart/form-data body before the file part. Native local storage streams
the file to disk and can enforce optional max_size_bytes without buffering.
Other providers currently return PRESIGN_UNSUPPORTED; use a signed PUT there.
storage::presignUrl — GET-only response-override params
Two optional fields are accepted only when method is "GET". Passing
either on a PUT presign returns INVALID_PRESIGN_PARAMS.
| Field | Type | Description |
|---|---|---|
response_content_disposition |
string (optional) |
Override Content-Disposition header on the served response (e.g. "attachment; filename=\"report.pdf\""). |
response_content_type |
string (optional) |
Override Content-Type header on the served response (e.g. "application/pdf"). |
const { url } = await iii.trigger({
function_id: 'storage::presignUrl',
payload: {
bucket: 'uploads',
key: 'reports/q1.pdf',
method: 'GET',
expires_in_seconds: 300,
response_content_disposition: 'attachment; filename="q1.pdf"',
response_content_type: 'application/pdf',
},
})Local development & testing
The committed config.yaml declares a single scratch bucket served directly
by the worker. Pass it as a seed so the configuration worker picks it
up on first boot — zero cloud credentials required.
# In one terminal: start the engine (must include the configuration worker)
iii --config config.yaml
# In another: build & run the worker, seeding config.yaml on first registration
cargo run --release -- --url ws://127.0.0.1:49134 --config ./config.yamlThe worker registers its schema with the configuration worker (seeding
config.yaml if no stored value exists), fetches the live config, initializes
the native store and optional direct-transfer HTTP listener, and registers
storage::putObject, storage::getObject, storage::deleteObject,
storage::presignUrl, storage::presignPost, storage::headObject,
storage::listBuckets, and storage::listObjects. Files land under
data/storage/ under III_COMPOSE_DIR (configurable via
providers.local.data_dir).
Running --manifest prints the registry-publish JSON without touching the
engine — useful when testing CI flows:
cargo run -- --manifest | jq .Tests
cargo test --lib # unit tests (config, manifest, handlers, triggers)
cargo test --test schemas # schema regression for every `storage::*` RPC
cargo test --test manifest # `--manifest` subprocess contract
cargo test --test integration # spec §9 pattern A: spawns engine + workertests/integration.rs self-skips when the iii engine is not available on
PATH. Native local tests require no external storage dependency. The richer
per-provider e2e suite under tests/e2e/
is env-var-gated — see tests/e2e/run-tests.sh for the orchestrator.
Verification before publishing
The full preflight checklist for binary workers
(docs/sops/binary-worker.md §11):
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
./target/debug/storage --manifest | jq .api reference (json)
{
"functions": [
{
"description": "Delete an object. No-op when the object does not exist.",
"metadata": {},
"name": "storage::deleteObject",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"bucket": {
"type": "string"
},
"key": {
"type": "string"
},
"version_id": {
"type": [
"string",
"null"
]
}
},
"required": [
"bucket",
"key"
],
"title": "DeleteReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"deleted": {
"type": "boolean"
}
},
"required": [
"deleted"
],
"title": "DeleteResp",
"type": "object"
}
},
{
"description": "Download a small object inline as base64 (10 MiB hard limit). This buffers and inflates the payload; use a presignUrl GET for files and large objects.",
"metadata": {},
"name": "storage::getObject",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Read a genuinely small object inline. The complete object is buffered and base64-encoded, with a 10 MiB decoded hard limit. Use a signed GET from `storage::presignUrl` for files and larger objects.",
"properties": {
"bucket": {
"type": "string"
},
"key": {
"type": "string"
},
"version_id": {
"type": [
"string",
"null"
]
}
},
"required": [
"bucket",
"key"
],
"title": "GetReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"body_base64": {
"type": "string"
},
"content_type": {
"type": "string"
},
"etag": {
"type": "string"
},
"last_modified": {
"format": "date-time",
"type": "string"
},
"size": {
"format": "uint64",
"minimum": 0,
"type": "integer"
}
},
"required": [
"body_base64",
"content_type",
"etag",
"last_modified",
"size"
],
"title": "GetResp",
"type": "object"
}
},
{
"description": "Fetch object metadata (size, ETag, content-type, last-modified) without downloading the body.",
"metadata": {},
"name": "storage::headObject",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"bucket": {
"type": "string"
},
"key": {
"type": "string"
},
"version_id": {
"type": [
"string",
"null"
]
}
},
"required": [
"bucket",
"key"
],
"title": "HeadReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"content_type": {
"type": "string"
},
"etag": {
"type": "string"
},
"last_modified": {
"format": "date-time",
"type": "string"
},
"size": {
"format": "uint64",
"minimum": 0,
"type": "integer"
}
},
"required": [
"content_type",
"etag",
"last_modified",
"size"
],
"title": "HeadResp",
"type": "object"
}
},
{
"description": "List configured worker-facing storage buckets and their providers.",
"metadata": {},
"name": "storage::listBuckets",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ListBucketsReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"BucketSummary": {
"properties": {
"name": {
"type": "string"
},
"provider": {
"type": "string"
}
},
"required": [
"name",
"provider"
],
"type": "object"
}
},
"properties": {
"buckets": {
"items": {
"$ref": "#/definitions/BucketSummary"
},
"type": "array"
}
},
"required": [
"buckets"
],
"title": "ListBucketsResp",
"type": "object"
}
},
{
"description": "List objects and common prefixes in a bucket with provider-native pagination.",
"metadata": {},
"name": "storage::listObjects",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"bucket": {
"type": "string"
},
"cursor": {
"type": [
"string",
"null"
]
},
"delimiter": {
"description": "Directory separator. Use `/` for the bucket/folder explorer; omit it for a flat recursive listing.",
"type": [
"string",
"null"
]
},
"limit": {
"default": 250,
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"prefix": {
"default": "",
"type": "string"
}
},
"required": [
"bucket"
],
"title": "ListObjectsReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ObjectSummary": {
"properties": {
"content_type": {
"type": [
"string",
"null"
]
},
"etag": {
"type": "string"
},
"key": {
"type": "string"
},
"last_modified": {
"format": "date-time",
"type": "string"
},
"size": {
"format": "uint64",
"minimum": 0,
"type": "integer"
}
},
"required": [
"etag",
"key",
"last_modified",
"size"
],
"type": "object"
}
},
"properties": {
"common_prefixes": {
"items": {
"type": "string"
},
"type": "array"
},
"next_cursor": {
"type": [
"string",
"null"
]
},
"objects": {
"items": {
"$ref": "#/definitions/ObjectSummary"
},
"type": "array"
}
},
"required": [
"common_prefixes",
"objects"
],
"title": "ListObjectsResp",
"type": "object"
}
},
{
"description": "Internal: transactionally apply the authoritative storage configuration without restarting the worker.",
"metadata": {
"internal": true
},
"name": "storage::on-config-change",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"default": null,
"type": [
"string",
"null"
]
}
},
"title": "OnConfigChangeEvent",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"ok": {
"type": "boolean"
}
},
"required": [
"ok"
],
"title": "OnConfigChangeResponse",
"type": "object"
}
},
{
"description": "Issue a short-lived multipart/form-data POST for direct browser upload. The file never buffers in the engine or worker RPC path.",
"metadata": {},
"name": "storage::presignPost",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"bucket": {
"type": "string"
},
"content_type": {
"type": "string"
},
"expires_in_seconds": {
"default": 600,
"format": "uint64",
"minimum": 0,
"type": "integer"
},
"key": {
"type": "string"
},
"max_size_bytes": {
"description": "Optional upload cap enforced while the HTTP body is streamed.",
"format": "uint64",
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"required": [
"bucket",
"content_type",
"key"
],
"title": "PresignPostReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"expires_at": {
"format": "date-time",
"type": "string"
},
"fields": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"url": {
"type": "string"
}
},
"required": [
"expires_at",
"fields",
"url"
],
"title": "PresignPostResp",
"type": "object"
}
},
{
"description": "Issue a short-lived URL the browser can hit directly to PUT or GET an object.",
"metadata": {},
"name": "storage::presignUrl",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"HttpMethod": {
"enum": [
"GET",
"PUT"
],
"type": "string"
}
},
"properties": {
"bucket": {
"type": "string"
},
"content_type": {
"type": [
"string",
"null"
]
},
"expires_in_seconds": {
"default": 600,
"format": "uint64",
"minimum": 0,
"type": "integer"
},
"key": {
"type": "string"
},
"method": {
"$ref": "#/definitions/HttpMethod"
},
"response_content_disposition": {
"description": "GET-only: override `Content-Disposition` header on the served response. Rejected with `INVALID_PRESIGN_PARAMS` if set on a PUT presign.",
"type": [
"string",
"null"
]
},
"response_content_type": {
"description": "GET-only: override `Content-Type` header on the served response. Rejected with `INVALID_PRESIGN_PARAMS` if set on a PUT presign.",
"type": [
"string",
"null"
]
}
},
"required": [
"bucket",
"key",
"method"
],
"title": "PresignReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"expires_at": {
"format": "date-time",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"expires_at",
"url"
],
"title": "PresignResp",
"type": "object"
}
},
{
"description": "Upload a small object inline as base64 (10 MiB hard limit). This buffers and inflates the payload; use presignPost or a presignUrl PUT for files and large objects.",
"metadata": {},
"name": "storage::putObject",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Write a genuinely small object inline. The complete base64 payload is buffered, with a 10 MiB decoded hard limit. Use `storage::presignPost` or a signed PUT from `storage::presignUrl` for files and larger objects.",
"properties": {
"body_base64": {
"description": "Base64-encoded small body (10 MiB decoded hard limit). The legacy field name `body` is accepted as an alias for backward compatibility.",
"type": "string"
},
"bucket": {
"type": "string"
},
"cache_control": {
"type": [
"string",
"null"
]
},
"content_type": {
"type": [
"string",
"null"
]
},
"key": {
"type": "string"
},
"metadata": {
"additionalProperties": {
"type": "string"
},
"default": {},
"type": "object"
}
},
"required": [
"body_base64",
"bucket",
"key"
],
"title": "PutReq",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"etag": {
"type": "string"
},
"size": {
"format": "uint64",
"minimum": 0,
"type": "integer"
},
"version_id": {
"type": [
"string",
"null"
]
}
},
"required": [
"etag",
"size"
],
"title": "PutResp",
"type": "object"
}
},
{
"description": "Serve the storage worker's injected console UI assets (content function for its console:script / console:style triggers).",
"metadata": {
"internal": true
},
"name": "storage::ui-content",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Input of the content function: the console asks for one asset by path.",
"properties": {
"path": {
"description": "The asset path from the trigger config (e.g. `state/page.js`).",
"type": "string"
}
},
"required": [
"path"
],
"title": "UiContentInput",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Output of the content function.",
"properties": {
"content": {
"description": "The asset source, verbatim.",
"type": "string"
},
"content_type": {
"description": "MIME type the console should serve the asset with.",
"type": "string"
}
},
"required": [
"content",
"content_type"
],
"title": "UiContentResult",
"type": "object"
}
}
],
"triggers": [
{
"description": "Fires when an object is written to a configured bucket whose notifications source is wired up.",
"invocation_schema": {},
"metadata": {},
"name": "storage::object-created",
"return_schema": {}
},
{
"description": "Fires when an object is removed from a configured bucket whose notifications source is wired up.",
"invocation_schema": {},
"metadata": {},
"name": "storage::object-deleted",
"return_schema": {}
}
]
}