Skip to main content

Data Model

See also: Firestore and Security Rules, Story Engine, Auth and Users, Pricing and Subscriptions, Story Categories

All shared type contracts live in one library: @stretched/types (libs/stretched-types). It is the single source of truth for every shape that crosses the client ⇄ Firestore ⇄ Functions boundary — both the Angular apps and the Firebase Functions import from it, so contracts never drift. It contains no runtime code and no dependencies (a handful of display consts and pure helpers aside).

House rule: a stored shape is never renamed after first use in Firestore — add a migration instead. Change a document type only together with the code that reads/writes it AND libs/firebase-permissions/firestore.rules (see Firestore and Security Rules).

Layout

One folder per domain, each domain file named after it (geo/geo.ts, never a folder index.ts). src/index.ts is the one intentional barrel — the package's public surface. Consumers always import @stretched/types, never deep paths.

Every domain file follows the same internal order, so you always know where to look:

1. Value types — unions / enums (PartnerStatus, FeedbackType)
2. Document / model — with a `stored at c/{id}` note + wire caveat
3. Display consts — labels / blurbs (PARTNER_VALUE_ANGLES) [optional]
4. *Api namespace — endpoint contracts (FeedbackApi.Send.{Request,Response})
FileExportsFirestore home
src/shared/api.tsApiError — the error body all Functions return
src/auth/auth.tsAuthUser — the Firebase-auth surface the app consumes (see Auth and Users)not a document
src/user/user.tsUser, UserStoryEntry — profile + subscription + answers (map model)users/{uid}
src/user/user-data.tsUserData, StoryEntry, EncryptionMeta, UserDataPayload, UserApirival array model + client-side encryption wrapperusers/{uid}
src/story/story.tsStory, FirebaseInternalStoryDocument — the authored template (see Story Engine)stories/{name}
src/story/story-content.tsDynamicComponent, DynamicInput, StorySection, StoryVariable, ValidatorDef, VariableType — the authoring substrate rendered by the Dynamic Component Systemembedded in Story
src/story/story-category.tsSTORY_CATEGORY_IDS, StoryCategory, STORY_CATEGORIES + helpers (see Story Categories)persisted ids on stories
src/story/story-locale.tsNonEnglishStoryOverride — text-only translation overlay keyed to a story + language
src/subscription/subscription.tsTIER_IDS, TierId, TierInfo, SUBSCRIPTION_TIERS, BillingCycle, SubscriptionApi (see Pricing and Subscriptions)subscription on users/{uid} + tier custom claim
src/partner/partner.tsPartnerAccount, PartnerProduct, PartnerStatus, PartnerValueAngle (see Partner Program)partners/{uid}
src/feedback/feedback.tsFeedbackPayload, FeedbackType, FeedbackApi (see Feedback System)— (Function-only)
src/geo/geo.tsLocationResult, MatrixRequest/Result, DirectionsResult, RouteProfile, GeoApi— (Function-only)

ER diagram (generated — do not hand-edit)

The diagram below is generated by tools/generate-types-erd.ts directly from the types (see Tooling and Scripts); bun run types:erd refreshes it and CI (bun run types:erd:check) fails if it is stale, so it is always current. Reading it: solid lines are real nested/embedded TypeScript references; dashed lines are links that exist only as bare strings, declared with a @ref JSDoc tag (the weak seams to be aware of). *Api contracts and @erd-ignore-tagged display-metadata shapes are excluded.

How the entities relate

  • Story (stories/{name}) is the template an admin authors in the Page Builder and publishes: locale-keyed description components, sections, a variables map, and an optional builder-authored root component tree. Published definitions are served as static JSON/CDN at runtime — the Firestore collection is an authoring surface only. See Story Engine.
  • Story content substrate (story-content.ts): DynamicComponent / DynamicInput are { component: string, data: {...} } pairs resolved against the component registry at render time — deliberately no kind discriminator in the stored shape (see Dynamic Component System). StoryVariable carries a VariableType, optional ValidatorDef[] (expression + message — see Expressions and Math), and an optional bound DynamicInput (see Context and Bindings).
  • StoryCategory is a closed union of persisted string ids; a story files under several (Story.categories). Display metadata lives beside the ids — see Story Categories.
  • NonEnglishStoryOverride is not a second Story — it is a thin text-override document (story + language) that replaces only strings by dotted path; the graph, math, and generators are shared.
  • User (users/{uid}) holds profile, consent flags, client-side encryption material, an embedded subscription object (TierId, BillingCycle, Stripe ids — see Pricing and Subscriptions), and the user's story answers (below).
  • PartnerAccount (partners/{uid}) embeds PartnerProduct[]; its status lifecycle is enforced by security rules — see Partner Program and Firestore and Security Rules.

⚠️ Two rival "user's answers" models (unmerged)

Two representations of a user's saved answers to stories coexist, and they have not been merged:

Map modelArray model
Filelibs/stretched-types/src/user/user.tslibs/stretched-types/src/user/user-data.ts
ShapeUser.stories: story-key → { default, [year]: UserStoryEntry }UserData.stories: flat StoryEntry[]
Extrasper-entry doNotUseData / isComplete / isEncryptedUserDataPayload discriminated union wrapping the whole doc in AES-GCM ciphertext; EncryptionMeta (PBKDF2 salt + sentinel check)
Used bythe full user document modelsettings UI (user-settings-dialog, stats-section)

Do not add a third. Consolidating is a data decision (it touches encryption and persisted documents) — pick the keeper before building the user-answer loop, and treat it as a migration.

Note the answers link back to a Story only by a bare string key (e.g. "2024") — there is no TypeScript reference between them (a dashed edge in the diagram).

Date is the in-app shape, not the wire shape

Every Date field in these types describes the object in memory. On the wire it is something else:

  • Firestore round-trips Date as Timestamp.
  • Published JSON (CDN-served stories) carries ISO 8601 strings.

Nothing in the type tells you which side you hold — convert at the boundary. The story store does this with its StoredStory wrapper (see Story Engine); apply the same pattern to user documents. Fields that are always strings say so explicitly (e.g. StoryEntry.modified: string // ISO 8601).

JSDoc conventions that drive the diagram

tools/generate-types-erd.ts infers most edges automatically (arrays, Records, nested objects → the right cardinality). Two annotations cover what it cannot infer:

  • @ref <Entity> on a field — a foreign-key-style link that is only a bare string at compile time (e.g. story_REF: string on FirebaseInternalStoryDocument). Rendered as a dashed edge. Add it whenever you introduce a new string link.
  • @erd-ignore on a type — excludes display-metadata records (TierInfo, StoryCategoryInfo, PartnerValueAngleInfo) from the diagram. Whole API/boundary files (shared/api.ts, auth/auth.ts, feedback/feedback.ts, geo/geo.ts) are skipped via SKIP_FILES in the tool itself.

After changing any type: bun run types:erd, commit the regenerated README block. See Tooling and Scripts.

Endpoint contracts (*Api namespaces)

The frontend talks to Firestore directly for documents (guarded by Firestore and Security Rules) — those need no API schema. Only a handful of operations go through HTTPS Functions, each with a typed contract namespace used as the generic args to HttpClient calls:

NamespaceEndpointsWhy a Function
UserApiDELETE /users/data, DELETE /users/accountrules can't check auth_time; re-auth enforced server-side
SubscriptionApiPOST /subscription/checkout, POST /subscription/portalStripe secret key stays server-side
FeedbackApiPOST /feedback/sendemail send + rate limiting (see Feedback System)
GeoApiGET /geo/autocomplete, GET /geo/search, POST /geo/matrix, GET /geo/directionsORS API key stays server-side (see Secrets Management)

All error responses share ApiError (src/shared/api.ts).