diff --git a/README.md b/README.md index 9d32ca5..e0986ff 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,25 @@ export const PostRepository = Firestore.makeRepository(PostModel, { ); ``` +### Query a collection group + +Every repository has a `group` view that runs the same query methods across +every collection with the same ID at any depth (`posts/{postId}/comments`, +`users/{userId}/comments`). `pathField` fills in each document's full path. + +```typescript +export const CommentRepository = (postId: string) => + Firestore.makeRepository(CommentModel, { + collectionPath: `posts/${postId}/comments`, + idField: 'id', + pathField: 'path', + spanPrefix: 'CommentRepository', + }); + +// repo.query(...) — comments on this post +// repo.group.query(...) — comments on every post +``` + ### Writes at a known ID `add` lets Firestore pick the ID; `set` writes at an ID the caller already diff --git a/packages/admin/src/lib/firestore/firestore-service.spec.ts b/packages/admin/src/lib/firestore/firestore-service.spec.ts index a4f706b..fc2fe6f 100644 --- a/packages/admin/src/lib/firestore/firestore-service.spec.ts +++ b/packages/admin/src/lib/firestore/firestore-service.spec.ts @@ -68,8 +68,12 @@ const makeFakeDb = () => { const tx = { get: async (refOrQuery: { path: string; doc?: unknown }) => { - // Collection refs (queries) have a doc factory, document refs do not. - if (typeof refOrQuery.doc === 'function') { + // Collection refs (queries) have a doc factory, document refs do not; + // collection group refs are marked by their fake path prefix. + if ( + typeof refOrQuery.doc === 'function' || + refOrQuery.path.startsWith('group:') + ) { state.txOps.push(['query', refOrQuery.path]); return { docs: [fakeSnapshot(`${refOrQuery.path}/1`, { title: 'tx' })], @@ -113,9 +117,23 @@ const makeFakeDb = () => { }; }; + const fakeCollectionGroup = (id: string): Record => ({ + path: `group:${id}`, + get: async () => { + state.directOps.push(['queryGroup', id]); + return { + docs: [ + fakeSnapshot(`posts/p1/${id}/1`, { title: 'nested' }), + fakeSnapshot(`users/u1/${id}/2`, { title: 'nested' }), + ], + }; + }, + }); + const db = { doc: fakeDocRef, collection: fakeCollection, + collectionGroup: fakeCollectionGroup, recursiveDelete: async (ref: { path: string }) => { state.directOps.push(['recursiveDelete', ref.path]); }, @@ -201,6 +219,17 @@ describe('FirestoreService (admin)', () => { expect(results[0][1]).toEqual({ title: 'tx' }); }); + it('routes collection group queries through transaction.get', async () => { + const { db, state } = makeFakeDb(); + const results = await run( + db, + withService((fs) => fs.withTransaction(fs.queryGroup('comments', []))), + ); + + expect(state.txOps).toEqual([['query', 'group:comments']]); + expect(results).toHaveLength(1); + }); + it('returns the result of the effect', async () => { const { db } = makeFakeDb(); const result = await run( @@ -415,5 +444,35 @@ describe('FirestoreService (admin)', () => { expect(state.txOps).toEqual([]); expect(state.batchOps).toEqual([]); }); + + it('fails typed on a malformed collection group ID, effect and stream', async () => { + const { db } = makeFakeDb(); + const fromEffect = await run( + db, + withService((fs) => Effect.flip(fs.queryGroup('a/b', []))), + ); + const fromStream = await run( + db, + withService((fs) => + Effect.flip(Stream.runCollect(fs.streamQueryGroup('a/b', []))), + ), + ); + expect(fromEffect.code).toBe('invalid-argument'); + expect(fromStream.code).toBe('invalid-argument'); + }); + + it('queries a collection group across parents', async () => { + const { db, state } = makeFakeDb(); + const results = await run( + db, + withService((fs) => fs.queryGroup('comments', [])), + ); + + expect(state.directOps).toEqual([['queryGroup', 'comments']]); + expect(results.map(([ref]) => ref.path)).toEqual([ + 'posts/p1/comments/1', + 'users/u1/comments/2', + ]); + }); }); }); diff --git a/packages/admin/src/lib/firestore/firestore-service.ts b/packages/admin/src/lib/firestore/firestore-service.ts index 5bd3fa0..0be834d 100644 --- a/packages/admin/src/lib/firestore/firestore-service.ts +++ b/packages/admin/src/lib/firestore/firestore-service.ts @@ -14,6 +14,7 @@ import { FirestoreError, FirestoreService, makeSnapshotPacker, + validateCollectionId, } from 'effect-firebase'; import type { App as FirebaseAdminApp } from 'firebase-admin/app'; import type { Snapshot } from 'effect-firebase'; @@ -21,12 +22,13 @@ import { UnknownError } from 'effect/Cause'; import { getFirestore, type Firestore, + type Query, type Transaction, type WriteBatch, } from 'firebase-admin/firestore'; import { App } from '../app.js'; import { firestoreDecode, makeConverter } from './converter.js'; -import { buildQuery } from './query-builder.js'; +import { buildCollectionGroupQuery, buildQuery } from './query-builder.js'; const packSnapshot = makeSnapshotPacker(firestoreDecode); @@ -193,15 +195,14 @@ const make = (db: Firestore) => { ), ); - const streamQuery = ( - collectionPath: string, - constraints: Parameters[2], + const streamQueryOf = ( + makeQuery: () => Query, options?: Parameters[1], ) => Stream.callback, FirestoreError>((queue) => Effect.acquireRelease( Effect.sync(() => { - const query = buildQuery(db, collectionPath, constraints); + const query = makeQuery(); return query.onSnapshot( (snapshot) => { const snapshots = Arr.filterMap(snapshot.docs, (doc) => @@ -226,6 +227,39 @@ const make = (db: Firestore) => { ), ); + // The SDK throws synchronously on a malformed collection ID; validating + // up front turns that into a typed failure for both the effect and the + // stream, matching the mock. + const checkCollectionId = ( + collectionId: string, + ): Effect.Effect => { + const invalid = validateCollectionId(collectionId); + return invalid === undefined + ? Effect.void + : Effect.fail( + new FirestoreError({ + code: 'invalid-argument', + name: 'FirestoreError', + message: invalid, + }), + ); + }; + + const runQuery = (makeQuery: () => Query) => + Effect.gen(function* () { + const tx = yield* CurrentTransaction; + const snapshot = yield* Effect.tryPromise({ + try: () => { + const query = makeQuery(); + return Option.isSome(tx) ? tx.value.get(query) : query.get(); + }, + catch: (error) => mapError(error), + }); + return Arr.filterMap(snapshot.docs, (doc) => + Result.fromOption(packSnapshot(doc), () => void 0), + ); + }); + return FirestoreService.of({ get: (path, options) => Effect.gen(function* () { @@ -317,19 +351,15 @@ const make = (db: Firestore) => { ), ), query: (collectionPath, constraints) => - Effect.gen(function* () { - const tx = yield* CurrentTransaction; - const snapshot = yield* Effect.tryPromise({ - try: () => { - const query = buildQuery(db, collectionPath, constraints); - return Option.isSome(tx) ? tx.value.get(query) : query.get(); - }, - catch: (error) => mapError(error), - }); - return Arr.filterMap(snapshot.docs, (doc) => - Result.fromOption(packSnapshot(doc), () => void 0), - ); - }), + runQuery(() => buildQuery(db, collectionPath, constraints)), + queryGroup: (collectionId, constraints) => + checkCollectionId(collectionId).pipe( + Effect.flatMap(() => + runQuery(() => + buildCollectionGroupQuery(db, collectionId, constraints), + ), + ), + ), streamDoc: (path, options) => Stream.unwrap( assertNoTransaction('streamDoc').pipe( @@ -339,7 +369,24 @@ const make = (db: Firestore) => { streamQuery: (collectionPath, constraints, options) => Stream.unwrap( assertNoTransaction('streamQuery').pipe( - Effect.map(() => streamQuery(collectionPath, constraints, options)), + Effect.map(() => + streamQueryOf( + () => buildQuery(db, collectionPath, constraints), + options, + ), + ), + ), + ), + streamQueryGroup: (collectionId, constraints, options) => + Stream.unwrap( + assertNoTransaction('streamQueryGroup').pipe( + Effect.andThen(checkCollectionId(collectionId)), + Effect.map(() => + streamQueryOf( + () => buildCollectionGroupQuery(db, collectionId, constraints), + options, + ), + ), ), ), withTransaction: (self: Effect.Effect) => diff --git a/packages/admin/src/lib/firestore/query-builder.ts b/packages/admin/src/lib/firestore/query-builder.ts index 80d2311..bb38955 100644 --- a/packages/admin/src/lib/firestore/query-builder.ts +++ b/packages/admin/src/lib/firestore/query-builder.ts @@ -108,20 +108,18 @@ const isCompositeFilter = ( constraint._tag === 'And' || constraint._tag === 'Or'; /** - * Build a Firebase Admin SDK query from a collection path and constraints. + * Apply constraints to a base query (a collection or collection group). */ -export const buildQuery = ( +const applyConstraints = ( db: Firestore, - collectionPath: string, + base: Query, constraints: ReadonlyArray, ): Query => { - const collectionRef: CollectionReference = db.collection(collectionPath); - // Separate composite filters from other constraints const compositeFilters = constraints.filter(isCompositeFilter); const otherConstraints = constraints.filter((c) => !isCompositeFilter(c)); - let query: Query = collectionRef; + let query: Query = base; // Apply composite filters first (there should be at most one top-level composite) for (const filter of compositeFilters) { @@ -135,3 +133,25 @@ export const buildQuery = ( return query; }; + +/** + * Build a Firebase Admin SDK query from a collection path and constraints. + */ +export const buildQuery = ( + db: Firestore, + collectionPath: string, + constraints: ReadonlyArray, +): Query => { + const collectionRef: CollectionReference = db.collection(collectionPath); + return applyConstraints(db, collectionRef, constraints); +}; + +/** + * Build a Firebase Admin SDK collection group query from a collection ID and + * constraints. + */ +export const buildCollectionGroupQuery = ( + db: Firestore, + collectionId: string, + constraints: ReadonlyArray, +): Query => applyConstraints(db, db.collectionGroup(collectionId), constraints); diff --git a/packages/client/src/lib/firestore/firestore-service.spec.ts b/packages/client/src/lib/firestore/firestore-service.spec.ts index 92e4a51..719c21b 100644 --- a/packages/client/src/lib/firestore/firestore-service.spec.ts +++ b/packages/client/src/lib/firestore/firestore-service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, vi } from 'vitest'; -import { Cause, Data, Effect, Exit, Result } from 'effect'; +import { Cause, Data, Effect, Exit, Result, Stream } from 'effect'; import { FirestoreService } from 'effect-firebase'; import type { Firestore } from 'firebase/firestore'; @@ -107,6 +107,7 @@ vi.mock('firebase/firestore', async (importOriginal) => { ? h.fakeDocRef(path) : h.fakeDocRef(`${dbOrCollection.path}/generated-id`), collection: (_db: unknown, path: string) => h.fakeCollection(path), + collectionGroup: (_db: unknown, id: string) => ({ path: `group:${id}` }), query: (ref: unknown) => ref, getDoc: async (ref: { path: string }) => { h.state.directOps.push(['get', ref.path]); @@ -250,6 +251,17 @@ describe('FirestoreService (client)', () => { expect(Cause.hasDies(exit.cause)).toBe(true); } }); + + it('dies when querying a collection group inside a transaction', async () => { + const exit = await runExit( + withService((fs) => fs.withTransaction(fs.queryGroup('comments', []))), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + } + }); }); describe('withBatch', () => { @@ -342,5 +354,27 @@ describe('FirestoreService (client)', () => { expect(h.state.txOps).toEqual([]); expect(h.state.batchOps).toEqual([]); }); + + it('fails typed on a malformed collection group ID, effect and stream', async () => { + const fromEffect = await run( + withService((fs) => Effect.flip(fs.queryGroup('a/b', []))), + ); + const fromStream = await run( + withService((fs) => + Effect.flip(Stream.runCollect(fs.streamQueryGroup('a/b', []))), + ), + ); + expect(fromEffect.code).toBe('invalid-argument'); + expect(fromStream.code).toBe('invalid-argument'); + }); + + it('queries a collection group directly', async () => { + const results = await run( + withService((fs) => fs.queryGroup('comments', [])), + ); + + expect(h.state.directOps).toEqual([['query', 'group:comments']]); + expect(results).toHaveLength(1); + }); }); }); diff --git a/packages/client/src/lib/firestore/firestore-service.ts b/packages/client/src/lib/firestore/firestore-service.ts index 0441ee2..6459c52 100644 --- a/packages/client/src/lib/firestore/firestore-service.ts +++ b/packages/client/src/lib/firestore/firestore-service.ts @@ -10,13 +10,18 @@ import { Result, Stream, } from 'effect'; -import { FirestoreError, FirestoreService } from 'effect-firebase'; +import { + FirestoreError, + FirestoreService, + validateCollectionId, +} from 'effect-firebase'; import type { FirestoreDataOptions, Snapshot } from 'effect-firebase'; import type { FirebaseApp } from 'firebase/app'; import { doc, getFirestore, type Firestore, + type Query, type Transaction, type WriteBatch, getDoc, @@ -32,7 +37,7 @@ import { } from 'firebase/firestore'; import { App, layer as appLayer } from '../app.js'; import { firestoreDecode, makeConverter } from './converter.js'; -import { buildQuery } from './query-builder.js'; +import { buildCollectionGroupQuery, buildQuery } from './query-builder.js'; const dataOptions = (options?: FirestoreDataOptions) => ({ serverTimestamps: options?.serverTimestamps ?? 'estimate', @@ -149,15 +154,14 @@ const make = (db: Firestore) => { ), ); - const streamQuery = ( - collectionPath: string, - constraints: Parameters[2], + const streamQueryOf = ( + makeQuery: () => Query, options?: FirestoreDataOptions, ) => Stream.callback, FirestoreError>((queue) => Effect.acquireRelease( Effect.sync(() => { - const q = buildQuery(db, collectionPath, constraints); + const q = makeQuery(); return onSnapshot( q, (snapshot) => { @@ -183,6 +187,45 @@ const make = (db: Firestore) => { ), ); + // The SDK throws synchronously on a malformed collection ID; validating + // up front turns that into a typed failure for both the effect and the + // stream, matching the mock. + const checkCollectionId = ( + collectionId: string, + ): Effect.Effect => { + const invalid = validateCollectionId(collectionId); + return invalid === undefined + ? Effect.void + : Effect.fail( + new FirestoreError({ + code: 'invalid-argument', + name: 'FirestoreError', + message: invalid, + }), + ); + }; + + // The client SDK only supports document reads inside transactions. + const runQuery = (operation: string, makeQuery: () => Query) => + assertNoTransaction(operation).pipe( + Effect.flatMap(() => + Effect.tryPromise({ + try: async () => { + const snapshot = await getDocs(makeQuery()); + return Arr.filterMap(snapshot.docs, (queryDoc) => { + const data = queryDoc.data(); + if (!data) return Result.failVoid; + return Result.succeed([ + { id: queryDoc.id, path: queryDoc.ref.path }, + firestoreDecode(data), + ] as const); + }); + }, + catch: (error) => FirestoreError.fromError(error), + }), + ), + ); + return FirestoreService.of({ get: (path, options) => Effect.gen(function* () { @@ -272,24 +315,13 @@ const make = (db: Firestore) => { ), ), query: (collectionPath, constraints) => - // The client SDK only supports document reads inside transactions. - assertNoTransaction('query').pipe( + runQuery('query', () => buildQuery(db, collectionPath, constraints)), + queryGroup: (collectionId, constraints) => + checkCollectionId(collectionId).pipe( Effect.flatMap(() => - Effect.tryPromise({ - try: async () => { - const q = buildQuery(db, collectionPath, constraints); - const snapshot = await getDocs(q); - return Arr.filterMap(snapshot.docs, (queryDoc) => { - const data = queryDoc.data(); - if (!data) return Result.failVoid; - return Result.succeed([ - { id: queryDoc.id, path: queryDoc.ref.path }, - firestoreDecode(data), - ] as const); - }); - }, - catch: (error) => FirestoreError.fromError(error), - }), + runQuery('queryGroup', () => + buildCollectionGroupQuery(db, collectionId, constraints), + ), ), ), streamDoc: (path, options) => @@ -301,7 +333,24 @@ const make = (db: Firestore) => { streamQuery: (collectionPath, constraints, options) => Stream.unwrap( assertNoTransaction('streamQuery').pipe( - Effect.map(() => streamQuery(collectionPath, constraints, options)), + Effect.map(() => + streamQueryOf( + () => buildQuery(db, collectionPath, constraints), + options, + ), + ), + ), + ), + streamQueryGroup: (collectionId, constraints, options) => + Stream.unwrap( + assertNoTransaction('streamQueryGroup').pipe( + Effect.andThen(checkCollectionId(collectionId)), + Effect.map(() => + streamQueryOf( + () => buildCollectionGroupQuery(db, collectionId, constraints), + options, + ), + ), ), ), withTransaction: (self: Effect.Effect) => diff --git a/packages/client/src/lib/firestore/query-builder.ts b/packages/client/src/lib/firestore/query-builder.ts index ed10d1b..0d806a1 100644 --- a/packages/client/src/lib/firestore/query-builder.ts +++ b/packages/client/src/lib/firestore/query-builder.ts @@ -1,5 +1,6 @@ import { collection, + collectionGroup, query, where, orderBy, @@ -104,20 +105,36 @@ const toFilterConstraint = ( }; /** - * Build a Firebase Client SDK query from a collection path and constraints. + * Apply constraints to a base query (a collection or collection group). */ -export const buildQuery = ( +const applyConstraints = ( db: Firestore, - collectionPath: string, + base: Query, constraints: ReadonlyArray, ): Query => { - const collectionRef = collection(db, collectionPath); const firebaseConstraints = constraints.map((constraint) => toFirebaseConstraint(db, constraint), ); // Cast is safe - QueryCompositeFilterConstraint can be used in query() - return query( - collectionRef, - ...(firebaseConstraints as FirebaseQueryConstraint[]), - ); + return query(base, ...(firebaseConstraints as FirebaseQueryConstraint[])); }; + +/** + * Build a Firebase Client SDK query from a collection path and constraints. + */ +export const buildQuery = ( + db: Firestore, + collectionPath: string, + constraints: ReadonlyArray, +): Query => applyConstraints(db, collection(db, collectionPath), constraints); + +/** + * Build a Firebase Client SDK collection group query from a collection ID and + * constraints. + */ +export const buildCollectionGroupQuery = ( + db: Firestore, + collectionId: string, + constraints: ReadonlyArray, +): Query => + applyConstraints(db, collectionGroup(db, collectionId), constraints); diff --git a/packages/effect-firebase/AGENTS.md b/packages/effect-firebase/AGENTS.md index 5f67778..986da81 100644 --- a/packages/effect-firebase/AGENTS.md +++ b/packages/effect-firebase/AGENTS.md @@ -144,6 +144,48 @@ Repository methods (all fail with `ModelError = FirestoreError | UnknownError | | `queryStream(constraints)` | `Stream>` | Live. | | `getByQuery(constraints)` | `Effect>` | First match. | | `getByQueryStream(constraints)` | `Stream>` | Live first match. | +| `group.query(...)` etc. | as above | Same four query methods over the collection group. See below. | + +### Collection group queries + +Every repository has a `group` view with the same four query methods, run +over the **collection group** with the repository's collection ID (the last +segment of `collectionPath`): every collection with that ID at any depth. +`posts/{p}/comments` and `users/{u}/comments` are both in the `comments` +group. Set `pathField` (a `Model.GeneratedByDb(Schema.String)` field) to have +every read fill in the document's full path, so a group result can be +written back to. + +```ts +class CommentModel extends Model.Class('CommentModel')({ + id: Model.GeneratedByDb(CommentId), + path: Model.GeneratedByDb(Schema.String), // filled from the document path + body: Schema.String, + createdAt: Firestore.DateTimeInsert, +}) {} + +export const CommentRepository = (postId: string) => + Firestore.makeRepository(CommentModel, { + collectionPath: `posts/${postId}/comments`, + idField: 'id', + pathField: 'path', + spanPrefix: 'app.CommentRepository', + }); + +const program = Effect.gen(function* () { + const repo = yield* CommentRepository('p1'); + const onPost = yield* repo.query(Query.orderBy('createdAt', 'desc')); + const everywhere = yield* repo.group.query( + Query.and(Query.orderBy('createdAt', 'desc'), Query.limit(20)), + ); +}); +``` + +Firestore requires a collection-group index for fields a group query filters +or orders on; the emulator prints the `firebase` command to create one. +Cursors must be field values, not document snapshots. The raw service +methods are `FirestoreService.queryGroup(collectionId, constraints)` and +`streamQueryGroup(collectionId, constraints)`. ### Choosing `set` variant @@ -279,7 +321,7 @@ Rules: every repository/`FirestoreService` call inside the effect is routed through the ambient transaction/batch; nested calls join it. Transactions may be retried (effect must be idempotent); reads must precede writes; streams and `deleteRecursive` die inside a transaction; the **client SDK cannot `query` -inside a transaction** (document reads only). Batches are write-only (reads +or `queryGroup` inside a transaction** (document reads only). Batches are write-only (reads run immediately, do not see staged writes), max 500 writes. The mock runs both as plain pass-through. @@ -435,8 +477,8 @@ root: https://github.com/fwal/effect-firebase/blob/main/REACT.md. 2. Variants are `select`/`insert`/`update`/`json`/`jsonCreate`/`jsonUpdate` (not `get`/`add`). 3. `getById` returns `Option`; it does not fail on a missing document. -4. `deleteRecursive` is Admin-only. `query` inside a transaction is - Admin-only. +4. `deleteRecursive` is Admin-only. `query`/`queryGroup` inside a + transaction is Admin-only. 5. Pick `set`'s `variant` deliberately; the default re-stamps `createdAt`. 6. Cursor pagination: add `Query.addOrderByDocumentId()` and pass the doc id as the second cursor value when the order field can have duplicates. diff --git a/packages/effect-firebase/MIGRATION.md b/packages/effect-firebase/MIGRATION.md index 436ef44..a6f1174 100644 --- a/packages/effect-firebase/MIGRATION.md +++ b/packages/effect-firebase/MIGRATION.md @@ -279,6 +279,8 @@ Nothing was removed from repositories. `add`, `update`, `getById`, | `deleteRecursive(id)` | Delete a document and its subcollections. **Admin SDK only** — dies on the client layer. | | `getByQuery(constraints)` | First result of a query as `Option`. | | `getByQueryStream(constraints)` | Live `Stream` of the first result. | +| `group.query(...)` etc. | The four query methods over the collection group (every collection with the same ID, at any depth). | +| option `pathField` | A `GeneratedByDb(Schema.String)` field filled with each document's full path on read. | If you hand-rolled `set` through `FirestoreService.set` with a repository's encoder, replace it with `repo.set` and choose the `variant` deliberately — @@ -306,13 +308,15 @@ nested partial flattened into dotted paths instead. Only relevant if you implement `FirestoreService` yourself or pass overrides to `MockFirestoreService`. -| v0.x | v1.0 | -| --------------------- | ------------------------------------------------------- | -| `remove(path)` | `delete(path)` (**renamed**) | -| `set(path, data)` | `set(path, data, options?)` with `options.merge` | -| — | `withTransaction(effect)` (**new, required**) | -| — | `withBatch(effect)` (**new, required**) | -| `Context.Tag` service | `Context.Service` (`yield* FirestoreService` unchanged) | +| v0.x | v1.0 | +| --------------------- | --------------------------------------------------------------------------- | +| `remove(path)` | `delete(path)` (**renamed**) | +| `set(path, data)` | `set(path, data, options?)` with `options.merge` | +| — | `withTransaction(effect)` (**new, required**) | +| — | `withBatch(effect)` (**new, required**) | +| — | `queryGroup(collectionId, constraints)` (**new, required**) | +| — | `streamQueryGroup(collectionId, constraints, options?)` (**new, required**) | +| `Context.Tag` service | `Context.Service` (`yield* FirestoreService` unchanged) | The `noopLayer` and `MockFirestoreService` already implement the new members (`MockFirestoreService` runs `withTransaction`/`withBatch` as pass-through). @@ -323,6 +327,11 @@ withTransaction: (self) => self, withBatch: (self) => self, ``` +`queryGroup` / `streamQueryGroup` back `repo.group`; a custom layer that +never serves group queries can implement them by delegating to `query` / +`streamQuery` with the collection ID as the path, or fail with a +`FirestoreError` of code `unimplemented`. + Transactions and batches are exposed to application code as `Firestore.withTransaction` and `Firestore.withBatch`; every repository call inside the effect is routed through the ambient transaction/batch. Read the diff --git a/packages/effect-firebase/README.md b/packages/effect-firebase/README.md index bb593f6..44b1612 100644 --- a/packages/effect-firebase/README.md +++ b/packages/effect-firebase/README.md @@ -83,10 +83,42 @@ repo.query(constraints); // Effect> repo.queryStream(constraints); // Stream> repo.getByQuery(constraints); // Effect> repo.getByQueryStream(constraints); // Stream> +repo.group.query(constraints); // same four query methods over the collection group ``` All methods fail with `ModelError = FirestoreError | UnknownError | NoSuchElementError | SchemaError`. +### Collection groups + +Every repository has a `group` view with the same four query methods, run over the **collection group** with the repository's collection ID (the last segment of `collectionPath`) — `posts/{postId}/comments` and `users/{userId}/comments` are both part of the `comments` group. Set `pathField` to have every read fill in the document's full path. + +```typescript +class CommentModel extends Model.Class('CommentModel')({ + id: Model.GeneratedByDb(CommentId), + path: Model.GeneratedByDb(Schema.String), + body: Schema.String, + createdAt: Firestore.DateTimeInsert, +}) {} + +export const CommentRepository = (postId: string) => + Firestore.makeRepository(CommentModel, { + collectionPath: `posts/${postId}/comments`, + idField: 'id', + pathField: 'path', + spanPrefix: 'CommentRepository', + }); + +const program = Effect.gen(function* () { + const repo = yield* CommentRepository('p1'); + const onPost = yield* repo.query(Query.orderBy('createdAt', 'desc')); + const everywhere = yield* repo.group.query( + Query.orderBy('createdAt', 'desc'), + ); +}); +``` + +The underlying service methods are `FirestoreService.queryGroup` and `streamQueryGroup`. Firestore needs a collection-group index for the fields a group query filters or orders on. + ## Queries ```typescript @@ -144,7 +176,7 @@ Firestore.withTransaction( - The SDK retries the transaction on contention, so the effect may run more than once. - Firestore requires all transactional reads to happen before the first write. - Nested `withTransaction` calls join the ambient transaction. -- `streamDoc`, `streamQuery`, and `deleteRecursive` cannot be used inside a transaction; the client SDK additionally disallows `query`. +- `streamDoc`, `streamQuery`, `streamQueryGroup`, and `deleteRecursive` cannot be used inside a transaction; the client SDK additionally disallows `query` and `queryGroup`. `Firestore.withBatch` stages writes on a write batch and commits them atomically when the effect succeeds. When the effect fails, nothing is committed: diff --git a/packages/effect-firebase/src/index.ts b/packages/effect-firebase/src/index.ts index ff5ca89..12fb6e9 100644 --- a/packages/effect-firebase/src/index.ts +++ b/packages/effect-firebase/src/index.ts @@ -4,5 +4,6 @@ export * from './lib/firestore/firestore-service.js'; export * from './lib/firestore/errors.js'; export * from './lib/firestore/snapshot.js'; export * from './lib/firestore/noop-layer.js'; +export * from './lib/firestore/path.js'; export * as Query from './lib/firestore/query/index.js'; export type { QueryConstraint } from './lib/firestore/query/constraints.js'; diff --git a/packages/effect-firebase/src/lib/firestore/firestore-service.ts b/packages/effect-firebase/src/lib/firestore/firestore-service.ts index fe6e266..3f323ee 100644 --- a/packages/effect-firebase/src/lib/firestore/firestore-service.ts +++ b/packages/effect-firebase/src/lib/firestore/firestore-service.ts @@ -86,6 +86,27 @@ type FirestoreQuery = { collectionPath: string, constraints: ReadonlyArray, ) => Effect.Effect, FirestoreError | UnknownError>; + + /** + * Query a collection group: every collection in the database whose ID is + * `collectionId`, at any depth (`posts/{postId}/comments` and + * `users/{userId}/comments` both match the group `comments`). + * + * Field-based constraints work as in {@link query}; a collection group + * query on the real SDKs additionally needs a collection-group index for + * the fields it orders by or filters on. Cursor constraints must use field + * values, not document snapshots. + * + * @param collectionId - The ID (last path segment) of the collections to + * query. Must be a single segment: no `/`. + * @param constraints - The constraints to apply to the query. + * @returns A list of {@link Snapshot}s of the matching documents. Each + * snapshot's `Ref.path` tells which collection it came from. + */ + readonly queryGroup: ( + collectionId: string, + constraints: ReadonlyArray, + ) => Effect.Effect, FirestoreError | UnknownError>; }; type FirestoreStreaming = { @@ -113,6 +134,20 @@ type FirestoreStreaming = { constraints: ReadonlyArray, options?: FirestoreDataOptions, ) => Stream.Stream, FirestoreError>; + + /** + * Stream a collection group query from the Firestore database. See + * {@link FirestoreQuery.queryGroup} for what a collection group is. + * @param collectionId - The ID (last path segment) of the collections to query. + * @param constraints - The constraints to apply to the query. + * @param options - The options for the query. + * @returns A {@link https://effect.website/docs/stream/introduction/ | Stream} of a list of {@link Snapshot}s of the matching documents. + */ + readonly streamQueryGroup: ( + collectionId: string, + constraints: ReadonlyArray, + options?: FirestoreDataOptions, + ) => Stream.Stream, FirestoreError>; }; type FirestoreTransactions = { @@ -133,10 +168,11 @@ type FirestoreTransactions = { * write. Violations surface as a {@link FirestoreError} at runtime. * - Nested `withTransaction` calls join the ambient transaction instead of * starting a new one. - * - `streamDoc`, `streamQuery`, and `deleteRecursive` cannot participate in - * a transaction and cause a defect (`Effect.die`) when used inside one. - * - With the client SDK, `query` is not supported inside a transaction - * (only document reads are) and causes a defect. + * - `streamDoc`, `streamQuery`, `streamQueryGroup`, and `deleteRecursive` + * cannot participate in a transaction and cause a defect (`Effect.die`) + * when used inside one. + * - With the client SDK, `query` and `queryGroup` are not supported inside + * a transaction (only document reads are) and cause a defect. * - Forked fibers must not outlive the transaction; all transactional work * has to complete before the effect finishes. * diff --git a/packages/effect-firebase/src/lib/firestore/firestore.ts b/packages/effect-firebase/src/lib/firestore/firestore.ts index 7c7588d..3c5fa01 100644 --- a/packages/effect-firebase/src/lib/firestore/firestore.ts +++ b/packages/effect-firebase/src/lib/firestore/firestore.ts @@ -13,7 +13,13 @@ export * from './model/array.js'; export * from './model/number.js'; // Repository factory -export { makeRepository } from './model/repository.js'; +export { + makeRepository, + type Repository, + type RepositoryQueries, + type RepositoryQuery, + type StringFieldKey, +} from './model/repository.js'; export { MAX_FIELD_PATH_DEPTH, type FieldPathLeaf, diff --git a/packages/effect-firebase/src/lib/firestore/model/repository.spec.ts b/packages/effect-firebase/src/lib/firestore/model/repository.spec.ts index 6cbf341..45353dd 100644 --- a/packages/effect-firebase/src/lib/firestore/model/repository.spec.ts +++ b/packages/effect-firebase/src/lib/firestore/model/repository.spec.ts @@ -1,5 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; -import { DateTime, Effect, Layer, Option, Schema } from 'effect'; +import { + Cause, + DateTime, + Effect, + Exit, + Layer, + Option, + Schema, + Stream, +} from 'effect'; import { delete as deleteField } from '../fields/delete.js'; import { Model } from 'effect/unstable/schema'; import { makeRepository } from './repository.js'; @@ -55,8 +64,10 @@ const makeLayer = (overrides: Partial) => delete: notMocked('delete'), deleteRecursive: notMocked('deleteRecursive'), query: notMocked('query'), + queryGroup: notMocked('queryGroup'), streamDoc: notMocked('streamDoc'), streamQuery: notMocked('streamQuery'), + streamQueryGroup: notMocked('streamQueryGroup'), ...overrides, } as FirestoreServiceShape); @@ -749,4 +760,107 @@ describe('Repository', () => { expect(Option.isNone(result)).toBe(true); }); }); + + describe('group', () => { + class CommentModel extends Model.Class('CommentModel')({ + id: Model.GeneratedByDb(PostId), + path: Model.GeneratedByDb(Schema.String), + body: Schema.String, + likes: Schema.Number, + }) {} + + const groupSnap = ( + path: string, + data: Record, + ): Snapshot => [{ id: path.split('/').pop() as string, path }, data]; + + const makeCommentRepo = (overrides: Partial) => + makeRepository(CommentModel, { + collectionPath: 'posts/p1/comments', + idField: 'id', + pathField: 'path', + spanPrefix: 'test', + }).pipe(Effect.provide(makeLayer(overrides))); + + it('queries the collection group with the last path segment', async () => { + const queryGroupMock = vi.fn(() => + Effect.succeed([ + groupSnap('posts/p1/comments/c1', { body: 'First', likes: 1 }), + groupSnap('users/u1/comments/c2', { body: 'Second', likes: 2 }), + ]), + ); + const repo = await Effect.runPromise( + makeCommentRepo({ queryGroup: queryGroupMock }), + ); + const results = await Effect.runPromise(repo.group.query([])); + + expect(queryGroupMock).toHaveBeenCalledWith('comments', []); + expect(results).toEqual([ + { id: 'c1', path: 'posts/p1/comments/c1', body: 'First', likes: 1 }, + { id: 'c2', path: 'users/u1/comments/c2', body: 'Second', likes: 2 }, + ]); + }); + + it('fills pathField on collection-scoped reads too', async () => { + const queryMock = vi.fn(() => + Effect.succeed([ + groupSnap('posts/p1/comments/c1', { body: 'First', likes: 1 }), + ]), + ); + const repo = await Effect.runPromise( + makeCommentRepo({ query: queryMock }), + ); + const results = await Effect.runPromise(repo.query([])); + + expect(queryMock).toHaveBeenCalledWith('posts/p1/comments', []); + expect(results[0]).toMatchObject({ + id: 'c1', + path: 'posts/p1/comments/c1', + }); + }); + + it('streams through streamQueryGroup', async () => { + const streamMock = vi.fn(() => + Stream.make([ + groupSnap('posts/p1/comments/c1', { body: 'First', likes: 1 }), + ]), + ); + const repo = await Effect.runPromise( + makeCommentRepo({ streamQueryGroup: streamMock }), + ); + const first = await Effect.runPromise( + Stream.runHead(repo.group.getByQueryStream([])), + ); + + expect(streamMock).toHaveBeenCalledWith('comments', []); + expect(Option.getOrThrow(Option.flatten(first))).toMatchObject({ + id: 'c1', + path: 'posts/p1/comments/c1', + }); + }); + + it('rejects a non-string pathField at compile time', () => { + makeRepository(CommentModel, { + collectionPath: 'comments', + idField: 'id', + // @ts-expect-error likes is a number field + pathField: 'likes', + spanPrefix: 'test', + }); + }); + + it('dies on a collection path with an empty last segment', async () => { + const exit = await Effect.runPromiseExit( + makeRepository(CommentModel, { + collectionPath: 'posts/p1/comments/', + idField: 'id', + spanPrefix: 'test', + }).pipe(Effect.provide(makeLayer({}))), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + } + }); + }); }); diff --git a/packages/effect-firebase/src/lib/firestore/model/repository.ts b/packages/effect-firebase/src/lib/firestore/model/repository.ts index 9b149a9..d8a4985 100644 --- a/packages/effect-firebase/src/lib/firestore/model/repository.ts +++ b/packages/effect-firebase/src/lib/firestore/model/repository.ts @@ -1,6 +1,7 @@ import { Array as Arr, Effect, Option, Schema, Stream, Struct } from 'effect'; import { Model } from 'effect/unstable/schema'; import { FirestoreService } from '../firestore-service.js'; +import { collectionIdOf, validateCollectionId } from '../path.js'; import { Snapshot } from '../snapshot.js'; import { NoSuchElementError, UnknownError } from 'effect/Cause'; import { FirestoreError } from '../errors.js'; @@ -67,6 +68,64 @@ export type SetWrite = readonly merge?: boolean; }; +/** + * The query surface of a {@link Repository}, available both on the + * repository itself and on its collection group view `repo.group`. + */ +export type RepositoryQueries = { + /** + * Query the database. + * @param constraints - The constraints to apply to the query. + * @returns A list of the results of the query. + */ + readonly query: ( + constraints: RepositoryQuery, + ) => Effect.Effect< + ReadonlyArray, + ModelError, + S['DecodingServices'] | S['EncodingServices'] + >; + + /** + * Stream the results of a query. + * @param constraints - The constraints to apply to the query. + * @returns A {@link https://effect.website/docs/stream/introduction/ | Stream} of the results of the query. + */ + readonly queryStream: ( + constraints: RepositoryQuery, + ) => Stream.Stream< + ReadonlyArray, + ModelError, + S['DecodingServices'] | S['EncodingServices'] + >; + + /** + * Query the database and return the first result. + * @param constraints - The constraints to apply to the query. + * @returns The first result of the query, or `None` if no results. + */ + readonly getByQuery: ( + constraints: RepositoryQuery, + ) => Effect.Effect< + Option.Option, + ModelError, + S['DecodingServices'] | S['EncodingServices'] + >; + + /** + * Stream the first result of a query. + * @param constraints - The constraints to apply to the query. + * @returns A {@link https://effect.website/docs/stream/introduction/ | Stream} of the first result and any updates to it. + */ + readonly getByQueryStream: ( + constraints: RepositoryQuery, + ) => Stream.Stream< + Option.Option, + ModelError, + S['DecodingServices'] | S['EncodingServices'] + >; +}; + export type Repository< S extends Model.Any, Id extends keyof S['Type'] & keyof S['fields'], @@ -245,59 +304,30 @@ export type Repository< ModelError, S['DecodingServices'] | S['EncodingServices'] >; +} & RepositoryQueries & { + /** + * The same four query methods, run over the **collection group** with this + * repository's collection ID (the last segment of `collectionPath`): every + * collection with that ID, at any depth. A repository over + * `posts/{postId}/comments` therefore reads comments across all posts via + * `repo.group.query(...)`. + * + * Firestore requires a collection-group index for the fields a group query + * filters or orders on. Set `pathField` on the repository to learn which + * collection each result came from. + */ + readonly group: RepositoryQueries; + }; - /** - * Query the database. - * @param constraints - The constraints to apply to the query. - * @returns A list of the results of the query. - */ - readonly query: ( - constraints: RepositoryQuery, - ) => Effect.Effect< - ReadonlyArray, - ModelError, - S['DecodingServices'] | S['EncodingServices'] - >; - - /** - * Stream the results of a query. - * @param constraints - The constraints to apply to the query. - * @returns A {@link https://effect.website/docs/stream/introduction/ | Stream} of the results of the query. - */ - readonly queryStream: ( - constraints: RepositoryQuery, - ) => Stream.Stream< - ReadonlyArray, - ModelError, - S['DecodingServices'] | S['EncodingServices'] - >; - - /** - * Query the database and return the first result. - * @param constraints - The constraints to apply to the query. - * @returns The first result of the query, or `None` if no results. - */ - readonly getByQuery: ( - constraints: RepositoryQuery, - ) => Effect.Effect< - Option.Option, - ModelError, - S['DecodingServices'] | S['EncodingServices'] - >; - - /** - * Stream the first result of a query. - * @param constraints - The constraints to apply to the query. - * @returns A {@link https://effect.website/docs/stream/introduction/ | Stream} of the first result and any updates to it. - */ - readonly getByQueryStream: ( - constraints: RepositoryQuery, - ) => Stream.Stream< - Option.Option, - ModelError, - S['DecodingServices'] | S['EncodingServices'] - >; -}; +/** + * The keys of `S` whose field schema is a string, so a path field can be + * filled with a document path. + */ +export type StringFieldKey = { + [ + K in keyof S['fields'] & keyof S['Type'] + ]: S['fields'][K] extends Schema.String ? K : never; +}[keyof S['fields'] & keyof S['Type']]; /** * Create a repository for a document model. @@ -331,6 +361,23 @@ export type Repository< * * const posts = yield* PostRepository.query(Query.orderBy('createdAt', 'desc')); * ``` + * + * @example + * ```ts + * // A repository over a subcollection can read across every parent through + * // its collection group view. + * const CommentRepository = (postId: string) => + * Firestore.makeRepository(CommentModel, { + * collectionPath: `posts/${postId}/comments`, + * idField: 'id', + * pathField: 'path', + * spanPrefix: 'example.CommentRepository', + * }); + * + * const repo = yield* CommentRepository('p1'); + * const mine = yield* repo.query(Query.orderBy('createdAt', 'desc')); + * const everywhere = yield* repo.group.query(Query.orderBy('createdAt', 'desc')); + * ``` */ export const makeRepository = < S extends Model.Any, @@ -343,17 +390,39 @@ export const makeRepository = < options: { readonly collectionPath: string; readonly idField: Id; + /** + * A string field to fill with each document's full path on read (for + * example `posts/p1/comments/c1`). Declare it as + * `Model.GeneratedByDb(Schema.String)` so it is never part of a write + * payload. Mostly useful together with {@link Repository.group}, whose + * results span many parent documents. + */ + readonly pathField?: StringFieldKey; readonly spanPrefix: string; }, ): Effect.Effect, never, FirestoreService> => Effect.gen(function* () { const firestore = yield* FirestoreService; + const collectionId = collectionIdOf(options.collectionPath); + const invalidId = validateCollectionId(collectionId); + if (invalidId !== undefined) { + return yield* Effect.die( + new Error(`${options.spanPrefix}: ${invalidId}`), + ); + } + const idSchema = Model.fields[options.idField] as unknown as IdSchema; const structFromSnapshot = (snapshot: Snapshot) => { const [ref, data] = snapshot; - return { ...data, [options.idField]: ref.id }; + return { + ...data, + [options.idField]: ref.id, + ...(options.pathField === undefined + ? {} + : { [options.pathField]: ref.path }), + }; }; const addSchema = Fetch.findOne({ @@ -586,23 +655,6 @@ export const makeRepository = < }), ); - const querySchema = Fetch.findAll({ - Request: Schema.Array(Schema.Any), - Result: Model, - execute: (constraints: ReadonlyArray) => - firestore - .query( - options.collectionPath, - constraints as ReadonlyArray, - ) - .pipe(Effect.map((snapshots) => snapshots.map(structFromSnapshot))), - }); - - const query = (constraints: RepositoryQuery) => - querySchema(constraints as ReadonlyArray).pipe( - Effect.withSpan(`${options.spanPrefix}.query`, {}), - ); - const getByIdStreamSchema = Fetch.streamOne({ Request: idSchema, Result: Model, @@ -619,63 +671,25 @@ export const makeRepository = < ), ); - const queryStreamSchema = Fetch.streamAll({ - Request: Schema.Array(Schema.Any), - Result: Model, - execute: (constraints: ReadonlyArray) => - firestore - .streamQuery( - options.collectionPath, - constraints as ReadonlyArray, - ) - .pipe(Stream.map((snapshots) => snapshots.map(structFromSnapshot))), + const queries = makeQueries({ + Model, + spanPrefix: options.spanPrefix, + structFromSnapshot, + query: (constraints) => + firestore.query(options.collectionPath, constraints), + streamQuery: (constraints) => + firestore.streamQuery(options.collectionPath, constraints), }); - const queryStream = (constraints: RepositoryQuery) => - queryStreamSchema(constraints as ReadonlyArray).pipe( - Stream.tap(() => Effect.logTrace(`${options.spanPrefix}.streamQuery`)), - ); - - const getByQuerySchema = Fetch.findOneOption({ - Request: Schema.Array(Schema.Any), - Result: Model, - execute: (constraints: ReadonlyArray) => - firestore - .query( - options.collectionPath, - constraints as ReadonlyArray, - ) - .pipe(Effect.map((snapshots) => snapshots.map(structFromSnapshot))), + const group = makeQueries({ + Model, + spanPrefix: `${options.spanPrefix}.group`, + structFromSnapshot, + query: (constraints) => firestore.queryGroup(collectionId, constraints), + streamQuery: (constraints) => + firestore.streamQueryGroup(collectionId, constraints), }); - const getByQuery = (constraints: RepositoryQuery) => - getByQuerySchema(constraints as ReadonlyArray).pipe( - Effect.withSpan(`${options.spanPrefix}.getByQuery`, {}), - ); - - const getByQueryStreamSchema = Fetch.streamOne({ - Request: Schema.Array(Schema.Any), - Result: Model, - execute: (constraints: ReadonlyArray) => - firestore - .streamQuery( - options.collectionPath, - constraints as ReadonlyArray, - ) - .pipe( - Stream.map((snapshots) => - Arr.head(snapshots.map(structFromSnapshot)), - ), - ), - }); - - const getByQueryStream = (constraints: RepositoryQuery) => - getByQueryStreamSchema(constraints as ReadonlyArray).pipe( - Stream.tap(() => - Effect.logTrace(`${options.spanPrefix}.getByQueryStream`), - ), - ); - return { add, set, @@ -684,9 +698,85 @@ export const makeRepository = < getByIdStream, delete: deleteById, deleteRecursive: deleteRecursiveById, - query, - queryStream, - getByQuery, - getByQueryStream, + ...queries, + group, }; }); + +/** + * Build the four query methods over a pair of raw query/stream functions. + * Used for both the collection-scoped methods of a {@link Repository} and + * its {@link Repository.group} view. + */ +const makeQueries = (options: { + readonly Model: S; + readonly spanPrefix: string; + readonly structFromSnapshot: (snapshot: Snapshot) => Record; + readonly query: ( + constraints: ReadonlyArray, + ) => Effect.Effect, FirestoreError | UnknownError>; + readonly streamQuery: ( + constraints: ReadonlyArray, + ) => Stream.Stream, FirestoreError>; +}): RepositoryQueries => { + const { Model, spanPrefix, structFromSnapshot } = options; + + const runQuery = (constraints: ReadonlyArray) => + options + .query(constraints as ReadonlyArray) + .pipe(Effect.map((snapshots) => snapshots.map(structFromSnapshot))); + + const runStreamQuery = (constraints: ReadonlyArray) => + options + .streamQuery(constraints as ReadonlyArray) + .pipe(Stream.map((snapshots) => snapshots.map(structFromSnapshot))); + + const querySchema = Fetch.findAll({ + Request: Schema.Array(Schema.Any), + Result: Model, + execute: runQuery, + }); + + const query = (constraints: RepositoryQuery) => + querySchema(constraints as ReadonlyArray).pipe( + Effect.withSpan(`${spanPrefix}.query`, {}), + ); + + const queryStreamSchema = Fetch.streamAll({ + Request: Schema.Array(Schema.Any), + Result: Model, + execute: runStreamQuery, + }); + + const queryStream = (constraints: RepositoryQuery) => + queryStreamSchema(constraints as ReadonlyArray).pipe( + Stream.tap(() => Effect.logTrace(`${spanPrefix}.streamQuery`)), + ); + + const getByQuerySchema = Fetch.findOneOption({ + Request: Schema.Array(Schema.Any), + Result: Model, + execute: runQuery, + }); + + const getByQuery = (constraints: RepositoryQuery) => + getByQuerySchema(constraints as ReadonlyArray).pipe( + Effect.withSpan(`${spanPrefix}.getByQuery`, {}), + ); + + const getByQueryStreamSchema = Fetch.streamOne({ + Request: Schema.Array(Schema.Any), + Result: Model, + execute: (constraints: ReadonlyArray) => + runStreamQuery(constraints).pipe( + Stream.map((structs) => Arr.head(structs)), + ), + }); + + const getByQueryStream = (constraints: RepositoryQuery) => + getByQueryStreamSchema(constraints as ReadonlyArray).pipe( + Stream.tap(() => Effect.logTrace(`${spanPrefix}.getByQueryStream`)), + ); + + return { query, queryStream, getByQuery, getByQueryStream }; +}; diff --git a/packages/effect-firebase/src/lib/firestore/noop-layer.ts b/packages/effect-firebase/src/lib/firestore/noop-layer.ts index 0ebdb01..d3167ef 100644 --- a/packages/effect-firebase/src/lib/firestore/noop-layer.ts +++ b/packages/effect-firebase/src/lib/firestore/noop-layer.ts @@ -22,8 +22,10 @@ export const noopLayer = Layer.succeed(FirestoreService, { delete: NotInitiallized, deleteRecursive: NotInitiallized, query: NotInitiallized, + queryGroup: NotInitiallized, streamDoc: NotInitiallized, streamQuery: NotInitiallized, + streamQueryGroup: NotInitiallized, withTransaction: NotInitiallized, withBatch: NotInitiallized, }); diff --git a/packages/effect-firebase/src/lib/firestore/path.ts b/packages/effect-firebase/src/lib/firestore/path.ts new file mode 100644 index 0000000..2b42a87 --- /dev/null +++ b/packages/effect-firebase/src/lib/firestore/path.ts @@ -0,0 +1,21 @@ +/** + * Validate a collection ID (a single path segment, as used by collection + * group queries), returning an error message when it is malformed. + * + * Shared by the repository factory and every backend so the rule and its + * message stay in one place. + */ +export const validateCollectionId = ( + collectionId: string, +): string | undefined => + collectionId.length > 0 && !collectionId.includes('/') + ? undefined + : `Invalid collection ID '${collectionId}': expected a single non-empty path segment`; + +/** + * The collection ID (final segment) of a collection path. + */ +export const collectionIdOf = (collectionPath: string): string => { + const segments = collectionPath.split('/'); + return segments[segments.length - 1]; +}; diff --git a/packages/mock/README.md b/packages/mock/README.md index 369fe5d..4fbddf6 100644 --- a/packages/mock/README.md +++ b/packages/mock/README.md @@ -8,7 +8,7 @@ Beyond a plain test double, the mock is a small simulated backend built for **de - **Reactive streams** — `streamDoc` / `streamQuery` are live: writes and runtime toggles push new emissions through already-subscribed streams, just like `onSnapshot`. - **Simulated states** — flip any collection between `data`, `empty`, `loading` and `error` at runtime with the `MockController`, and watch your UI's spinner, empty and error paths render with no backend involved. - **Latency simulation** — add artificial delay to every operation. -- **Write fidelity** — server timestamps materialize on write, `delete`/`arrayUnion`/`arrayRemove`/`increment` sentinels are honored, and queries (where, orderBy, cursors, limits) are evaluated in-process. +- **Write fidelity** — server timestamps materialize on write, `delete`/`arrayUnion`/`arrayRemove`/`increment` sentinels are honored, and queries (where, orderBy, cursors, limits, collection groups) are evaluated in-process. ## Installation @@ -123,6 +123,15 @@ const mock = layer({ }); ``` +A collection group query (`queryGroup` / `streamQueryGroup`, or a +repository's `group` view) resolves its state by collection ID, so +`states: { comments: 'loading' }` covers both a top-level `comments` +collection and the `comments` group across every parent. As in Firestore, +a `__name__` cursor (`Query.orderByDocumentId`) in a group query must be a +full document path (`posts/p1/comments/c1`); a bare ID fails with +`invalid-argument`, as it does on the real SDKs. A single-collection query +takes a bare ID. + ## Driving the backend from outside Effect `make()` returns a handle instead of just a layer: the same options as `layer()`, plus direct access to the controller as a plain value. Every controller effect requires no services, so React components, Storybook decorators or test helpers can run them with `Effect.runPromise` directly. This is what the [`@effect-firebase/devtools`](../devtools) panel builds on: @@ -174,7 +183,7 @@ await Effect.runPromise( - In-memory only — no persistence between process restarts - Queries are evaluated in-process — behaviour may differ from real Firestore for edge cases (composite index requirements are not enforced, `not-in`/`!=` null semantics are simplified) -- Simulated states are keyed per collection path (or the `'*'` wildcard), not per query +- Simulated states are keyed per collection path (or the `'*'` wildcard), not per query; collection group queries resolve their state by collection ID - No security rules evaluation - `withTransaction` and `withBatch` run the effect directly — no retries, no rollback, and no staged writes - No multi-client synchronization diff --git a/packages/mock/src/lib/firestore/firestore-service.ts b/packages/mock/src/lib/firestore/firestore-service.ts index 3711e55..022a12d 100644 --- a/packages/mock/src/lib/firestore/firestore-service.ts +++ b/packages/mock/src/lib/firestore/firestore-service.ts @@ -31,12 +31,18 @@ export const MockFirestoreService = ( query: () => { throw new Error('MockFirestoreService.query not implemented.'); }, + queryGroup: () => { + throw new Error('MockFirestoreService.queryGroup not implemented.'); + }, streamDoc: () => { throw new Error('MockFirestoreService.streamDoc not implemented.'); }, streamQuery: () => { throw new Error('MockFirestoreService.streamQuery not implemented.'); }, + streamQueryGroup: () => { + throw new Error('MockFirestoreService.streamQueryGroup not implemented.'); + }, // The mock has no concurrency or staging semantics, so transactions and // batches simply run the effect: reads and writes hit the overridden // methods directly. diff --git a/packages/mock/src/lib/firestore/layer.spec.ts b/packages/mock/src/lib/firestore/layer.spec.ts index a366116..74821da 100644 --- a/packages/mock/src/lib/firestore/layer.spec.ts +++ b/packages/mock/src/lib/firestore/layer.spec.ts @@ -68,7 +68,174 @@ const awaitLength = (collected: ReadonlyArray, length: number) => } }); +const commentFixture = rawFixture('comments', { + top: { body: 'top-level', likes: 1 }, +}); + +const nestedComments = rawFixture('posts/1/comments', { + a: { body: 'on post 1', likes: 5 }, + b: { body: 'also on post 1', likes: 2 }, +}); + +const userComments = rawFixture('users/u1/comments', { + c: { body: 'on user u1', likes: 9 }, +}); + describe('layer', () => { + describe('collection groups', () => { + it('queries every collection with the ID, at any depth', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const results = yield* firestore.queryGroup('comments', [ + new Query.OrderBy({ field: 'likes', direction: 'desc' }), + ]); + expect(results.map(([ref]) => ref.path)).toEqual([ + 'users/u1/comments/c', + 'posts/1/comments/a', + 'posts/1/comments/b', + 'comments/top', + ]); + // A regular query stays scoped to one collection. + const only = yield* firestore.query('posts/1/comments', []); + expect(only.map(([ref]) => ref.id)).toEqual(['a', 'b']); + }), + { + fixtures: [postFixture, commentFixture, nestedComments, userComments], + }, + )); + + it('ignores collections that merely contain the ID', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + yield* firestore.set('comments-archive/x', { body: 'nope' }); + yield* firestore.set('posts/1/comment/y', { body: 'nope' }); + const results = yield* firestore.queryGroup('comments', []); + expect(results.map(([ref]) => ref.path)).toEqual([ + 'posts/1/comments/a', + 'posts/1/comments/b', + ]); + }), + { fixtures: [nestedComments] }, + )); + + it('rejects a collection ID with a slash', async () => { + const error = await run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + return yield* Effect.flip(firestore.queryGroup('posts/comments', [])); + }), + ); + expect((error as FirestoreError).code).toBe('invalid-argument'); + }); + + it('rejects a bare-ID cursor on a group query, effect and stream', async () => { + const [fromEffect, fromStream] = await run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const constraints = [ + ...Query.orderByDocumentId('asc'), + new Query.StartAfter({ values: ['a'] }), + ]; + const effectError = yield* Effect.flip( + firestore.queryGroup('comments', constraints), + ); + const streamError = yield* Effect.flip( + Stream.runCollect( + firestore.streamQueryGroup('comments', constraints), + ), + ); + // A full path pages as expected. + const paged = yield* firestore.queryGroup('comments', [ + ...Query.orderByDocumentId('asc'), + new Query.StartAfter({ values: ['posts/1/comments/a'] }), + ]); + expect(paged.map(([ref]) => ref.path)).toEqual([ + 'posts/1/comments/b', + 'users/u1/comments/c', + ]); + return [effectError, streamError] as const; + }), + { fixtures: [nestedComments, userComments] }, + ); + expect((fromEffect as FirestoreError).code).toBe('invalid-argument'); + expect((fromStream as FirestoreError).code).toBe('invalid-argument'); + }); + + it('resolves the simulated state by collection ID', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const controller = yield* MockController; + yield* controller.setState('comments', 'empty'); + expect(yield* firestore.queryGroup('comments', [])).toEqual([]); + yield* controller.setState('comments', 'data'); + expect(yield* firestore.queryGroup('comments', [])).toHaveLength(2); + }), + { fixtures: [nestedComments] }, + )); + + it('streams live results across parents', () => + run( + Effect.gen(function* () { + const firestore = yield* FirestoreService; + const emissions: Array> = []; + + const fiber = yield* Effect.forkChild( + Stream.runForEach( + firestore.streamQueryGroup('comments', []), + (snapshots) => + Effect.sync(() => { + emissions.push(snapshots); + }), + ), + ); + + yield* awaitLength(emissions, 1); + expect(emissions[0].length).toBe(2); + + yield* firestore.add('users/u2/comments', { body: 'new', likes: 0 }); + yield* awaitLength(emissions, 2); + expect(emissions[1].length).toBe(3); + + yield* Fiber.interrupt(fiber); + }), + { fixtures: [nestedComments] }, + )); + + it('drives a repository group view', () => + run( + Effect.gen(function* () { + class Comment extends Model.Class('Comment')({ + id: Model.GeneratedByDb(Schema.String), + path: Model.GeneratedByDb(Schema.String), + body: Schema.String, + likes: Schema.Number, + }) {} + const repo = yield* Firestore.makeRepository(Comment, { + collectionPath: 'posts/1/comments', + idField: 'id', + pathField: 'path', + spanPrefix: 'test.CommentRepository', + }); + const scoped = yield* repo.query([]); + expect(scoped.map((c) => c.id)).toEqual(['a', 'b']); + + const top = yield* repo.group.getByQuery([ + new Query.OrderBy({ field: 'likes', direction: 'desc' }), + ]); + expect(Option.isSome(top)).toBe(true); + expect((top as Option.Some).value).toMatchObject({ + id: 'c', + path: 'users/u1/comments/c', + likes: 9, + }); + }), + { fixtures: [nestedComments, userComments] }, + )); + }); + describe('CRUD', () => { it('adds, reads, updates and deletes documents', () => run( diff --git a/packages/mock/src/lib/firestore/layer.ts b/packages/mock/src/lib/firestore/layer.ts index cecd26b..9d32487 100644 --- a/packages/mock/src/lib/firestore/layer.ts +++ b/packages/mock/src/lib/firestore/layer.ts @@ -17,15 +17,18 @@ import { FirestoreService, Snapshot, type FirestoreServiceShape, + type QueryConstraint, } from 'effect-firebase'; import { MockController, type MockControllerShape } from './controller.js'; -import { applyConstraints } from './query-filter.js'; +import { applyConstraints, validateGroupCursors } from './query-filter.js'; import type { Fixture } from './fixture.js'; import * as MockState from './state.js'; import { docsInCollection, + docsInCollectionGroup, makeSnapshot, parentPath, + validateCollectionId, validateCollectionPath, validateDocPath, type StoreSnapshot, @@ -178,6 +181,58 @@ const makeFirestore = ( ); }); + const runQuery = ( + stateKey: string, + constraints: ReadonlyArray, + select: ( + docs: Readonly>, + ) => ReadonlyArray, + ): Effect.Effect, FirestoreError> => + Effect.gen(function* () { + yield* sleep; + const state = yield* guard(stateKey); + if (state._tag === 'Empty') { + return []; + } + const snapshot = yield* SubscriptionRef.get(ref); + return applyConstraints(select(snapshot.docs), constraints); + }); + + const streamQueryOf = ( + stateKey: string, + constraints: ReadonlyArray, + select: ( + docs: Readonly>, + ) => ReadonlyArray, + ): Stream.Stream, FirestoreError> => + Stream.unwrap( + Effect.as( + sleep, + SubscriptionRef.changes(ref).pipe( + Stream.switchMap( + ( + snapshot, + ): Stream.Stream, FirestoreError> => { + const state = MockState.resolve(snapshot.states, stateKey); + switch (state._tag) { + case 'Loading': + return Stream.never; + case 'Error': + return Stream.fail(state.error); + case 'Empty': + return Stream.succeed([]); + case 'Data': + return Stream.succeed( + applyConstraints(select(snapshot.docs), constraints), + ); + } + }, + ), + Stream.changesWith(snapshotsEqual), + ), + ), + ); + return { get: (path) => readDoc(path), @@ -250,15 +305,20 @@ const makeFirestore = ( query: (collectionPath, constraints) => Effect.gen(function* () { yield* validate(validateCollectionPath(collectionPath)); - yield* sleep; - const state = yield* guard(collectionPath); - if (state._tag === 'Empty') { - return []; - } - const snapshot = yield* SubscriptionRef.get(ref); - return applyConstraints( - docsInCollection(snapshot.docs, collectionPath), - constraints, + return yield* runQuery(collectionPath, constraints, (docs) => + docsInCollection(docs, collectionPath), + ); + }), + + // A collection group query resolves its simulated state by collection ID, + // so `states: { comments: 'loading' }` covers both the top-level + // `comments` collection and the `comments` group. + queryGroup: (collectionId, constraints) => + Effect.gen(function* () { + yield* validate(validateCollectionId(collectionId)); + yield* validate(validateGroupCursors(constraints)); + return yield* runQuery(collectionId, constraints, (docs) => + docsInCollectionGroup(docs, collectionId), ); }), @@ -309,38 +369,19 @@ const makeFirestore = ( if (invalid !== undefined) { return Stream.fail(invalidArgument(invalid)); } - return Stream.unwrap( - Effect.as( - sleep, - SubscriptionRef.changes(ref).pipe( - Stream.switchMap( - ( - snapshot, - ): Stream.Stream, FirestoreError> => { - const state = MockState.resolve( - snapshot.states, - collectionPath, - ); - switch (state._tag) { - case 'Loading': - return Stream.never; - case 'Error': - return Stream.fail(state.error); - case 'Empty': - return Stream.succeed([]); - case 'Data': - return Stream.succeed( - applyConstraints( - docsInCollection(snapshot.docs, collectionPath), - constraints, - ), - ); - } - }, - ), - Stream.changesWith(snapshotsEqual), - ), - ), + return streamQueryOf(collectionPath, constraints, (docs) => + docsInCollection(docs, collectionPath), + ); + }, + + streamQueryGroup: (collectionId, constraints) => { + const invalid = + validateCollectionId(collectionId) ?? validateGroupCursors(constraints); + if (invalid !== undefined) { + return Stream.fail(invalidArgument(invalid)); + } + return streamQueryOf(collectionId, constraints, (docs) => + docsInCollectionGroup(docs, collectionId), ); }, diff --git a/packages/mock/src/lib/firestore/query-filter.spec.ts b/packages/mock/src/lib/firestore/query-filter.spec.ts index 199bd4b..2eb67df 100644 --- a/packages/mock/src/lib/firestore/query-filter.spec.ts +++ b/packages/mock/src/lib/firestore/query-filter.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { Query, Snapshot } from 'effect-firebase'; -import { applyConstraints } from './query-filter.js'; +import { applyConstraints, validateGroupCursors } from './query-filter.js'; const snap = (id: string, data: Record): Snapshot => [ { id, path: `posts/${id}` }, @@ -222,6 +222,121 @@ describe('applyConstraints', () => { ).toEqual(['2', '4']); }); + it('orders and pages a collection group by full path, not bare ID', () => { + // Same IDs under different parents: Firestore compares the full + // reference, so `posts/p1/comments/c1` sorts before `users/u1/comments/c1` + // and neither is dropped or treated as a duplicate. + const group: ReadonlyArray = [ + [{ id: 'c1', path: 'users/u1/comments/c1' }, { likes: 1 }], + [{ id: 'c1', path: 'posts/p1/comments/c1' }, { likes: 1 }], + [{ id: 'c2', path: 'posts/p1/comments/c2' }, { likes: 1 }], + ]; + const paths = (results: ReadonlyArray) => + results.map(([ref]) => ref.path); + + expect(paths(applyConstraints(group, []))).toEqual([ + 'posts/p1/comments/c1', + 'posts/p1/comments/c2', + 'users/u1/comments/c1', + ]); + expect( + paths(applyConstraints(group, Query.orderByDocumentId('desc'))), + ).toEqual([ + 'users/u1/comments/c1', + 'posts/p1/comments/c2', + 'posts/p1/comments/c1', + ]); + // A group cursor on __name__ is a full document path. + expect( + paths( + applyConstraints(group, [ + ...Query.orderByDocumentId('asc'), + new Query.StartAfter({ values: ['posts/p1/comments/c1'] }), + ]), + ), + ).toEqual(['posts/p1/comments/c2', 'users/u1/comments/c1']); + // The implicit tiebreaker pages past equal field values the same way. + expect( + paths( + applyConstraints(group, [ + new Query.OrderBy({ field: 'likes', direction: 'asc' }), + new Query.StartAfter({ values: [1, 'posts/p1/comments/c2'] }), + ]), + ), + ).toEqual(['users/u1/comments/c1']); + }); + + it('rejects bare-ID name cursors for collection group queries', () => { + const named = Query.orderByDocumentId('asc'); + expect( + validateGroupCursors([ + ...named, + new Query.StartAfter({ values: ['c1'] }), + ]), + ).toMatch(/full document path/); + // The implicit tiebreaker position is checked too. + expect( + validateGroupCursors([ + new Query.OrderBy({ field: 'likes', direction: 'asc' }), + new Query.StartAfter({ values: [1, 'c1'] }), + ]), + ).toMatch(/full document path/); + // Odd segment counts name a collection, not a document. + expect( + validateGroupCursors([ + ...named, + new Query.EndAt({ values: ['posts/p1/comments'] }), + ]), + ).toMatch(/full document path/); + expect( + validateGroupCursors([ + ...named, + new Query.StartAfter({ values: ['posts/p1/comments/c1'] }), + ]), + ).toBeUndefined(); + // Field-only cursors are unaffected. + expect( + validateGroupCursors([ + new Query.OrderBy({ field: 'likes', direction: 'asc' }), + new Query.StartAfter({ values: [1] }), + ]), + ).toBeUndefined(); + }); + + it('rejects cursors with more values than the query orders by', () => { + // One value per orderBy plus the implicit document-name tiebreaker is the + // most Firestore accepts; more than that is an invalid cursor, not a + // crash. + expect( + validateGroupCursors([ + new Query.OrderBy({ field: 'likes', direction: 'asc' }), + new Query.StartAfter({ + values: [1, 'posts/p1/comments/c1', 'extra'], + }), + ]), + ).toMatch(/Too many cursor values/); + expect( + validateGroupCursors([new Query.StartAfter({ values: ['a', 'b'] })]), + ).toMatch(/Too many cursor values/); + // An explicit __name__ ordering is the document-name position, so no + // implicit one is appended and a second value is already too many. + expect( + validateGroupCursors([ + ...Query.orderByDocumentId('asc'), + new Query.StartAfter({ + values: ['posts/p1/comments/c1', 'posts/p1/comments/c2'], + }), + ]), + ).toMatch(/Too many cursor values/); + expect( + validateGroupCursors([ + new Query.OrderBy({ field: 'likes', direction: 'asc' }), + ...Query.orderByDocumentId('asc'), + new Query.StartAfter({ values: [1, 'posts/p1/comments/c1'] }), + ]), + ).toBeUndefined(); + }); + it('applies cursors relative to orderBy values', () => { const ordered = [new Query.OrderBy({ field: 'views', direction: 'asc' })]; expect( diff --git a/packages/mock/src/lib/firestore/query-filter.ts b/packages/mock/src/lib/firestore/query-filter.ts index c50d73a..9c3d9d2 100644 --- a/packages/mock/src/lib/firestore/query-filter.ts +++ b/packages/mock/src/lib/firestore/query-filter.ts @@ -86,20 +86,117 @@ const matchesFilter = (data: DocData, filter: Filter): boolean => { } }; +/** + * Whether the orderBy at `index` is the document-name position: an explicit + * `__name__` orderBy, or the implicit tiebreaker Firestore appends after the + * last explicit one. + */ +const isNamePosition = ( + orderBys: ReadonlyArray, + index: number, +): boolean => + index === orderBys.length || + orderBys[index]?.field === Query.documentIdFieldPath; + const orderValues = ( snapshot: Snapshot, orderBys: ReadonlyArray, ): ReadonlyArray => { const [ref, data] = snapshot; - // The __name__ sentinel (Query.orderByDocumentId) resolves to the - // document ID, which lives on the ref rather than in the data. + // The __name__ sentinel (Query.orderByDocumentId) resolves to the full + // document reference, so documents with the same ID under different + // parents (as in a collection group) still order deterministically. const values = orderBys.map((orderBy) => orderBy.field === Query.documentIdFieldPath - ? ref.id + ? ref.path : fieldValue(data, orderBy.field), ); - // Firestore implicitly orders by document ID as the final tiebreaker. - return [...values, ref.id]; + // Firestore implicitly orders by document name as the final tiebreaker. + return [...values, ref.path]; +}; + +/** + * Collect the orderBys and cursor arrays out of a constraint list. + */ +const cursorsOf = ( + constraints: ReadonlyArray, +): { + readonly orderBys: ReadonlyArray; + readonly cursors: ReadonlyArray>; +} => { + const orderBys: Array = []; + const cursors: Array> = []; + for (const constraint of constraints) { + switch (constraint._tag) { + case 'OrderBy': + orderBys.push(constraint); + break; + case 'StartAt': + case 'StartAfter': + case 'EndAt': + case 'EndBefore': + cursors.push(constraint.values); + break; + } + } + return { orderBys, cursors }; +}; + +/** + * Validate the document-name cursor values of a **collection group** query, + * returning an error message when one is not a full document path. Both + * SDKs reject a bare ID there, since it does not name a document without + * knowing which parent it belongs to. + */ +export const validateGroupCursors = ( + constraints: ReadonlyArray, +): string | undefined => { + const { orderBys, cursors } = cursorsOf(constraints); + for (const cursor of cursors) { + // Firestore allows one value per orderBy, plus the implicit document-name + // tiebreaker when the query does not already order by __name__ itself. + // Anything beyond that is rejected by both SDKs. + const maxValues = orderBys.some( + (orderBy) => orderBy.field === Query.documentIdFieldPath, + ) + ? orderBys.length + : orderBys.length + 1; + if (cursor.length > maxValues) { + return 'Too many cursor values specified. The specified values must match the orderBy() constraints of the query'; + } + for (let i = 0; i < cursor.length; i++) { + if (!isNamePosition(orderBys, i)) { + continue; + } + const value = cursor[i]; + const segments = typeof value === 'string' ? value.split('/') : []; + const isDocumentPath = + segments.length >= 2 && + segments.length % 2 === 0 && + segments.every((segment) => segment.length > 0); + if (!isDocumentPath) { + return `When querying a collection group and ordering by document ID, the cursor value must be a full document path, but '${String( + value, + )}' is not`; + } + } + } + return undefined; +}; + +/** + * Resolve a cursor value at a document-name position. A single-collection + * query takes a bare ID, which Firestore expands against that collection; + * the mock expands it against the snapshot's own parent. A collection group + * query only ever reaches here with full paths (see + * {@link validateGroupCursors}). + */ +const nameCursor = (snapshot: Snapshot, value: unknown): unknown => { + if (typeof value !== 'string' || value.includes('/')) { + return value; + } + const [ref] = snapshot; + return `${ref.path.slice(0, ref.path.length - ref.id.length)}${value}`; }; const compareSnapshots = ( @@ -127,7 +224,10 @@ const compareCursor = ( const values = orderValues(snapshot, orderBys); for (let i = 0; i < Math.min(cursor.length, values.length); i++) { const direction = orderBys[i]?.direction ?? 'asc'; - const diff = compare(values[i], cursor[i]); + const expected = isNamePosition(orderBys, i) + ? nameCursor(snapshot, cursor[i]) + : cursor[i]; + const diff = compare(values[i], expected); if (diff !== 0) { return direction === 'desc' ? -diff : diff; } diff --git a/packages/mock/src/lib/firestore/store.ts b/packages/mock/src/lib/firestore/store.ts index efbeea5..5912379 100644 --- a/packages/mock/src/lib/firestore/store.ts +++ b/packages/mock/src/lib/firestore/store.ts @@ -1,4 +1,6 @@ -import { Snapshot } from 'effect-firebase'; +import { Snapshot, validateCollectionId } from 'effect-firebase'; + +export { validateCollectionId }; import type * as MockState from './state.js'; import { type DocData } from './value.js'; @@ -53,6 +55,22 @@ export const docsInCollection = ( .map(([path, data]) => makeSnapshot(path, data)); }; +/** + * All documents in any collection whose ID is `collectionId`, at any depth, + * ordered by full document path. + */ +export const docsInCollectionGroup = ( + docs: Readonly>, + collectionId: string, +): ReadonlyArray => + Object.entries(docs) + .filter(([path]) => { + const segments = path.split('/'); + return segments[segments.length - 2] === collectionId; + }) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([path, data]) => makeSnapshot(path, data)); + /** * Validate a path, returning an error message when it is malformed. * Documents sit at an even number of segments, collections at an odd number;