
status + priorityScore, sweeps finding stale tasks by status + TTL.onSnapshot.processedAssets keyed on (sourceAssetId, stepType, paramsHash) — a re-run either cache-hits or regenerates identical output. Re-running is never a correctness problem. Re-running a batch must not create duplicate rows Deterministic document IDs where idempotency matters: baseClips/{listingId}__{sourceAssetId}, templateVersions/{templateId}_v{N}, reviews/job-{jobId}, digestLocks/{YYYY-MM-DD}. Writing twice overwrites one doc instead of creating two. S3 paths carry run-specific UUIDs, so naive param hashing never cache-hits across runs Per-step stable cache-key builders (videoGenerateCacheKey, upscale key) that hash only content hashes and semantic params, and deliberately exclude startFrameS3Key / inputS3Key. Adding a field to a cache key would invalidate every existing entry Keys evolve conditionally — e.g. the provider tag is folded in only when seedance is requested, so pre-existing Veo entries hash identically to before the field existed. One overloaded status enum can't express "funded, rendered, claimed by an editor, revision requested, not yet delivered" Six orthogonal state axes (funding · production · execution · editor work · customer review · delivery), resolved by pure functions into one display state and one queue bucket. Two surfaces computing "credits reserved" from different sources disagreed on a live customer account One canonical authority per question. getWorkspaceState owns access + credits; the frontend renders the answer and never merges independent counters. A job's task list is unbounded (~180 tasks for a photo-to-video batch) but its shot list is not Embedded where bounded, referenced where not. jobs.shots[] is an embedded array; tasks is its own collection so sweeps can query by status and workerQueue across all jobs. Recovery sweeps and workers race on the same document 79 transactions across the codebase for leases and claims: recovery-attempt reservation, delivery claim, digest lock, notification dedupe upsert. The same error firing 40 times in a minute would page 40 times Dedupe-by-hash documents — sha256(errorClass|errorKind|service|jobId|taskId) in a 5-minute window, bumping occurrenceCount instead of re-notifying. Client SDKs must see live calendar updates without being able to corrupt anything Default deny-all security rules with narrow, field-scoped read allowances; every mutation goes through the API on the Admin SDK. A presigned URL stored in a document is a time bomb URLs are never persisted — mediaLibrary stores the S3 key and re-signs per read.onSnapshot, everything else via the API.listingId, jobId, calendarEntryId, ownerId, sourceAssetId). The reason is query reach: a sweep that needs "every task in state X across every job" cannot do that against per-job subcollections without a collection-group index and a much weaker security story. Flat collections make every reaper, audit, and admin view a single indexed query.packages/shared/src/firebase/collections.ts — as a COLLECTIONS const plus a *Ref() accessor per collection. No string literal for a collection name appears anywhere else in the codebase.listings, assets, jobs, tasks, calendarEntries, jobErrorEvents, apiCostEvents Independent creation, no coordination, no hotspotting. Deterministic composite baseClips/{listingId}__{sourceAssetId}, templateVersions/{templateId}_v{N}, reviews/job-{jobId} Idempotent writes and single-get() resolution — a template shot's base_clip bind resolves its clip without a query. Natural key / singleton digestLocks/{date}, baseClipConfig/active-config, editorSettings/calendar-lead-templates, editorSettings/active-local-tour-template, infraStatus/production-health A distributed lock is a document that either exists or doesn't; a global config is one row.digestLocks/{today} and creates it only if absent. Fifteen scheduler replicas, one digest — no external lock service.jobs.shots[] is an embedded array of maps; tasks is a separate collection. Both are children of a job, so the split is a deliberate judgment:retryFailedProductionJob rewrites the whole shots array and the job status in a single batch write.status and workerQueue so the orphaned-task reaper can find stale work fleet-wide.processedAssets is the cache table for every generative step. The lookup is a three-field equality query — (sourceAssetId, stepType, paramsHash) — backed by a dedicated composite index, and it is the single most frequent query in the system.paramsHash is sha256 of the params object with sorted keys, so property order can't change the hash. Per-step builders then narrow what goes in:derivedAssets sits one level up: a job-scoped, shot-chain-level record exposing three outputs per shot (finalS3Key, imageS3Key, lastFrameS3Key) so a downstream shot can request pixel-exact continuity with its upstream neighbor's ending frame rather than its starting image. Cache and chain are separate tables because they answer different questions — "has this step ever been computed?" versus "what did this shot hand to the next one, in this job?"unfunded · funded · consumed · refunded Production draft · scheduled · production_window_open · materializing · queued Execution pending · processing · waiting · running · stalled · failed · recoverable · rendered Editor work unclaimed · claimed · locked · in_repair · escalated · approved_internally Customer review not_visible · pending_customer_review · revision_requested · revision_in_progress · customer_approved Delivery not_delivered · delivered · download_only · approved_for_posting · posted · cancellededitor-lifecycle-resolver.service.ts takes (calendarEntry, job, review, taskSummary, renderState) and resolves each axis with a pure function, then folds them into one of 23 display states and one of 11 queue buckets. None of that is stored. There is no editorState field to drift, no migration when a bucket is added, and the whole resolver is unit-testable without Firestore. The underlying documents keep their own narrow status fields; the composite view is computed at read time.creditsReserved had two independent derivations and they disagreed on a live account, showing "0 reserved" while work was committed. The fix was not a better formula; it was GET /api/account/workspace-state as the single answer for subscription access, credits, and trial-video count, with frontend surfaces rendering it and explicitly forbidden from doing their own Math.max(...) reconciliation.getWorkspaceState, the billing ledger, calendar entries, jobs, reviews, delivered media) and never re-derives credits or access from a display label.scheduledDate, productionStartDate, deliveryDate — are ISO 8601 strings. They represent a calendar day a human chose, they sort lexicographically, they work in Firestore range queries unchanged, and they don't shift under a timezone conversion.createdAt, updatedAt, startedAt, completedAt, failedAt — are Firestore Timestamps, so they can be written with serverTimestamp() and ordered against a server clock rather than a worker's.Timestamp, a raw {_seconds} map from an older SDK path, and ISO strings). Recovery code therefore routes every read through a timestampToMillis() coercion helper rather than assuming. That helper is the seam that let the schema evolve without a backfill.processedAssets.listingId — lets cache invalidation scope to a listing without joining through assets.calendarEntries.ownerId — serves both the composite index for a customer's calendar and the security rule that lets that customer subscribe to it.jobs.calendarEntryId — lets recovery re-point the entry and review in the same batch as the job.jobs.errorEventCount / hasErrors — a counter bumped by FieldValue.increment so the monitoring list never has to count a subcollection.listings.assetSummary — running image/video/byte counts so eligibility checks don't scan assets.FieldValue.increment appears 60 times; it is the default for any counter, because two workers incrementing concurrently is the normal case, not the edge case.firestore.rules governs only what a browser can touch. It is a closed list:calendarEntries ownerId == uid, or admin Live onSnapshot during the calendar reveal instantPreviews createdByUid == uid, or admin Onboarding preview polling videoFeedback own docs where source == 'customer', or admin Customer feedback history adminActions admin, or actorUid == uid Append-only: create, update and delete are all false errorReports / errorGroups / templateIngestionJobs / stripeEvents adminLevel in [superadmin, support, developer] Admin dashboardsstripeEvents/{customerId}/events/{eventId} — is written by Cloud Functions in the other repo and read-only here.assignedListingIds. Past that, listing access has to move to a server-side Firestore lookup — the schema is designed so that switch is a middleware change, not a data migration, because listings.assignedUserIds already carries the inverse edge.firestore.indexes.json is checked in and deployed: 122 composite indexes across 42 collection groups, plus 4 field overrides. The distribution tells you where the query pressure is — jobs (10), users (8), calendarEntries (7), jobErrorNotifications (7), metricSnapshots (7). The widest is five fields (metricDeltas: accountId, platform, entityType, entityId, windowEnd). Exactly one index uses arrayContains, because array-contains queries don't compose and are a dead end for pagination.packages/shared/src/firebase/admin.ts; bypasses security rulesrole, adminLevel, assignedListingIds (1000-byte budget)FieldValue.increment / serverTimestamp / FieldValue.delete (60+ sites) — contention-safe counters and explicit field removalfirestore.indexes.json — 122 composite indexes, version-controlled and deployedfirestore.rules — deny-by-default, read-only exceptions*.types.ts modules in packages/shared/src/types/, one per entity, with FirebaseFirestore.Timestamp typed explicitlypackages/shared/src/schemas/, validating every request body before it can reach a writecreditTransactions, referrals, brokerageReferralRequests, stripeEvents, and Stripe synchronization. This schema reads them and never writes them.packages/shared/src/firebase/collections.ts — the only place collection names exist; 69 constants + typed refs + deterministic-ID builderspackages/shared/src/types/*.types.ts — 39 entity type modulespackages/shared/src/schemas/*.schema.ts — 29 Zod modules, one per write surfacefirestore.indexes.json · firestore.rules — deployed with the appsystem-design.md §3 — the original schema design documentConflictError rather than silently double-acting.CREDIT_ALLOCATION_DIVERGENCE, UNFUNDED_WORK_IN_EDITOR_QUEUE, DELIVERED_WITHOUT_MEDIA, ORPHANED_EDITOR_REVIEW, …). It is read-only by design: detection ships before repair, and no repair is added until its false-positive rate is understood.COLLECTIONS at all — most belong to the other repo's analytics and prediction subsystem, and one (audioAssets) is a removed collection whose index outlived it.users.focusType / users.leadSources — accounts onboarded before these shipped have neither; behavior is unchanged for them.jobs.kind — a discriminator (standard · local-tour · free-trial · editor_test) that unlocks placeholderListing, editorWebhookUrl, editorWebhookSecret without forking the collection.jobs.productionIncident / automaticRecoveryCount — stamped by the recovery layer onto existing documents.audioAssets (per-user audio uploads) was deleted outright because the user-upload model reintroduced exactly the licensing problem the audio-swap feature exists to avoid — the spec calls for a royalty-free library only. It was replaced by audioTracks, an admin-managed catalog seeded from S3. The lesson embedded in the comment left behind in collections.ts: record why a collection was removed, in the file where someone would otherwise re-add it..doc(id).get() or an equality query served by a declared composite index. 43 of 69 collections need no composite index because they are addressed by ID.getWorkspaceState; no surface recomputes or reconciles them.
Posted Sep 20, 2026
Database schema for a content pipeline: flat collections, content-addressed caching, deterministic IDs, and lifecycle state derived at read time, never stored.