.repos/alchemy-effect/examples/gcp-storage-uploads
| Name | Last commit message | Last commit date |
|---|---|---|
| .. | ||
README.md
gcp-storage-uploads#
An upload pipeline on Cloud Run. A public API stores files in Cloud Storage; a private indexer picks each upload up from the bucket's notification, hashes it, and records its metadata in Firestore.
PUT /files/:name ──> Api ──> gs://…/uploads/:name
│ OBJECT_FINALIZE
v
Pub/Sub push ──> Indexer ──> Firestore files/:name
GET /files ─────────> Api <────────────────────────── (size, type, sha256)
src/resources.ts— theUploadsbucket and theFilesFirestore database (named,FIRESTORE_NATIVE).src/Api.ts— publicGCP.Function:PUT /files/:namestores the body atuploads/:nameand returns202.GET /files/:namereturns the stored bytes.GET /fileslists the indexed metadata.DELETE /files/:namedeletes the object and its metadata.
src/Indexer.ts— privateGCP.FunctionconsumingOBJECT_FINALIZEevents foruploads/. It reads the exact object generation the event names, computes its sha256, and writesfiles/:name. Uploads never wait on the hash.
Bindings and event sources#
| Host | Binding / event source | IAM granted to the host's service account |
|---|---|---|
| Api | GCP.Storage.ReadBucket(bucket) | roles/storage.objectViewer on the bucket |
| Api | GCP.Storage.WriteBucket(bucket) | roles/storage.objectUser on the bucket |
| Api | GCP.Firestore.ReadWriteDatabase(files) | roles/datastore.user on the project, conditioned on this database |
| Indexer | GCP.Storage.ReadBucket(bucket) | roles/storage.objectViewer on the bucket |
| Indexer | GCP.Firestore.WriteDatabase(files) | roles/datastore.user on the project, conditioned on this database |
| Indexer | GCP.Storage.consumeBucketEvents(bucket) | roles/run.invoker on the Indexer itself (for the push OIDC token) |
consumeBucketEvents is provided by GCP.Storage.BucketEventSourceLive
with GCP.Run.TopicEventSource for delivery. Together they create a
Pub/Sub topic, a JSON_API_V1 bucket notification filtered to
OBJECT_FINALIZE under uploads/ (granting the Cloud Storage service
agent roles/pubsub.publisher on the topic), and a push subscription that
calls the Indexer with an OIDC token for its own service account.
Firestore databases have no resource-level IAM policy, so the Firestore
roles are granted on the project under an IAM Condition that names the
Files database.
Deploy#
Requires Docker (the services are built from main). Credentials come from your alchemy profile: run alchemy profile once and pick GCP (Service account JSON for a key file, or Stored for an access token or key kept in ~/.alchemy/credentials, plus a default region), then deploy with --profile <name>.
pnpm deploy --profile <name>
curl -X PUT --data-binary @photo.jpg -H 'content-type: image/jpeg' "$url/files/photo.jpg"
curl "$url/files" # metadata appears once the indexer has run
curl -o out.jpg "$url/files/photo.jpg"
curl -X DELETE "$url/files/photo.jpg"
On a fresh deploy the project-level Firestore grants can take a few minutes to propagate. Until then the indexer's writes fail and Pub/Sub redelivers them, so metadata shows up late rather than never.
Test#
ALCHEMY_PROFILE=<name> bun test
Deploys the stack, uploads a text file and a random binary, reads both back, waits for the indexer's records, checks each sha256 against the uploaded bytes and each record against the live Firestore document and object generation, deletes a file, then destroys the stack and checks the bucket, database, and services are gone.
Destroy#
pnpm destroy --profile <name>
The bucket uses forceDestroy: true, so destroy removes its objects too.
Drop it for real user data.