npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

firebase-contract

v0.1.0

Published

Treat a **YAML contract as the single source of truth** for a Firebase app and generate every downstream representation from it — so shared types, enums, and validation stop being hand-maintained in parallel (and stop drifting).

Readme

firebase-contract

Treat a YAML contract as the single source of truth for a Firebase app and generate every downstream representation from it — so shared types, enums, and validation stop being hand-maintained in parallel (and stop drifting).

From one contract, generate:

  • TypeScript types (interfaces + frozen-const enums)
  • Zod validation schemas
  • Data Connect GraphQL schema (@table, keys, directives), query/mutation operations (routed per connector), and Any↔logical adapters
  • Firestore projection schemas (denormalized read model; shared field groups via fragments)
  • API request/response types, request-validation Zod, and class-validator DTOs
  • Cloud Task / Pub/Sub payload envelopes + delivery constants
  • SQL migrations for constraints Data Connect can't express (composite FKs, CHECK, indexes)
  • Per-entity id codecs (typed encode/decode wrappers)
  • Discriminated unions
  • Project config (dataconnect.yaml, connector.yaml) + sync constants

Install

npm i -D firebase-contract

Provides the firebase-contract (alias fbc) CLI and a programmatic API.

Quick start

fbc init          # scaffold contract.yml + firebase-contract.json
fbc validate      # parse, resolve imports, semantically validate
fbc generate      # run every generator declared across the contract graph
fbc inspect       # print the normalized IR (debugging)
fbc where <Name>  # locate the yml defining a type (logical / gqlName / fsName / table)

One-command generation

Each contract file declares the generators it uses in a top-level generators: block — a name plus an output template. Declaring a document-scoped generator (typescript, zod, …) runs it once for that yml; api-scoped generators (api-types, api-validation, api-dto, task-payloads) run for the entries that opt in (see below):

# contract.yml (root)
project:
  aliases:
    "#contracts/*": libs/contracts/src/*   # out templates may target aliases
generators:
  - { generator: typescript, out: "#contracts", split: true }
  - { generator: api-types, out: "#contracts/api-types/{api-name}" }
imports:
  - ./apps/shop/data-connect/schema.yml
# apps/shop/data-connect/schema.yml
generators:
  - { generator: data-connect-graphql, out: src, split: true }
  - { generator: sql-migrations, out: . }
  • out resolves relative to the declaring yml; #alias/... prefixes resolve through the root yml's project.aliases (relative to the root).
  • Api-scoped templates may use {api-name} (kebab-cased name) and {path} (REST path with {param} segments dropped).
  • Entries reference declarations by name — nearest first (same yml → root).

Output settings: file, split, options, header

Every declaration (and any entry-level application) can reshape the output — the defaults reproduce each generator's built-in layout, so omitting them changes nothing:

generators:
  - generator: api-dto
    out: "src/entries/{path}/dto"
    file: "{api-name}.dto.ts"   # file-name template (default shown)
    split: true                 # one file per api (api-dto's default)
  - generator: api-types
    out: "#contracts/api-types"
    file: "{api-name}.types.ts" # override the bundled default with per-api files
    split: true
    options:
      typesImport: "../types"   # where `import type { … } from '…'` points
  - generator: typescript
    out: "#contracts"
    file: models.ts             # rename a single-file/barrel output
    split: true                 # document scope: per-table split layout
  • file — output file name. Api scope: a template ({api-name}/{path} allowed when split: true). Document scope: renames the single output file, or the barrel of a split layout.
  • split — api scope: true emits one file per api (the file template must contain a placeholder), false bundles everything into one file. Document scope: true selects the generator's per-item split layout (typescript, zod, firestore, data-connect-graphql, data-connect-operations) and errors for generators without one.
  • options — free-form string map passed through to the generator. Currently: typesImport (api-types, task-payloads, data-connect-operations) overrides the types import path. For api-types and task-payloads this is normally unnecessary: when the option is absent and a typescript generator is declared (nearest-first: same yml → root), the import specifier is derived automatically as the relative path from the output file to the declared barrel — moving or renaming the barrel (out / file) re-derives it. An explicit typesImport always wins; without a typescript declaration the generator's built-in default (./types) applies.
  • header — per-generator banner override: default for the built-in AUTO-GENERATED banner, any text for a custom comment, "" to suppress the banner for this generator. Wins over the contract-level header: and the CLI --header flag.
  • Defaults per generator: api-types → api-types.ts bundled, api-validation → api-validation.ts bundled, task-payloads → task-payloads.ts bundled, api-dto → {api-name}.dto.ts split.
  • Entry-level settings override the declaration, which overrides the generator's default (same nearest-first rule as out).

fbc generate contract.yml materializes all declared outputs across the whole import graph in one run. Passing -o/-g switches to single-target mode (fbc generate <entry> -o <dir> -g typescript,zod). firebase-contract.json holds only a default entry — output routing lives in the yml generators: declarations, so a config file can never silently override them.

Generated-file headers

By default no banner is emitted. Opt in per run or per contract:

fbc generate --header                    # default AUTO-GENERATED banner
fbc generate --header "Managed by fbc"   # custom text (multi-line ok)
header: default        # or any text; the CLI --header flag wins

Header text (contract header:, per-generator header, CLI --header) supports ${...} template variables:

| Variable | Value | | --- | --- | | ${rootContractPath} | entry (root) yml path, relative to the root yml's directory | | ${currentContractPath} | the yml the output is generated from | | ${allContractPath} | import chain root → current, joined with -> | | ${generatedAt} | date the file was first generated (carried over on regenerate) | | ${updatedAt} | date the content last changed |

A regenerate that produces identical content keeps both dates and reproduces the file byte-for-byte, so --check stays drift-free; when content changes, generatedAt is carried over from the existing file and only updatedAt moves. Date tokens accept an optional format — ${updatedAt:yyyy-mm-dd HH:mm:ss} — with runs yyyy/yy, MM, dd, HH, ss, and mm meaning month before any hour token / minute after one (both yyyy-mm-dd and HH:mm read naturally). Default: yyyy-MM-dd.

The default banner includes the source and dates:

// AUTO-GENERATED by firebase-contract. Do not edit by hand.
// Source: ${currentContractPath}
// Generated: ${generatedAt} / Updated: ${updatedAt}
// Regenerate with: fbc generate

Comment syntax adapts per file type (// for TS, # for GraphQL/YAML-ish, -- for SQL).


The YAML DSL

A contract is composed of top-level sections — enums, models, operations, apis, firestore, unions, envelopes, project — and imports that pull in other contracts (relative paths or npm packages). Imports are resolved transitively with cycle/duplicate detection and diamond dedup, so you can split a large contract across files that mirror your repo layout.

Type names are globally unique. The namespace is flat across the whole import graph: defining the same name twice — same kind (DUPLICATE_DEFINITION) or across kinds/derived identifiers, e.g. an enum and a model both named Status, or two enums whose CONSTANT_CASE consts collide (NAME_COLLISION) — aborts generation. Uniqueness is enforced per output namespace with its effective names, so fsName/gqlName renames legitimately reuse a name in a different output: GraphQL names are checked per data-connect-graphql scope (services may map distinct logical names onto the same gqlName), and the firestore module is checked under fsName ?? name alongside doc names. A bare type reference therefore always resolves to exactly one definition — use fbc where <Name> to find it.

Unknown keys are errors. Every key the parser does not consume is an UNKNOWN_KEY error (all reported in one pass), never silently dropped — a typo like optionnal: must not quietly turn into a required field. There is no version: field: nothing consumes one, so accepting it would only fake a compatibility mechanism that does not exist.

Out-of-vocabulary values are errors too. A value outside a field's fixed set is INVALID_VALUE rather than a silent coercion: auth: PUBILC does not quietly become NO_ACCESS, dir: Dsec does not become ASC, and a mistyped action / single / keyArg / style.* is not dropped. The one exception is where.op, which is warned (UNKNOWN_WHERE_OP) not errored — it is emitted verbatim into GraphQL and the recognized-operator list may lag Data Connect, so a typo is surfaced without rejecting a valid-but-unlisted operator.

imports:
  - ./apps/shop/data-connect/schema.yml
  - some-shared-package/contract.yml

Enums

enums:
  ProductStatus:
    description: Lifecycle state of a product
    values: [DRAFT, PUBLISHED, ARCHIVED]

  ShippingSpeed:
    description: |-
      Multi-line descriptions become multi-line comments.
    values:
      - { value: S, key: STANDARD }        # const-object key override → STANDARD: 'S'
      - { value: X, key: EXPRESS, description: per-value comment }

Enum options: description, gqlName (GraphQL rendering name), fsName / fsDescription (Firestore-side rename/comment — e.g. render DC's Status as ReviewStatus in projections), and per-value key / description overrides.

By default the TypeScript generator emits the frozen-const representation (runtime values + Key and value types) — the shape you'd otherwise hand-write and keep in sync with the DC enum and the Zod enum:

export const PRODUCT_STATUS = Object.freeze({ DRAFT: 'DRAFT', PUBLISHED: 'PUBLISHED', ARCHIVED: 'ARCHIVED' } as const)
export type ProductStatusKey = keyof typeof PRODUCT_STATUS
export type ProductStatus = (typeof PRODUCT_STATUS)[ProductStatusKey]

For a plain 'OPEN' | 'DONE' | 'DISABLED' union instead, construct the TypeScript generator with enumStyle: 'union' via the programmatic API (createTypeScriptGenerator({ enumStyle: 'union' })). This is a build-time generator option, not a YAML contract field.

Models

models:
  User:
    fields:
      id: { type: id, id: true }
      name: string                 # shorthand — the value is the type
      email: { type: string, email: true }

  Product:
    description: A unit of work
    key: [catalog, productNo]            # composite primary key
    indexes:
      - { fields: [catalog, productNo], unique: true }
      - { fields: [status, createdAt] }
    fields:
      catalog: { type: Catalog, relation: true }
      productNo: int
      title: { type: string, nonempty: true, maxLength: 200 }
      status: ProductStatus
      metadata: { type: ProductMetadata, optional: true }   # embedded → Any
      createdAt: timestamp

Scalar types

string, int, float, boolean, timestamp, date, json, id.

Field options

| option | meaning | | ----------------------------------- | -------------------------------------------------------- | | type | scalar name, or an enum/model name | | optional | value may be absent (field?) | | list | value is an array of type | | id | marks the (single) primary identifier | | unique | field-level unique → @unique | | relation | model-typed field is a foreign-key relation (see below) | | col | Data Connect column dataType (Int64 PKs default to bigserial) | | default | @default(expr: …) on the DC column | | literal | pin to one literal value (union discriminant tags) | | nullable | value may be null (.nullable() / \| null) | | description | doc comment carried into generated output | | jsdoc | render the description as a JSDoc block (Firestore fields) | | constraints → | | | min / max | numeric bounds | | minLength / maxLength | string/array length bounds | | nonempty | non-empty string/array | | pattern | regex the string must match | | email / url | string format |

Constraints flow into the Zod schemas, the API request validation, and the class-validator DTOs.

Model options

| option | meaning | | ------------ | ------------------------------------------------------------------ | | key | composite primary key field names (else the id: true field) | | table | table name override (default: snake_case pluralization) | | gqlName | GraphQL type name override | | fsName | Firestore-side rename (avoid collisions with table models) | | directives | multi = each type-level directive on its own line | | footer | trailing comment block after the closing } in the schema file | | indexes | composite @index/@unique ({ fields, name?, unique?, expand? } — expand renders args one per line) | | sql | raw SQL constraints (see SQL migrations) |

Embedded vs relation

A field whose type is another model is one of two things:

  • embedded (default) — a nested value object. Nested in TypeScript / Firestore / Zod; stored as Any (jsonb) in Data Connect with the logical type preserved and restored by the adapter.
  • relation (relation: true) — a foreign-key reference to another table. Data Connect emits a relation (owner: User!, auto-creating the FK column); TypeScript / Firestore / Zod expose the foreign-key id (ownerId).
fields:
  profile: { type: ProfileImage }              # embedded → Any + adapter
  owner:   { type: User, relation: true }      # relation → owner: User! / ownerId

Data Connect operations

Query/mutation operations over a model's table. Emits .gql (over the auto-generated <table>_insert/update/upsert/delete resolvers, with @auth directives) plus TS Variables/Result types. Operations are routed per connector — an operation may target several connectors and is emitted into each, under <connector>/operations.gql.

defaults:
  connectors: [app]        # file-level default for operations below

operations:
  CreateShop:
    type: mutation
    model: Shop
    action: insert                        # insert | update | upsert | delete
    auth: NO_ACCESS                       # NO_ACCESS | PUBLIC | USER
    connectors: [app, api]                # emitted into both connectors
    inputs: [type, ownerUser, name, slug, status]

  IncrementSeq:
    type: mutation
    model: Shop
    action: update
    inc: [projectNoSeq]                   # → projectNoSeq_update: { inc: 1 }
    exprs: { updatedAt: request.time }    # → updatedAt_expr: "request.time"

  SearchProducts:
    type: query
    model: Product
    auth: PUBLIC
    authReason: gated at the app layer    # required when auth is PUBLIC
    where:
      - { field: title, op: contains }    # eq | contains | lt | le | gt | ge | ne
      - status                            # shorthand → { field: status, op: eq }
    orderBy:
      - { field: createdAt, dir: DESC }
    limit: 20
    select: [id, title, status]

  UsageTotals:
    type: query
    model: UsageLog
    where: [{ field: shop, op: eq }]
    aggregate:
      count: true
      sum: [weightedAmount, inputTokens]

Relation inputs thread through as owner: { id: $ownerId }, matching Data Connect's relation-reference form. Composite keys become key: { a: $a, b: $b }.

Fidelity options

Everything below exists to reproduce real hand-written .gql files byte-for-byte:

| option | meaning | | --- | --- | | gqlName | rendered operation name (the yml key stays unique across services) | | footer | trailing # … comment block after the operation | | raw | emit the operation body verbatim (multi-model queries, _or keyset cursors — field checks are skipped) | | single: id \| key | single-row lookup (product(id: $id) / product(key: { … })) | | keyArg / keyVars | update/delete key argument form and variable renames | | whereAnd: true | render conditions as an _and: [ … ] array | | entityDir | override the operations/<entity>/ output directory | | style | formatting hints: signature, args, data, orderBy, auth, key, and, where (inline/multi/compact/bare) |

Inputs support var (variable rename), required (override optionality), literal (fixed value, no variable), flat (write the FK column directly), inc (increment write), and asKey (pass a relation as a Model_Key object variable — $catalogVersion: CatalogVersion_Key). where fields support dotted paths through relations (review.catalog.id → nested review: { catalog: { id: { eq: … } } }), literal comparisons, and flat FK reads. select supports nested selections, aliases, arguments, and reverse joins (comments: reviewComments_on_product). limit may be a number or a variable ({ var, default?, required? }).

Operations are namespaced per connector: two operations may share a rendered name as long as their connector sets don't overlap.

API endpoints

Model application endpoints in three kind-implied sections — apis: (https / callable, keyed by REST path), tasks: (Cloud Tasks), and events: (Pub/Sub). The api-scoped generators — request/response types (api-types), request-validation Zod (api-validation), class-validator DTOs (api-dto), and payload contracts (task-payloads) — run for the entries that opt in via the section defaults or the entry's own generators:. A payload references a model or declares inline fields; a void response maps to void.

envelopes:
  RetryTaskPayload:                        # RetryTaskPayload<T> = T & { … }
    fields:
      identifierId: string
      opId: string
      enqueuedAt: int

tasks:                                     # Cloud Tasks (kind implied)
  defaults:
    generators: [task-payloads]            # section default: applies to entries below
  createCatalog:
    envelope: RetryTaskPayload             # wraps the product data
    maxAttempts: 3
    request:
      fields:
        shopId: string
        catalogId: string
    response:
      void: true

events:                                    # Pub/Sub (kind implied)
  generateAiResponse:
    topic: ai-review-generate-response
    timeoutSeconds: 540
    generators: [task-payloads]

apis:                                      # https/callable, keyed by REST path
  defaults:
    generators: [api-types, api-validation]
  /shops/{slug}:
    operationId: getShopBySlug
    kind: callable                         # https (default) | callable
    request: { fields: { slug: { type: string, nonempty: true } } }
    response: { model: Shop }
    generators:                            # entry-level wins over section defaults;
      - api-types                          # an inline out overrides the declared template
      - { generator: api-dto, out: "src/entries/{path}" }

Generator application resolves in two tiers — the section defaults (apis:/tasks:/events: → defaults.generators) and the entry's own generators: (which replaces the defaults). apis: keys must be REST paths (/... or METHOD /... so the same route can appear once per verb) with an operationId; tasks and Pub/Sub events live in their own sections.

For createCatalog this yields CreateCatalogTaskData, CreateCatalogTaskPayload = RetryTaskPayload<CreateCatalogTaskData>, and CREATE_CATALOG_MAX_ATTEMPTS, plus the request types, Zod schema, and DTO.

Firestore projections

Firestore is a denormalized read model, not the same shape as Data Connect. A projection derives from a DC model (from) and applies the projection reality automatically:

  • relations → resolved string ids (ownerUser → ownerUserId: z.string())
  • timestamps → z.date(), optional fields → .nullable()
  • pick / omit select which base fields survive
  • inline fields add denormalized data (flattened DAG edges, stringified JSON)
  • extends: [<fragment>…] splices shared fields in (see Fragments below)
firestore:
  User:
    from: User
    collection: users/{userId}
    pick: [displayName, photoURL, createdAt, updatedAt, lastLoginAt]
  Product:
    from: Product
    collection: shops/{ws}/.../products/{productNo}
    omit: [catalog, log]
    fields:
      parentProductNos: { type: int, list: true }
      linkedCatalogTitle: { type: string, optional: true }

Emits Zod schemas + inferred types, mirroring a hand-written Firestore schema library. (firestore-types is the zero-config entry point for projects that have not written projections yet: it re-types every model with the Firestore Timestamp, no firestore: section needed. Once you declare projections, prefer the firestore generator — it is the one that understands pick/omit/fsName/denormalized fields.)

With firestore + split: true, fields whose Zod chain is identical to the Data Connect schema are reused via .pick() — only representation changes are restated:

export const ShopSchema = DcShopSchema.pick({
  type: true, name: true, slug: true, ownerUserId: true, status: true,
}).extend({
  createdAt: z.date(),
  updatedAt: z.date().nullable(),
})

Additional projection options:

  • override semantics — fields declared under fields: use nullable: → .nullable() and optional: → .optional() explicitly (base-picked fields map DC-optional → .nullable() automatically); default: → .default(…)
  • pick order is authoritative for the projected field order
  • helpers: — verbatim TypeScript (e.g. small accessor functions) emitted into the projection file
  • include: [Model] — host a shared embedded schema in this file even when no projected field references it
  • FS-only enums render as frozen consts (REVIEW_STATE + ReviewStateSchema), co-located with the first projection that references them

Fragments (fragments: + extends:)

A fragment is a named group of fields declared once and spliced into consumers via extends: [<name>…] — the way to share a common field across many projections (a consistency _meta_ envelope, audit columns, …) without a tool-imposed convention. The fragment field's name, type, and which docs carry it are all yours.

The value objects a fragment references can be pinned to a fixed split output via out (+ optional file) on the model declaration. A pinned value object is placement-scoped: emitted only at that location (dependency-ordered, with an index.ts barrel), imported by consumers via that directory, and excluded from the default typescript/zod barrels — so it lives purely in the firestore output. This is what lets a shared envelope sit under firestore/_/.

models:
  MetaOp:
    out: firestore/_          # → firestore/_/_meta_.ts (+ firestore/_/index.ts)
    file: _meta_.ts
    fields:
      id: { type: string }
  Meta:
    out: firestore/_
    file: _meta_.ts
    fields:
      scope: { type: float, nullable: true }
      op: { type: MetaOp, nullable: true }

fragments:
  meta:
    fields:
      _meta_: { type: Meta }

firestore:
  Shop:
    from: Shop
    extends: [meta]            # appends `_meta_: MetaSchema` (imported from './_')

extends referencing a fragment that is not declared is an UNKNOWN_FRAGMENT error, not a silent no-op — a typo like extends: [metaa] fails generation rather than quietly dropping the shared fields.

Discriminated unions

unions:
  CatalogOperationDraft:
    discriminant: operationType
    variants: [AddLinkOperation, CutLinkOperation]

Each variant is a model whose operationType field is pinned with literal: so the output is a valid z.discriminatedUnion('operationType', [...]) plus the TS union type.

SQL migrations

Constraints Data Connect cannot express — composite foreign keys, CHECK constraints, extra indexes — are declared per model and emitted as an executable migrations/constraints.sql:

models:
  ProductLink:
    key: [catalog, parentProductNo, childProductNo]
    sql:
      checks:
        - "parent_product_no != child_product_no"
      foreignKeys:
        - { columns: [catalog_id, parent_product_no], references: "products(catalog_id, product_no)" }
      indexes:
        - { columns: [catalog_id, child_product_no] }

Id codecs

For each model with an id field, the id-codecs generator emits typed encode<Model>Id / decode<Model>Id wrappers (numeric codec for Int64 ids, string codec otherwise) over generic primitives from a configurable core module — replacing scattered untyped encodeNumericId calls.

Project config

project:
  services:
    - { name: shop, database: shop }
    - { name: warehouse, database: warehouse }
  codebases:
    shp: shop
  idCodec:
    minLength: 8
    alphabet: <your-shuffled-sqids-alphabet>
  aliases:
    "#contracts/*": libs/contracts/src/*
  • services (name/database) feed the FIRESTORE_DATABASES constants in the split firestore barrel.
  • idCodec pins the Sqids settings as a contract constant; when present, the id-codecs generator also emits a self-contained id-core.ts (the encode/decode primitives), so nothing about id encoding lives outside the contract.
  • aliases maps #alias/... out-template prefixes to paths relative to the root yml, so imported contracts can target shared libs without relative path gymnastics (longest prefix wins).
  • codebases / service location + connectors are consumed by the optional config generator (preview), which emits per-service dataconnect.yaml, per-connector connector.yaml, and the FIRESTORE_DATABASES / API_CODEBASES sync constants. It does not yet reproduce every field of a real deployment config (e.g. cloudSql datasource details) — treat it as a starting point, not a drop-in replacement.

Generators

| name | scope | output | notes | | ------------------------ | -------- | ----------------------------- | ------------------------------------------------------ | | typescript | document | types.ts; split: true → types/<table>.ts + barrel | interfaces + const/union enums; split co-locates enums/embedded objects per table | | zod | document | schemas.ts; split: true → schemas/<table>.ts + barrel | z.object schemas (constraints, z.lazy model refs) | | data-connect-graphql | document | schema.gql; split: true → schema/<table>.gql | type … @table(name, key); @col/@unique/@index/@default | | data-connect-operations| document | <connector>/operations.gql + .ts; split: true → <connector>/operations/<entity>/{queries,mutations}.gql | queries/mutations (where ops, orderBy, limit, aggregate, exprs, inc), per connector; split matches the real Data Connect repo layout | | data-connect-adapter | document | data-connect-adapters.ts | convert Any rows ⇄ logical types | | firestore-types | document | firestore-types.ts | naive TS types with Firestore Timestamp (all models) | | firestore | document | firestore.ts; split: true → firestore/<collection>.ts + barrel | Firestore projection Zod schemas (derived, denormalized); extends fragments, pinned value objects under their out (e.g. firestore/_/), DC-schema .pick() reuse, FIRESTORE_DATABASES constants in the barrel | | api-types | api | api-types.ts | endpoint request/response types | | api-validation | api | api-validation.ts | endpoint request-validation Zod | | api-dto | api | <operation>.dto.ts per api | class-validator DTO classes (NestJS dto/ convention) | | task-payloads | api | task-payloads.ts | envelopes + *TaskData/*TaskPayload + constants | | sql-migrations | document | migrations/constraints.sql | composite FK / CHECK / index SQL — idempotent (IF NOT EXISTS / duplicate_object guard), safe to re-apply whole | | id-codecs | document | id-codecs.ts (+ id-core.ts) | typed per-entity id encode/decode wrappers; emits the Sqids primitives when project.idCodec is set | | unions | document | unions.ts | Zod discriminated unions + TS union types | | config | document | dataconnect.yaml, connector.yaml, constants.ts | DC configs + sync constants (preview — see Project config) |

Each generator emits nothing when its section is absent, so a small contract produces a small output. Select a subset with -g.

Scope decides how a generators: declaration takes effect: document generators run once for the yml that declares them; api generators run only for the entries that apply them (section defaults.generators → entry generators:, entry wins).

The Any boundary

Data Connect stores JSON and embedded objects as the Any scalar, erasing the logical type. The GraphQL generator keeps the logical type in a comment (metadata: Any # logical: ProductMetadata), and the adapter emits typed converters that call fromAny/toAny from firebase-contract/runtime:

ProductMetadata → Data Connect: Any → generated adapter → ProductMetadata

Programmatic API

import { compile, generate, generateAll, createDefaultRegistry } from 'firebase-contract'

const { ir, diagnostics } = compile('contract.yml')

// Single target:
const result = generate('contract.yml', {
  outDir: 'generated',
  generators: ['typescript', 'zod'],   // omit for all
  write: true,
})

// Every generator declared across the import graph:
const all = generateAll('contract.yml', { write: true })

Architecture

YAML → Parser → Import Resolver → Normalized IR → Semantic Validation → Generators
  • Generators never see YAML. Their only input is the IR (src/lib/_internal/ir).
  • Generators are independent of each other and of shared mutable state.
  • The CLI is thin and delegates to the compiler; it never calls a generator directly.
  • Dependency direction: cli → compiler → {parser, resolver, ir, validation, generators}.

All implementation lives under src/lib/_internal (imported via the ~internal alias); the outer layers are thin: src/lib/cli/** (bin), src/lib/modules/** (public subpath exports, e.g. firebase-contract/runtime), and src/index.ts (root barrel).

Adding a generator

Implement the Generator interface and register it — no existing code changes:

import { Generator, createDefaultRegistry } from 'firebase-contract'

const openApi: Generator = {
  name: 'openapi',
  generate: ir => [{ path: 'openapi.json', content: toOpenApi(ir) }],
}

const registry = createDefaultRegistry().register(openApi)

Adding a validation rule

Rules are independent (ir) => Diagnostic[] functions; pass a custom set to validateIr(ir, rules).