conceptadev / conceptadev/rockets

rockets-repository-firestore: parity plan to make Firestore a production store of record (transactions, uniqueness, zod compiler, cursors, TTL)

Open
#44 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
1
Forks
2
Avg merge
2d 10h
Merged PRs (30d)
23

Description

# Firestore adapter: what is missing to reach TypeORM-level parity as a production store of record

> **Draft issue for `conceptadev/rockets`.** Formatted for
> `.github/ISSUE_TEMPLATE/feature_request.yml` (sections **Use case** and **Proposal**);
> everything else is supporting detail that can be collapsed or dropped.
>
> Every claim was verified against the code at the paths given, in this repository. Where a
> commonly repeated assumption about the adapter turned out to be **wrong**, it is corrected
> in "Assumptions corrected" rather than repeated as a gap.

**Suggested title:** `rockets-repository-firestore`: parity plan to make Firestore a
production store of record (transactions, uniqueness, zod compiler, cursors, TTL)

**Labels:** `package: rockets-repository-firestore`, `type: enhancement`, `priority: high`

---

## Use case

### Field evidence

We rebuilt a real production API on Rockets SDK v8 as a benchmark — **51 wire endpoints, 65
frozen error codes, ~28.7k LOC of server source**, twice (once against a frozen HTTP
contract, once contract-free). The application it replaces is **Firestore-native**: chat
sessions, CMA PDF records, video records, rate-limit buckets and an analysis cache all live
in Firestore today.

We still chose TypeORM, and the decision was made and documented *before* the first line of
code was written. It is the single largest architectural concession of the study.

> Consumer repository referenced throughout (private): `realtystack`, branch
> `feat/rockets-api-max`. Absolute path on the machine where this was verified:
> `/Users/thiagoramalho/Documents/Thiago/Workspace/Concepta/OpenSource/realtystack`.
> Paths below are written `realtystack/`.

- `realtystack/docs/benchmark/build-plan.md` §1 (lines 19-52) — *"Decision: TypeORM as the
single app repository adapter … Firestore is not used"*, with five numbered reasons: the
zod compiler being TypeORM-only, transactions, cursor pagination, retention/TTL, and the
SQLite test tier.
- `realtystack/docs/benchmark/00-gap-register.md` row 1 (line 7) — *"`zodResource` compiler is
TypeORM-only (`typeOrmZodEntityCompiler`); no Firestore compiler"* — and row 2 (line 8), the
Firestore→SQL swap with its (contract-invisible) divergences.

**What that cost.** The headline measured result of the whole study is **19 of 52 routes
generated (37%)**, up from 5 of 47 (10.6%) on the frozen branch
(`realtystack/docs/benchmark/rockets-build-metrics.md:343-358`). All 19 come from
`zodResource`, which requires a `SchemaEntityCompiler`. There is exactly one implementation
in this repository and it emits TypeORM decorators. **Had we chosen Firestore, the number
would have been 0** — the framework's headline capability is currently unavailable to
Firebase-first users, i.e. precisely the audience the package names in
`packages/rockets-repository-firestore/README.md:50` (*"You want a Firebase-first app with
Firebase Auth + Firestore storage"*).

The README is already honest about part of this (`README.md:52-56`: *"When NOT to use this
package: You need ACID transactions across multiple entities"*). This issue argues that (a)
the constraint is broader than cross-entity ACID, (b) most of it is reachable with
Firestore's own primitives — `runTransaction`, `startAfter`, `FieldValue.increment`, batched
writes, native TTL policies, deterministic document ids — and (c) a small, clearly-labelled
residue is a genuine limit of the database, not of the adapter, and should be stated as a
contract rather than left implicit.

### The ask

Define and close the parity set that lets an application select
`defineFirestoreRepository()` as its **root** adapter — not as a per-entity side store — and
get the same framework capabilities a TypeORM app gets today.

---

## Parity matrix — TypeORM vs Firestore, today

Legend: ✅ full · 🟡 partial · ❌ absent · ⛔ not achievable in Firestore's model (see
"Honest limits").

| # | Capability | TypeORM | Evidence (TypeORM) | Firestore | Evidence (Firestore) |
|---|---|:--:|---|:--:|---|
| 1 | zod → entity compiler (`SchemaEntityCompiler`) | ✅ | `packages/rockets-repository-typeorm/src/zod/compile-entity.ts:73-116`; exported at the `/zod` subpath (`src/zod/index.ts`) | ❌ | No `/zod` subpath in `packages/rockets-repository-firestore/package.json` `exports:9-16`; no `entityCompiler` on the bootstrap (`src/integration/define-firestore-repository.ts:19-35`). Seam exists and names Firestore as the easy case: `packages/rockets-core/src/common/repository/schema-entity-compiler.interface.ts:26-31`, `.../repository-module.interface.ts:17-20` |
| 2 | Transaction factory registered on the feature module | ✅ | `@concepta/nestjs-repository-typeorm/dist/typeorm-repository.module.js:20-26` (`transactionFactories`), `dist/transaction/typeorm-transaction.js` | ❌ | `packages/rockets-repository-firestore/src/firestore-repository.module.ts:27-33` and `src/utils/firestore-repository.util.ts:56-68` return a `DynamicRepositoryModule` with no `transactionFactories` |
| 3 | `ctx` on repo calls joins the ambient transaction | ✅ | `@concepta/nestjs-repository-typeorm/dist/repository/typeorm-repository.js:28-41` (`getRepo(ctx)` → `trx.getOrStart(...).getClient()`), used by every method (`:162-216`) | ❌ | Every `do*` method takes `_options` and never reads it: `src/repository/firestore-repository.ts:97,119,129,139,150,159,169,182`. `FirestoreBackend` has no transaction handle at all (`src/interfaces/firestore-backend.interface.ts:34-51`) |
| 4 | `transactional: true` on a generated CRUD op | ✅ | `packages/rockets-core/src/domain/interfaces/rockets-resource-definition.interface.ts:218` → `@concepta/nestjs-crud/dist/infrastructure/utils/get-transactional-decorators.js` → `Transactional()` | 🟡 *(silently inert)* | The decorator applies, but with no factory `TransactionManager.isSupported` is `false` and `TransactionScope.run` defaults to `propagation: 'SUPPORTS'` → the operation runs **non-transactionally with no error** (`@concepta/nestjs-repository/dist/transaction/transaction-scope.js`) |
| 5 | `TransactionScope` usable from app services | ✅ | Same as #2/#3 | ❌ | Same as #2/#3 — silent no-op |
| 6 | Hooks that read/write through the repo inside the operation | ✅ | `getRepo(options.ctx)` above; hook resolution `@concepta/nestjs-core/dist/infrastructure/hook/hook.resolver.service.js` | 🟡 | Hooks run, but the transactional half of `{ ctx }` is unconditionally discarded (#3). See consumer gap `realtystack/docs/benchmark/00-gap-register.md` row 51 (line 62) for how sharp this footgun already is on SQL |
| 7 | Soft delete + restore + `withDeleted` | ✅ | `@DeleteDateColumn` via `compile-entity.ts:153-156`; adapter honours `withDeleted` | ✅ *(with caveats)* | `src/repository/firestore-repository.ts:167-190`; `withDeleted` at `:211`. Caveats: column auto-detection is heuristic (`src/repository/firestore-entity-metadata.ts:60-83`) and the exclusion is an **in-memory post-filter** that disables every server-side fast path (see item 11 and P1-4) |
| 8 | Unique constraints from `db: { unique: true }` | ✅ | `packages/rockets-repository-typeorm/src/zod/compile-entity.ts:100-109,173-175`; meta declared at `packages/rockets-core/src/zod/fields.ts:29-32,67-68` | ❌ | `src/repository/firestore-entity-metadata.ts:11-51` builds columns and reads **only** the soft-delete flag; `unique`/`index` are never consulted anywhere in the package |
| 9 | Indexes from `db: { index: true }` / composite indexes | ✅ | `compile-entity.ts:102-108,178-180` (`@Index`) | ❌ | Never read; no index artifact is emitted. Firestore's composite-index requirement therefore surfaces only in production as `FAILED_PRECONDITION` |
| 10 | Filter operators executed natively | ✅ 17/17 | `@concepta/nestjs-repository-typeorm/dist/repository/typeorm-repository.js:88-120` — EQ, NE, GT, GTE, LT, LTE, CONTAINS, NCONTAINS, STARTS, NSTARTS, ENDS, NENDS, IN, NIN, IS_NULL, NOT_NULL, BETWEEN all become SQL | 🟡 6/17 | Native: EQ, NE, GT/GTE/LT/LTE, IN (≤30), `array-contains`, prefix-`STARTS` as a range (`src/repository/firestore-where.translator.ts:244-313`). In-memory post-filters: NIN, IS_NULL, NOT_NULL(partial), CONTAINS/NCONTAINS, NSTARTS, ENDS/NENDS, BETWEEN (`:139-243`). `'not-in'` is declared in `src/interfaces/firestore-query.interface.ts:9` but never emitted |
| 11 | `count` / `findAndCount` | ✅ | TypeORM `count`/`findAndCount` | 🟡 | Implemented, and uses the server-side aggregation `query.count().get()` (`src/backends/admin-firestore.backend.ts:150-152`) — but **only** when the branch has zero post-filters, which a soft-deletable entity never satisfies (`src/repository/firestore-query-runner.ts:86-104`, `admin-firestore.backend.ts:134-148`) |
| 12 | Offset pagination pushed to the store | ✅ | `skip`/`take` → SQL `OFFSET/LIMIT` | 🟡 | `limit(skip + take)` + local slice (`src/backends/admin-firestore.backend.ts:123-131`) — same caveat as #11: skipped entirely when post-filters exist (`:108-121`, *"read all matching docs and slice locally"*) |
| 13 | Keyset / cursor pagination | ❌ | — | ❌ | Neither adapter has it, because `RepositoryFindOptions` has **no cursor field** (`@concepta/nestjs-repository/dist/repository/interfaces/repository-options.interface.d.ts`). Firestore is the adapter where this is *cheapest* to add (`startAfter`), and it is currently unused (`grep -rn startAfter packages/rockets-repository-firestore/src` → 0 hits) |
| 14 | Projection (`select`) | ✅ | `typeorm-repository.js:154` (`select: options.select`) | ❌ | Dropped: `src/repository/firestore-repository.ts:203-214` forwards only where/order/skip/take/withDeleted |
| 15 | Relations / `join` | ✅ | `typeorm-repository.js:132-149` (`translateJoin`, `resolveJoinClauses`) | ⛔/❌ | Silently ignored; `metadata.relations` is hardcoded `[]` (`src/repository/firestore-entity-metadata.ts:49`). Relational joins are not a Firestore feature — but *silently* returning unjoined rows is an adapter defect. See "Honest limits" |
| 16 | Atomic multi-row writes | ✅ | One SQL statement / one transaction | ❌ | `doCreateMany` loops `doCreate` (`src/repository/firestore-repository.ts:105-114`), `doDeleteMany` loops `doDelete` (`:157-165`); documented as non-atomic in `README.md:196-199`. `WriteBatch`/`BulkWriter` unused |
| 17 | Atomic counter / compare-and-set | ✅ | Inside a transaction | ❌ | `doUpdate`/`doUpsert`/`doReplace` are `set(..., {merge})` of the caller's in-memory copy — last write wins (`src/repository/firestore-repository.ts:116-146`). No `FieldValue.increment`, no `update()` precondition, no version column |
| 18 | Native retention / TTL | 🟡 | Not a TypeORM feature either; apps use a `deleteAt` column + sweep | ❌ | No TTL story (`grep -rn "ttl\|TTL" packages/rockets-repository-firestore/src` → 0 hits) — even though **Firestore has native TTL policies** and TypeORM does not. This is a place Firestore could *beat* parity |
| 19 | Schema lifecycle | ✅ | `synchronize`/migrations flow through `TypeOrmModule.forRoot({...connection, entities})` (`packages/rockets-repository-typeorm/src/define-typeorm-repository.ts:24-29`) | ⛔ *(n/a)* | Firestore is schemaless — no migration equivalent is needed. The real analog is **index + TTL policy deployment**, which is item 9/18, not a gap in itself |
| 20 | Per-entity collection/table naming | ✅ | `Entity(options.table)` | ✅ | `collection` flows through the planner (`packages/rockets-core/src/infrastructure/resource/planner/repository-plan.ts:80-91`) into `src/utils/firestore-repository.util.ts:37` |
| 21 | Root-adapter usage (`repository:` on the bundle) | ✅ | `defineTypeOrmRepository(connection)` | 🟡 | Structurally supported (`defineFirestoreRepository` implements `RepositoryBootstrap`), but items 1-3, 8, 16-17 make it unusable as the *only* adapter for a non-trivial app. The README itself scopes the package as *"per-entity opt-in, not a wholesale replacement"* (`README.md:24-27`) |
| 22 | Test tier parity | ✅ | Everything runs on SQLite in the default suite | 🟡 | `InMemoryFirestoreBackend` filters and sorts everything in memory and enforces none of Firestore's query rules (`src/backends/in-memory-firestore.backend.ts:74-89`); the emulator suite is opt-in and covers value semantics only (`vitest.firestore.config.mts` → `**/*.emulator-spec.ts`; `src/__tests__/firestore-backend.emulator-spec.ts`) |

**Score today: 5 ✅ / 8 🟡 / 7 ❌ / 2 ⛔** across 22 capabilities.

---

## Definition of "production-ready" (the objective bar)

This issue proposes that "Firestore is production-ready" means **all five** of the following
are demonstrably true in this repository's own test suites:

**(a) Root adapter + full generated CRUD.** An app can pass `defineFirestoreRepository()` as
the bundle's `repository` and use `zodResource` with every operation generated
(`list`/`read`/`create`/`update`/`replace`/`delete`/`restore`), with schema-derived DTOs and
OpenAPI — i.e. the "19 of 52 generated routes" result is reproducible on Firestore, not only
on TypeORM.

**(b) The four transactional behaviours run without touching the raw SDK.** Taken verbatim
from the consumer API, because they are the general shapes every real app needs:

| Behaviour | Consumer implementation (SQL today) | What it needs |
|---|---|---|
| Fail-closed rate limit — read N buckets, deny consuming nothing, else increment all, in one unit | `realtystack/api-rockets/src/rate-limit/rate-limit.store.ts:94-141` | transaction + atomic increment + unique bucket id |
| Idempotent enqueue — deterministic UUIDv8 reservation, fingerprint replay/409/expired-404, dispatch, rollback on unconfirmed dispatch | `realtystack/api-rockets/src/resources/video/application/commands/handlers/enqueue-video.handler.ts:53-58,134-151` | transaction spanning a create + an external call |
| Chat turn lock — reserve/complete/clear with replay-before-staleness and 180 s takeover | `realtystack/api-rockets/src/resources/chat-sessions/chat-session-store.service.ts:60,128,170` | transaction + compare-and-set |
| Render lease — single winner, expired-lease takeover, renewal | `realtystack/api-rockets/src/resources/video/application/commands/handlers/video-lease.handlers.ts:65-86,124-139` | transaction + compare-and-set |

**(c) Generated cursor list works.** A keyset page is expressible through
`RepositoryFindOptions` and compiles to `startAfter` on Firestore. (This one needs a core
change too — see item 13 of the matrix.)

**(d) Native retention.** A `deleteAt`-style field can be declared as a Firestore TTL field,
stored as a real `Timestamp`, and the policy is emitted as a deploy artifact rather than
hand-maintained.

**(e) Suite parity, not just API parity.** The Rockets e2e/repository suites that run against
SQLite run against the **Firestore emulator** with the same test set — not a reduced
value-semantics subset. Today `vitest.firestore.config.mts` includes only
`**/*.emulator-spec.ts`, and the single such file tests value ordering and null/missing
semantics. Query legality (composite indexes, the orderBy/inequality rule), transaction
limits and write-batch limits are invisible to CI, which is why a green build can fail on
first contact with real Firestore.

---

## Honest limits — where Firestore cannot have parity, and the contract we propose instead

Being precise about this matters more than the feature list: some items above are **adapter
gaps** (fixable), others are **database limits** (must become an explicit contract).

| Limit | Why it is real | Proposed contract |
|---|---|---|
| **Relational joins** (matrix #15) | Firestore has no server-side join. Emulating it is N+1 reads with no transactional consistency across the fan-out | Do **not** emulate silently. Either (i) **fail at boot** when an entity registered on Firestore declares a `join`/relation the adapter cannot serve, naming the relation; or (ii) support it explicitly through the existing federation orchestrator (`@concepta/nestjs-repository/dist/federation/*`) with the read amplification documented. Denormalisation stays the app's choice, but the framework must not pretend the join happened |
| **Multi-field unique constraints** (matrix #8) | Firestore's only uniqueness primitive is the document id | Two tiers: **(1)** single unique column → map it to the **document id** (deterministic id). This covers the majority in practice — 3 of the consumer app's 4 unique columns (`bucketId`, `documentId`, `uid`) are natural document ids. **(2)** anything else (composite or secondary unique) → a **uniqueness-index collection** (`{collection}__unique__{fields}`, value-derived doc id holding the owner row id) written with `create()` **inside the same transaction**. If neither is configured, **fail at boot** naming the column — never silently drop it |
| **Read-after-write inside a transaction** | Firestore requires all reads before all writes in a transaction, and may **retry the whole closure** | The imperative `TransactionInterface` (`start`/`commit`/`rollback`, `@concepta/nestjs-repository/dist/transaction/interfaces/transaction.interface.d.ts`) does not express either constraint. Proposal in P1-1: expose a **callback-shaped** `runInTransaction(ctx, fn)` capability alongside the imperative bridge, and document that handlers must be idempotent and must not interleave reads after writes. A bridge that hides this would produce transactions that are wrong under contention |
| **Transaction/batch size** | ≤ 500 writes per batch; 270 s transaction lifetime; `getAll` argument limits | Chunk in the backend where semantics allow (batch), and **throw a typed adapter exception** where they do not (a single transaction exceeding 500 writes cannot be split without losing atomicity) |
| **`OR` across fields** | Firestore's `or()` is limited and disjunctions across different fields need index support; the adapter already fans out DNF branches client-side (`src/repository/firestore-query-runner.ts:20-53`) | Keep DNF fan-out, but make the read amplification measurable (branch count in a debug log) and cap it in line with `RepositoryAdapter.MAX_DNF_BRANCHES` |
| **Schema migrations** (matrix #19) | Schemaless store — nothing to migrate | Not a gap. The equivalent deliverable is index + TTL policy emission (P2-3) |

Everything **not** in this table is an adapter gap and is in scope for the phases below.

---

## Proposal — three delivery phases

### Phase 1 — unblock "store of record" (correctness)

Nothing here is optional for an app that writes concurrent state. All four consumer
behaviours in criterion (b) fail silently today.

**P1-1 · Transactions (matrix #2, #3, #4, #5) — effort L**

The extension point already exists in the contract:
`DynamicRepositoryModule.transactionFactories?: TransactionFactoryDescriptor[]`
(`@concepta/nestjs-repository/dist/interfaces/repository-module.interface.d.ts`), so this is
**additive for core**; only `FirestoreBackend` changes shape.

1. Add to `FirestoreBackend` (`src/interfaces/firestore-backend.interface.ts`):
`runTransaction(fn: (tx: FirestoreTransactionHandle) => Promise): Promise` and an
optional `tx` argument on `get`/`set`/`create`/`delete`/`queryBranch`. `AdminFirestoreBackend`
implements it with `getFirestore().runTransaction(...)`; `InMemoryFirestoreBackend`
implements it with a copy-on-write snapshot plus a conflict check on commit.
2. Register a `TransactionFactoryDescriptor` from `createFirestoreFeatureModule`
(`src/utils/firestore-repository.util.ts:56-68`), mirroring
`@concepta/nestjs-repository-typeorm/dist/typeorm-repository.module.js:20-26`.
3. Thread `options.ctx` → `TransactionManager` → backend `tx` in **every** `do*` method
(`src/repository/firestore-repository.ts`), the way
`@concepta/nestjs-repository-typeorm/dist/repository/typeorm-repository.js:28-41` does.
4. Resolve the shape mismatch honestly (see "Honest limits"): ship the callback-shaped
capability first, and only then the imperative bridge if it can be made safe.
5. Until a factory exists, **`transactional: true` on a Firestore-backed op should refuse at
boot**, not run unprotected. A silent no-op is worse than an unsupported feature.

**P1-2 · Atomic increment and compare-and-set (matrix #17) — effort M**

- A sentinel understood by `toStore` (`src/repository/firestore-repository.ts:254-267`) and
by `AdminFirestoreBackend.serialise` (`src/backends/admin-firestore.backend.ts:228-230`),
translated to `FieldValue.increment(n)` / `FieldValue.serverTimestamp()` and emulated
numerically in the in-memory backend.
- A `precondition` on the backend's `set`/`delete` (`lastUpdateTime`, must-exist,
must-not-exist), surfaced as an optional `expectedVersion` on `RepositoryUpdateOptions`.
Without P1-1 this is the *only* way to make a lease or a lock correct on Firestore.

**P1-3 · Uniqueness (matrix #8) — effort M (tier 1: S)**

Implement the two-tier contract from "Honest limits". `create()` already surfaces the right
error for the document-id tier (`FirestoreDuplicateIdException`,
`src/backends/admin-firestore.backend.ts:65-81`). Boot-time refusal for anything unenforceable.

**P1-4 · Soft delete must stop forcing full-collection reads (matrix #7, #11, #12) — effort M**

`augmentBranchesForSoftDelete` (`src/repository/firestore-query-runner.ts:86-104`) appends a
`soft_delete_excluded` **post-filter** to every branch whenever the entity has a soft-delete
column and `withDeleted !== true`. Post-filters are exactly what disables the fast paths:
`pushToServer` requires `postFilters.length === 0` (`firestore-query-runner.ts:22-25`),
`queryBranch` skips `limit()` when post-filters exist (`admin-firestore.backend.ts:108-121`,
comment: *"read all matching docs and slice locally"*), and `countBranch` abandons the
aggregation for a full read (`:134-148`).

**Net effect: on any entity with `dateRemoved`/`deletedAt` — the Rockets standard — every
list and every count reads the whole filtered collection into the Node process.** The
`limit(skip + take)` optimisation advertised in `CHANGELOG.md` is unreachable for exactly
those entities. On Firestore that is a billing curve, not an exception.

*Fix:* the post-filter exists because Firestore cannot express "field is null **or**
missing". Solve it at write time — always materialise the soft-delete column as an explicit
`null` in `toStore`, so `where(field, '==', null)` becomes a real server-side filter and the
branch keeps `postFilters.length === 0`. Ship a documented backfill for pre-existing
documents and keep the post-filter only as an opt-in compatibility mode.

**P1-5 · Reconcile `orderBy` with inequality filters (matrix #10) — effort M**

`buildOrderedQuery` (`src/backends/admin-firestore.backend.ts:208-220`) applies the caller's
`orderBy` verbatim. Firestore requires a query with a range/inequality filter to order by
that field first. The consumer app's retention hook produces `deleteAt > now`
(`realtystack/api-rockets/src/resources/video/video-expiry-scope.hook.ts:38-46`) while the
list ops order by `updatedAt desc` — a combination Firestore rejects at runtime. The same
applies to `NOT_NULL`, which the translator maps to `!=`
(`src/repository/firestore-where.translator.ts:211-216`).

*Fix:* prepend the branch's inequality fields to the pushed `orderBy` and re-sort locally
only when the requested order differs — or refuse at translation time with a message naming
the required composite order. Either way, emit the composite-index requirement (P2-3).

**P1-6 · Batched writes (matrix #16) — effort S**

`FirestoreBackend.writeBatch(ops)` over `db.batch()` (chunked at 500) for Admin and
apply-all/discard-all for in-memory; route `doCreateMany`/`doDeleteMany` through it, joining
the ambient transaction when one is active.

> **Phase 1 acceptance criteria**
> 1. A handler using `TransactionScope.run(ctx, …)` with `{ ctx }` on repo calls executes
> atomically against the Firestore emulator; `propagation: 'MANDATORY'` throws when it cannot.
> 2. The four behaviours of criterion (b) pass their existing e2e suites on a Firestore
> backend: deny-consumes-nothing; replay 201 / conflict 409 / expired 404 with rollback on
> unconfirmed dispatch; replay-before-staleness with 180 s takeover; single-winner lease
> with expired takeover and renewal failure.
> 3. A concurrent-writers emulator test proves increment and compare-and-set (no lost update,
> exactly one lease winner out of N racers).
> 4. `db: { unique: true }` is enforced or refused at boot with the column name.
> 5. `ownerUid == x AND deleteAt > now`, `order updatedAt desc`, `take 20` runs as a
> server-side query with a pushed `limit`, and `count()` uses the aggregation — asserted by
> **read counts** against the emulator, not by returned rows.
> 6. `createMany` is all-or-nothing within Firestore's batch limits, or throws a typed
> exception when it cannot be.
> 7. `transactional: true` on a Firestore-backed op never silently degrades.

### Phase 2 — feature parity

**P2-1 · `firestoreZodEntityCompiler` (matrix #1) — effort M-L**

Ship `@concepta/rockets-repository-firestore/zod`. The class itself is trivial — the
interface doc says as much (`packages/rockets-core/src/common/repository/schema-entity-compiler.interface.ts:26-31`:
*"Firestore needs little more than a named class token plus a collection name"*) — but that
sentence is what makes this deceptively easy. The real content is the **metadata** the
adapter currently guesses or ignores:

- **Soft-delete column.** Today auto-detected by instantiating the class and walking
prototype property names (`src/repository/firestore-entity-metadata.ts:60-83`) — which
returns nothing for a compiled class whose fields are decorator-only or unassigned, and
then `delete()` throws at runtime (`src/repository/firestore-repository.ts:225-232`).
`FirestoreProviderOptions.softDeleteField` exists
(`src/interfaces/firestore-provider-options.interface.ts:12`) but **is never set by the
planner** — `buildRepositoryPlan` forwards only `key`, `entity` and `collection`
(`packages/rockets-core/src/infrastructure/resource/planner/repository-plan.ts:80-91`),
confirming the README note at `README.md:170-179`. Fix both: carry it in compiled metadata
**and** make the existing override reachable via `ModuleResourceEntityEntry`.
- **`db.unique` / `db.index`** (P1-3, P2-3).
- **Date typing.** The TypeORM compiler maps `z.iso.datetime()` to a real `datetime` column
(`packages/rockets-repository-typeorm/src/zod/compile-entity.ts:279-281`); the Firestore
compiler must decide per field whether the stored form is a `Timestamp` (**required** for
native TTL, P2-2) or an ISO string, and `toStore`/`fromStore` must honour that.
- **Warning from the field:** a compiler that emits "little more than a named class token"
lands squarely in a known DTO-layer failure — `isBaseEntityResponseField` returns `false`
when `meta.db` is `undefined`, so a schema with no persistence metadata is invisible to the
response DTO and ships `null` columns. Documented with reproduction at
`realtystack/docs/benchmark/00-gap-register.md` row 45 (line 56).

**P2-2 · Native TTL (matrix #18) — effort M**

Firestore has per-collection **TTL policies** keyed on a `Timestamp` field — a capability
TypeORM does not have, so this is a chance to exceed parity rather than chase it. Expose
`ttlField` on the entity registration row, store it as a real `Timestamp`, and emit the
policy into the deploy artifact (P2-3). The consumer app currently implements retention as a
read-time `deleteAt > now` filter on every find plus an app-owned sweep
(`realtystack/api-rockets/src/resources/video/video-expiry-scope.hook.ts`); its Firestore
predecessor marked 2 sites `rockets-gap: ttl` (see Prior art).

**P2-3 · Emit index and TTL artifacts (matrix #9) — effort M**

Generate a `firestore.indexes.json` fragment from the registered entities plus the queries
the adapter can build (owner + range + order combinations, plus P1-5's composite orders),
as a build step or a `defineFirestoreRepository({ emitIndexes })` option. This repo already
carries `firebase.json` and `firestore.rules` at the root, so there is a natural home.

**P2-4 · Relations: explicit, not silent (matrix #15) — effort M**

Implement the "Honest limits" contract: refuse at boot or serve through the federation
orchestrator. Ending the silent-drop is the deliverable; full join support is not.

**P2-5 · Projection (matrix #14) — effort S**

Push `options.select` to `query.select(...)`; keep full reads on the document-id path and
document why.

**P2-6 · Native `not-in`; make post-filter cost visible (matrix #10) — effort S**

Emit `not-in` (≤10 values) natively — the op is already declared
(`src/interfaces/firestore-query.interface.ts:9`) and never used. For the operators that must
stay in memory, add a debug log of the scan, and an opt-in `strictPushdown` mode that throws
instead of scanning.

> **Phase 2 acceptance criteria**
> 1. `bindZodResources(firestoreZodEntityCompiler)` produces the same generated route set as
> the TypeORM compiler for at least one full resource (list/read/create/update/delete/restore),
> with schema-derived DTOs and OpenAPI, verified in this repo's e2e suite.
> 2. A TTL-declared field is written as a `Timestamp` and the emitted artifact contains the
> matching TTL policy.
> 3. Every query the e2e suite issues has a corresponding entry in the emitted index artifact;
> a CI job asserts the suite runs clean against an emulator started with only those indexes.
> 4. An entity declaring an unsupported relation fails at boot with the relation named.
> 5. `select` reduces the fields returned (asserted at the backend seam).

### Phase 3 — performance, limits and confidence

**P3-1 · Cursor pagination (matrix #13) — effort S in the adapter, M with the core option.**
Add `cursor`/`after` to `RepositoryFindOptions` (opaque, adapter-encoded); Firestore maps it
to `startAfter` — the natural fit, since Firestore cursors *are* keyset. TypeORM maps it to
the `(a, b) < (x, y)` predicate apps write by hand today. Field evidence: the consumer app
hand-wrote keyset lists **three times** on SQL (bypass-log rows #17, #21, #25 in
`realtystack/docs/benchmark/00-bypass-log.md`) and its Firestore predecessor hand-wrote a
`startAfter` helper (see Prior art).

**P3-2 · Read-cost observability — effort S.** Surface document-read counts per operation
(debug log or a hook), so the difference between a pushed query and a client-side scan is
visible in tests. Firestore bills per read; a silent scan is a production incident, not a
slow test.

**P3-3 · Enforce Firestore semantics in the test double (matrix #22) — effort M.**
`InMemoryFirestoreBackend` should **reject** what Firestore rejects: missing composite index
(against a declared index set), inequality-without-leading-orderBy, `in` > 30, batch > 500,
read-after-write inside a transaction. Today it filters and sorts everything happily
(`src/backends/in-memory-firestore.backend.ts:74-89`), which is why these defects are
invisible to CI.

**P3-4 · Emulator suite parity (criterion (e)) — effort M.** Broaden
`vitest.firestore.config.mts` beyond `*.emulator-spec.ts` value-parity tests so the
repository/e2e suites that run on SQLite also run on the emulator in CI.

**P3-5 · Documentation and correctness cleanups — effort S.**
- `README.md:192-194` claims document-id `IN` accepts *"at most 500 ids"* in *"one Admin SDK
`getAll` request"*. The code enforces **no** cap
(`src/repository/firestore-where.translator.ts:179-187`) and chunks by **300** across
multiple `getAll` calls (`src/backends/admin-firestore.backend.ts:24,164-184`). Fix the doc
or enforce the cap.
- `WhereOperator.CONTAINS` with an array value maps to `array-contains` with the **whole
array** as the comparison value (`src/repository/firestore-where.translator.ts:263-273`);
Firestore's `array-contains` matches a single *element*, and `array-contains-any` is the
list operator. No test covers this path (`grep "array-contains" src/__tests__` → 0 hits).
- Subcollections / `collectionGroup` are unsupported; every entity is a flat top-level
collection (`src/repository/firestore-repository.ts:45-49`). Sub-resources therefore model
as flat collections with an FK field. Worth an explicit README section, and optionally a
`parent`/`subcollection` registration option (effort L).

> **Phase 3 acceptance criteria**
> 1. A generated `list` op returns a cursor page compiled to `startAfter`, proven against the
> emulator.
> 2. Read-count assertions exist for the list/count paths and fail if a scan regresses.
> 3. The in-memory backend rejects each of the five illegal query shapes above, with tests.
> 4. CI runs the repository/e2e suite on the emulator, not only the value-parity file.

---

## Assumptions corrected

Recorded so this issue does not misstate the package — each of these was on our own
"missing" list and is **wrong**:

1. **`count()` is implemented**, with the server-side aggregation
(`src/backends/admin-firestore.backend.ts:150-152`). The caveat is P1-4, not absence.
2. **Soft delete and restore are implemented** (`src/repository/firestore-repository.ts:167-190`),
including `withDeleted` — with the detection caveat in P2-1.
3. **`skip`/`take` *are* pushed to the server** on the clean path
(`admin-firestore.backend.ts:123-131`), not only sliced in memory. The problem is how
rarely that path is reachable.
4. **`IN ≤ 30` is enforced** in code with a clear error
(`src/repository/firestore-where.translator.ts:224-234`). The "500" document-id limit,
however, exists only in the README — see P3-5.
5. **OR is supported** via `RepositoryAdapter.toDnf()` with client-side branch merging
(`src/repository/firestore-query-runner.ts:20-53`), and the "one inequality field per
query" rule is enforced with a good error
(`src/repository/firestore-where.translator.ts:315-331`).
6. **The transaction extension point already exists in the contract**, so P1-1 is additive for
`rockets-core`; only `FirestoreBackend` takes a breaking change.
7. **Cursor pagination is partly a core gap, not only a Firestore gap** —
`RepositoryFindOptions` has no cursor field for any adapter, TypeORM included.
8. **Schema migrations are not a Firestore gap** — there is nothing to migrate; the real
deliverable is index/TTL artifact emission.

---

## Prior art (field evidence)

**A full Firestore POC on Rockets** (consumer repo, branch `feat/rockets-migration`) ran the
same application on the raw Firebase SDK behind Rockets and ended with **15 explicitly marked
bypass sites** in 8 files. Reproduce with `git grep -n "rockets-gap:" feat/rockets-migration`:

- **Transactions — 9 sites** (the figure quoted in the consumer's recon notes):
`api/src/chat/chat-session.service.ts:111` (`deleteSession`), `:122` (`reserveTurn`), `:182`
(`completeTurn`), `:216` (`clearTurn`); `api/src/reports/cma/cma-share.service.ts:92`
(`createOwnerShare`), `:123` (`revokeOwnerShare`); `api/src/video/video.service.ts:38`
(`enqueueVideoGeneration`), `:101` (`reserveIdempotentRecord`);
`api/src/video/pipeline/upload-video.ts:175` (`completeOwnedRenderLease`).
(Raw `runTransaction` calls in that POC's `src` number **10** — the rate-limit one is filed
under `ttl`.)
- **Cursor pagination — 4 sites**: `api/src/common/firestore/list-owned-artifacts.ts:29` (the
shared keyset helper — it calls `startAfter(snapshot)` at `:88`, the exact primitive the
adapter does not expose), `api/src/chat/chat-session.service.ts:85`,
`api/src/reports/cma/cma-pdf.service.ts:180`, `api/src/video/video.service.ts:295`.
- **TTL — 2 sites**: `api/src/common/rate-limit/rate-limit.service.ts:64`,
`api/src/common/cache/analysis-response-cache.service.ts:100`.

**Consumer gap register** (`realtystack/docs/benchmark/00-gap-register.md`) — rows that bear
directly on this issue:

- **#1** (line 7) — no Firestore zod compiler; the reason the benchmark chose TypeORM → P2-1.
- **#2** (line 8) — the Firestore→SQL swap and its contract-invisible divergences (deep-health
`firestore=1` → `database=1`, `rateLimitStore: "database"`) → the whole issue.
- **#43** (line 54) — the CRUD list grammar cannot carry a computed resource's parameters
(1 of 17 map). Adapter-neutral, but it is the precedent for *"the grammar is a language of
predicates over a result set"* — relevant when specifying what P3-1's cursor should be.
- **#45** (line 56) — a schema with no persistence metadata is invisible to the DTO layer and
array columns ship empty → the warning in P2-1.
- **#51** (line 62) — a repo call made from inside a hook must forward `{ ctx }` or it commits
outside the operation's transaction *and* runs with hooks off. On Firestore today `ctx` is
discarded unconditionally, so the first half of that footgun is permanent rather than
opt-in → P1-1.

Related bypass-log rows (`realtystack/docs/benchmark/00-bypass-log.md`): #17/#21/#25 (three
hand-written keyset lists → P3-1), #26 (idempotent transactional enqueue → P1-1/P1-2), #27
(worker lease state machine → P1-1/P1-2), #4/#10/#20 (transactional rate-limit store →
P1-1/P1-2/P1-3).

Contributor guide

Open the contributing guide

Research direction

Start with the parity matrix, packages/rockets-repository-firestore/README.md, and src/utils/firestore-repository.util.ts, then inspect define-firestore-repository.ts, firestore-repository.module.ts, and firestore-repository.ts. Map the missing transaction, compiler, uniqueness, cursor, projection, batch-write, and TTL capabilities to the existing interfaces. Done means a prioritized, scoped parity plan that distinguishes implementable adapter work from Firestore limits and identifies the required tests or deployment artifacts.

Written by the indexing model from the issue text.

Assessment

Tech stack
firebase, typescript
Domain
backend-api-design, database
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.