Skip to main content

file-storage-service — SYNC_CONTRACT

Companion: SERVICE_OVERVIEW · DOMAIN_MODEL · APPLICATION_LOGIC · ADR-0003 Electron Offline-First Desktop

This service is Electron-aware but its aggregates are not replicated to the desktop SQLite store. Bytes never live on staff laptops outside of an active operation, and metadata is fetched over the network via thin caching at the desktop edge. This document declares the canonical conflict policy for every aggregate the service owns and explains how the offline-first desktop interacts with the file-storage API.

1. Replication summary (per aggregate)

AggregateReplicated to desktop?Conflict policyWhy
FileObjectNo (read-through cache only)server_authoritativeBytes are too large to mirror; metadata is small but fast-changing across many tenants and is always read on demand from the cloud.
UploadSessionNoserver_authoritativeSessions are short-lived (≤ 1 h) and tied to a server-issued signed URL.
VariantNo (URL cached)server_authoritativeVariants are derived; clients only need the URL to fetch them.
ScanResultNoserver_authoritativeScan is server-side; result reaches the desktop only via melmastoon.file.scan.passed.v1 projection in property-service.
RetentionPolicy (platform defaults)Read-only mirror, refresh on connectserver_authoritativeUsed to render UI hints on retention; never edited offline.
RetentionPolicy (tenant overrides)Noserver_authoritativeEdited only by Tenant.Compliance from backoffice online.
Bucket (logical)No (config baked at deploy)n/aPlatform-level; never visible to clients.
AccessGrantNoserver_authoritativeAudit grain; written exclusively on the server.
QuotaRead-only snapshot, refresh on connectserver_authoritativeCap & current-usage banner only.
ErasureRequestNoserver_authoritativeInitiated online via backoffice; result polled.

2. Desktop interaction patterns

The Electron desktop (@ghasi/app-desktop-backoffice) interacts with file-storage-service through the BFF (bff-backoffice-service) and never directly. Three patterns are supported:

2.1 Online direct upload (default)

  1. Renderer requests POST /uploads → BFF proxies → file-storage returns signed PUT URL.
  2. Renderer streams bytes directly to GCS via the signed URL (Electron uses net.request to bypass the bundled Chromium proxy and avoid the 4 GB Chromium fetch limit).
  3. Renderer calls POST /uploads/{ups}/confirm with the SHA-256 it computed during streaming (Electron uses crypto.createHash('sha256') on the file stream).

2.2 Online upload via BFF proxy (constrained networks)

For tenants on captive corporate networks that block direct GCS PUT, the BFF can proxy the upload (POST /bff/backoffice/v1/files/upload-proxy). The BFF holds the signed URL on behalf of the renderer and streams bytes through. This adds latency and counts toward BFF egress quotas; a feature flag enables it per tenant.

2.3 Offline pending uploads (queued)

When the renderer is offline:

  1. The user picks a file → renderer captures bytes into the desktop's local outbox blob store (an Electron app.getPath('userData')/outbox/ directory) along with a queued intent row in the desktop SQLite (pending_file_uploads).
  2. On reconnect, the desktop's sync engine drains the outbox: for each row it calls POST /uploads, then PUTs bytes from local disk, then confirm. The original local copy is deleted on confirmed success.
  3. Failures bubble into the desktop UI as red badges with retry actions; conflicts are impossible because the server is authoritative and the file is brand-new.

The local outbox is encrypted at rest using the desktop OS keyring (Keytar) — see ADR-0003 §6.

3. Conflict policy details

3.1 server_authoritative (default)

The cloud row is the truth. The desktop's only writes are initiate / confirm / abort / delete / patch-metadata, each of which is an idempotent server command. The desktop never stores a "local edit" of file metadata that needs reconciliation.

The one exception is PATCH /files/{med} for altText and tags. Here we use optimistic concurrency (If-Match: <version>):

  • Renderer reads version.
  • Renderer issues PATCH with If-Match.
  • On 412 PRECONDITION_FAILED, renderer re-fetches and either retries automatically (additive tag union) or surfaces a conflict toast (alt-text edit).

3.2 Tag merge (additive)

For tags array updates, the renderer can opt into set union semantics (X-Merge-Strategy: tags-union), in which case the server applies tags = uniq([...current, ...incoming]) and never raises 412 on tag-only edits. Used by AI-suggested tag application from the backoffice photo grid where many concurrent edits are expected.

3.3 Erasure / delete

Always server-authoritative. The desktop sees a deletion via the melmastoon.file.deleted.v1 event consumed by property-service / theme-config-service, which then pushes the projection to the desktop via the normal sync channel.

4. Read-through cache on desktop

The Electron renderer caches FileObject metadata in a SQLite table file_metadata_cache keyed by (tenant_id, file_object_id):

CREATE TABLE file_metadata_cache (
tenant_id TEXT NOT NULL,
file_object_id TEXT NOT NULL,
payload_json TEXT NOT NULL, -- serialized FileObjectDto
fetched_at INTEGER NOT NULL, -- epoch ms
expires_at INTEGER NOT NULL,
PRIMARY KEY (tenant_id, file_object_id)
);

Cache TTL = 60 s for ready files, 5 s for scanning/uploaded. Invalidated proactively on consumption of melmastoon.file.optimization.completed.v1, melmastoon.file.scan.{passed,failed}.v1, melmastoon.file.deleted.v1. Variants' URLs are cached separately under signed_url_cache with TTL = min(grant.expiresAt − now − 30s, 5min) to avoid presenting expired URLs to the chrome <img> tag.

5. Variant URL distribution

The desktop is allowed to embed CDN URLs (for public_media) directly in <img srcset> because they pass through Cloud CDN, which (a) does not require per-request signing for public_media and (b) caches efficiently. For private and archive scopes the renderer must request POST /files/{med}/download-url per use because every read needs an audit row.

6. Drift handling

Because nothing is replicated, drift is impossible — the desktop never holds a divergent state. The only drift surface is the metadata cache (§4), which is an opaque snapshot; staleness is acceptable up to TTL because (a) the underlying status FSM moves forward only (no rollbacks) and (b) UI affordances that depend on status (e.g., "this photo is still being processed") are explicitly designed for eventual consistency.

7. Bandwidth & UX considerations

Target markets are bandwidth-constrained. Specific guidance for desktop callers:

  • For property photo upload pages, the renderer chunks file selection into batches of 10 and runs uploads with Promise.all capped at 3 in flight to avoid saturating outbound links. The signed URLs are issued in a single round-trip via the upcoming POST /api/v1/files/uploads:batch (Phase 2; tracked in SERVICE_RISK_REGISTER).
  • For viewing, the desktop always requests thumb first, then hero lazily on focus, and never requests full unless the user opens the lightbox.
  • srcset rendering uses CDN-fronted webp/avif variants; the renderer picks image/avif if electron.process.versions.electron indicates AVIF support (Electron 28+).

8. Sync engine integration tests

@ghasi/sync-engine ships a contract test pack that file-storage participates in:

  • desktop-offline-upload.contract.spec.ts — drains the local outbox, asserts each upload completes and the local copy is removed.
  • desktop-metadata-cache.contract.spec.ts — asserts cache is invalidated on each event consumed.
  • desktop-signed-url-expiry.contract.spec.ts — asserts the renderer never serves an expired signed URL to the DOM.

9. Mobile (consumer app) sync posture

The consumer mobile app (@ghasi/app-mobile-consumer, React Native) does not have an offline outbox for uploads — it requires connectivity to upload check-in selfies / ID scans (Phase 2). Reads are always live; the app uses CDN for public_media and signed-on-demand for any private content (e.g., the guest's own invoice PDF). No local cache beyond the OS image cache.

10. Summary

file-storage-service is append-only and server-authoritative from the desktop's perspective. There are no CRDTs, no lww-diff merges, no append-only logs to reconcile. The contract is intentionally narrow because file content is large and storage policy is server-owned; the only surface that requires nuance is PATCH altText/tags, where optimistic concurrency or tag-union merge handles the rare conflict. This keeps the desktop sync engine's job as small as possible for this aggregate family.