~/gqlith — docs/storage.md
Early access gqlith is in private early access — the code is not publicly available yet. These docs preview the surface so you know what to expect. Join the list →

Docs / Batteries

File storage

Governed file uploads from Postgres annotations — direct-to-store uploads with mint/confirm, signed downloads, image variants, quotas, lifecycle GC and S3 / R2 / GCS adapters.

Tag a column @gqlith.file(…) and gqlith generates a complete, governed file pipeline: upload mutations, signed downloads, per-tenant quotas, lifecycle garbage collection and webhooks — under the same RBAC, tenancy and audit model as everything else.

Enabled by choosing a storage adapter. Files live in your bucket; metadata lives in a managed gqlith_files table in your Postgres.

COMMENT ON COLUMN recipes.hero_image IS
  '@gqlith.file(profile: "images", maxSizeBytes: 10485760,
                allowedContentTypes: ["image/png", "image/jpeg", "image/webp"],
                variants: { thumb: "256x256" })';

Direct-to-store uploads, mint → confirm

File bytes never pass through your API. The client asks for an upload ticket, PUTs directly to the store, then confirms:

  1. requestUpload mints an opaque, signed ticket after checking RBAC, content-type / size policy and the tenant’s quota.
  2. The client uploads straight to S3 / R2 / GCS with that ticket.
  3. confirmUpload verifies the object server-side and flips it pending → confirmed.

Attaching a file to a row is validated on every FK write — confirmed status, tenant, owner and mint binding all checked — so a caller can’t attach someone else’s upload or shop for a laxer policy.

With the client SDK, the whole dance is one call: client.files.uploadFile(blob, opts). Large files switch to resumable multipart automatically (uploadFileResumable / resumeUpload); single-request uploads are supported up to 5 GiB.

Signed downloads, private by default

File.url and downloadUrl(fileId) mint short-lived signed URLs through a fail-closed authorization gate. Visibility is configured per profile (public | private, default private), and untrusted content types are served neutralized with Content-Disposition: attachment.

Image variants

Declare presets in the annotation (variants: { thumb: "256x256" }) and request them with File.variantUrl(preset) — served via CDN transform for public profiles, or derived by a sharp worker you run on a Node/Bun host (PNG / JPEG / GIF / WEBP).

Lifecycle: GC, webhooks, quotas

  • Orphaned uploads are reaped on a schedule you configure (orphanPolicy: reap-after-30d, or retain to keep everything); deletes and restores flow through a transactional outbox with a two-phase reconciler, so the bucket and the database can’t drift.
  • HMAC-signed lifecycle webhooks (onUploadConfirmed, onFileDeleted) deliver at-least-once with backoff and a dead-letter state.
  • Per-tenant storage quotas (quota.maxBytesPerTenant, maxFilesPerTenant) are reserved at mint time, transactionally.

Two environment variables are load-bearing: GQLITH_REAPER_SERVICE_TOKEN authenticates the reaper endpoint on any delete-capable deployment, and GQLITH_WEBHOOK_SIGNING_KEY signs lifecycle webhooks when storagePolicy.webhooks is declared.

Adapters

  • s3-compatible — AWS S3, Cloudflare R2, and any S3-API store (MinIO, B2, Spaces, Ceph), with SigV4 signing that runs on the edge.
  • gcs — Google Cloud Storage (V4 signing, resumable sessions).
  • local — in-process object server for dev and tests. Not for production.