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})
| File | Exports | Firestore home |
|---|---|---|
src/shared/api.ts | ApiError — the error body all Functions return | — |
src/auth/auth.ts | AuthUser — the Firebase-auth surface the app consumes (see Auth and Users) | not a document |
src/user/user.ts | User, UserStoryEntry — profile + subscription + answers (map model) | users/{uid} |
src/user/user-data.ts | UserData, StoryEntry, EncryptionMeta, UserDataPayload, UserApi — rival array model + client-side encryption wrapper | users/{uid} |
src/story/story.ts | Story, FirebaseInternalStoryDocument — the authored template (see Story Engine) | stories/{name} |
src/story/story-content.ts | DynamicComponent, DynamicInput, StorySection, StoryVariable, ValidatorDef, VariableType — the authoring substrate rendered by the Dynamic Component System | embedded in Story |
src/story/story-category.ts | STORY_CATEGORY_IDS, StoryCategory, STORY_CATEGORIES + helpers (see Story Categories) | persisted ids on stories |
src/story/story-locale.ts | NonEnglishStoryOverride — text-only translation overlay keyed to a story + language | — |
src/subscription/subscription.ts | TIER_IDS, TierId, TierInfo, SUBSCRIPTION_TIERS, BillingCycle, SubscriptionApi (see Pricing and Subscriptions) | subscription on users/{uid} + tier custom claim |
src/partner/partner.ts | PartnerAccount, PartnerProduct, PartnerStatus, PartnerValueAngle (see Partner Program) | partners/{uid} |
src/feedback/feedback.ts | FeedbackPayload, FeedbackType, FeedbackApi (see Feedback System) | — (Function-only) |
src/geo/geo.ts | LocationResult, 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-keyeddescriptioncomponents,sections, avariablesmap, and an optional builder-authoredrootcomponent 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/DynamicInputare{ component: string, data: {...} }pairs resolved against the component registry at render time — deliberately nokinddiscriminator in the stored shape (see Dynamic Component System).StoryVariablecarries aVariableType, optionalValidatorDef[](expression + message — see Expressions and Math), and an optional boundDynamicInput(see Context and Bindings). StoryCategoryis a closed union of persisted string ids; a story files under several (Story.categories). Display metadata lives beside the ids — see Story Categories.NonEnglishStoryOverrideis not a secondStory— 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 embeddedsubscriptionobject (TierId,BillingCycle, Stripe ids — see Pricing and Subscriptions), and the user's story answers (below).PartnerAccount(partners/{uid}) embedsPartnerProduct[]; itsstatuslifecycle 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 model | Array model | |
|---|---|---|
| File | libs/stretched-types/src/user/user.ts | libs/stretched-types/src/user/user-data.ts |
| Shape | User.stories: story-key → { default, [year]: UserStoryEntry } | UserData.stories: flat StoryEntry[] |
| Extras | per-entry doNotUseData / isComplete / isEncrypted | UserDataPayload discriminated union wrapping the whole doc in AES-GCM ciphertext; EncryptionMeta (PBKDF2 salt + sentinel check) |
| Used by | the full user document model | settings 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
DateasTimestamp. - 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: stringonFirebaseInternalStoryDocument). Rendered as a dashed edge. Add it whenever you introduce a new string link.@erd-ignoreon 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 viaSKIP_FILESin 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:
| Namespace | Endpoints | Why a Function |
|---|---|---|
UserApi | DELETE /users/data, DELETE /users/account | rules can't check auth_time; re-auth enforced server-side |
SubscriptionApi | POST /subscription/checkout, POST /subscription/portal | Stripe secret key stays server-side |
FeedbackApi | POST /feedback/send | email send + rate limiting (see Feedback System) |
GeoApi | GET /geo/autocomplete, GET /geo/search, POST /geo/matrix, GET /geo/directions | ORS API key stays server-side (see Secrets Management) |
All error responses share ApiError (src/shared/api.ts).