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)
| Aggregate | Replicated to desktop? | Conflict policy | Why |
|---|---|---|---|
FileObject | No (read-through cache only) | server_authoritative | Bytes are too large to mirror; metadata is small but fast-changing across many tenants and is always read on demand from the cloud. |
UploadSession | No | server_authoritative | Sessions are short-lived (≤ 1 h) and tied to a server-issued signed URL. |
Variant | No (URL cached) | server_authoritative | Variants are derived; clients only need the URL to fetch them. |
ScanResult | No | server_authoritative | Scan 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 connect | server_authoritative | Used to render UI hints on retention; never edited offline. |
RetentionPolicy (tenant overrides) | No | server_authoritative | Edited only by Tenant.Compliance from backoffice online. |
Bucket (logical) | No (config baked at deploy) | n/a | Platform-level; never visible to clients. |
AccessGrant | No | server_authoritative | Audit grain; written exclusively on the server. |
Quota | Read-only snapshot, refresh on connect | server_authoritative | Cap & current-usage banner only. |
ErasureRequest | No | server_authoritative | Initiated 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)
- Renderer requests
POST /uploads→ BFF proxies → file-storage returns signed PUT URL. - Renderer streams bytes directly to GCS via the signed URL (Electron uses
net.requestto bypass the bundled Chromium proxy and avoid the 4 GB Chromium fetch limit). - Renderer calls
POST /uploads/{ups}/confirmwith the SHA-256 it computed during streaming (Electron usescrypto.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:
- 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 queuedintentrow in the desktop SQLite (pending_file_uploads). - On reconnect, the desktop's sync engine drains the outbox: for each row it calls
POST /uploads, then PUTs bytes from local disk, thenconfirm. The original local copy is deleted on confirmed success. - 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
PATCHwithIf-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.allcapped at 3 in flight to avoid saturating outbound links. The signed URLs are issued in a single round-trip via the upcomingPOST /api/v1/files/uploads:batch(Phase 2; tracked in SERVICE_RISK_REGISTER). - For viewing, the desktop always requests
thumbfirst, thenherolazily on focus, and never requestsfullunless the user opens the lightbox. srcsetrendering uses CDN-fronted webp/avif variants; the renderer picksimage/avififelectron.process.versions.electronindicates 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.