From a1a5ba3e4c7b42d1ab096b11bf5f99c7d82e1589 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 8 Aug 2026 19:46:41 -0400 Subject: [PATCH 1/4] fix: align Firestore repository semantics --- .../rockets-repository-firestore/CHANGELOG.md | 11 +- .../rockets-repository-firestore/README.md | 6 +- .../rockets-repository-firestore/package.json | 6 +- .../firestore-repository.module.spec.ts | 169 ++++++++++++++++++ .../firestore-where.translator.spec.ts | 27 +++ .../src/backends/admin-firestore.backend.ts | 79 +++----- .../backends/in-memory-firestore.backend.ts | 125 +++---------- .../define-firestore-repository.ts | 2 +- .../interfaces/firestore-backend.interface.ts | 6 + .../src/repository/firestore-query-runner.ts | 49 +---- .../src/repository/firestore-repository.ts | 4 +- .../src/repository/firestore-row-filter.ts | 56 ++++++ .../src/repository/firestore-sort.ts | 28 +++ .../src/repository/firestore-value.ts | 25 +++ .../repository/firestore-where.translator.ts | 34 +++- 15 files changed, 407 insertions(+), 220 deletions(-) create mode 100644 packages/rockets-repository-firestore/src/repository/firestore-row-filter.ts create mode 100644 packages/rockets-repository-firestore/src/repository/firestore-sort.ts create mode 100644 packages/rockets-repository-firestore/src/repository/firestore-value.ts diff --git a/packages/rockets-repository-firestore/CHANGELOG.md b/packages/rockets-repository-firestore/CHANGELOG.md index 700029d12..197d8abc3 100644 --- a/packages/rockets-repository-firestore/CHANGELOG.md +++ b/packages/rockets-repository-firestore/CHANGELOG.md @@ -10,10 +10,11 @@ - `skip` / `take` pagination — `orderBy` + `limit(skip + take)` pushed to the Firestore Admin SDK so reads scale with the page, not the collection. - Efficient `count` / `findAndCount` (aggregation when possible). -- Soft delete / restore when `dateRemoved` or `deletedAt` is configured on the - entity (or via `softDeleteField` option). +- Soft delete / restore when `dateRemoved` or `deletedAt` exists on the entity. - `withDeleted` on find options. - Exported `ensureFirebaseAdminApp()` for shared Admin bootstrap with auth. +- Atomic create semantics: duplicate document ids are rejected instead of + silently overwritten. ### Changed @@ -22,4 +23,10 @@ `defineFirestoreRepository().forRoot()` delegates here. - Backend API: `query()` replaced by `queryBranch()` / `countBranch()` with structured query plans. +- Document-id `EQ` / `IN` predicates compose with ownership and other filters; + contradictory id predicates resolve to an empty result. +- Generated ids are persisted and returned from `upsert`, and every order + clause participates in deterministic sorting. +- Admin SDK and in-memory backends share the same local filter and sort + semantics for direct document lookups and post-filtered queries. - README documents supported features and Firestore platform limits. diff --git a/packages/rockets-repository-firestore/README.md b/packages/rockets-repository-firestore/README.md index fb454045e..5f7f2ed2b 100644 --- a/packages/rockets-repository-firestore/README.md +++ b/packages/rockets-repository-firestore/README.md @@ -8,8 +8,8 @@ > Firestore-backed entities with a TypeORM (or any other) default adapter, per > entity. -**Status:** preview (`0.0.1-dev.0` on npm, dist-tag `alpha`). API stable -enough to use; expect refinements before 1.0. +**Status:** pre-1.0 preview (`0.0.1-dev.0`, npm dist-tag `alpha`). Public +shapes may still change before 1.0. --- @@ -33,7 +33,7 @@ Other entities continue on the default adapter (TypeORM, in most apps). - `FirestoreRepositoryModule.forFeature(entities, options?)` — registers dynamic repository providers per entity row. - `defineFirestoreRepository()` — `RepositoryBootstrap` with the same shape as - app-local `defineTypeOrmRepository` (thin delegate, no env sniffing). + `defineTypeOrmRepository` from `@concepta/rockets-repository-typeorm`. - `FirestoreRepository` — adapter class implementing `RepositoryAdapter`. - `ensureFirebaseAdminApp(packageRoot)` — singleton Admin initialisation for diff --git a/packages/rockets-repository-firestore/package.json b/packages/rockets-repository-firestore/package.json index 59359afcd..d0a4d6ffa 100644 --- a/packages/rockets-repository-firestore/package.json +++ b/packages/rockets-repository-firestore/package.json @@ -24,13 +24,13 @@ "directory": "packages/rockets-repository-firestore" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": ">=20.0.0" }, "files": [ "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}", "README.md", - "LICENSE.txt" + "LICENSE.txt", + "CHANGELOG.md" ], "scripts": { "clean": "rimraf dist tsconfig.tsbuildinfo", diff --git a/packages/rockets-repository-firestore/src/__tests__/firestore-repository.module.spec.ts b/packages/rockets-repository-firestore/src/__tests__/firestore-repository.module.spec.ts index a00ae9aa1..9b94b6c47 100644 --- a/packages/rockets-repository-firestore/src/__tests__/firestore-repository.module.spec.ts +++ b/packages/rockets-repository-firestore/src/__tests__/firestore-repository.module.spec.ts @@ -22,6 +22,18 @@ class SoftWidgetEntity { dateRemoved!: Date | null; } +class OwnedWidgetEntity { + id!: string; + title!: string; + userId!: string; +} + +class OrderedWidgetEntity { + id!: string; + group!: string; + rank!: number; +} + describe(FirestoreRepositoryModule.name, () => { const backend = new InMemoryFirestoreBackend(); @@ -193,4 +205,161 @@ describe(FirestoreRepositoryModule.name, () => { expect(rows).toHaveLength(1); expect(total).toBe(2); }); + + it('applies every predicate when a branch also targets a document id', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + FirestoreRepositoryModule.forFeature( + [ + { + key: 'owned-widget', + entity: OwnedWidgetEntity, + collection: 'owned-widgets-id-filter', + }, + ], + { backend: new InMemoryFirestoreBackend() }, + ), + ], + }).compile(); + + const repo = moduleRef.get>( + getDynamicRepositoryToken('owned-widget'), + ); + + await repo.create({ id: 'private-1', title: 'Private', userId: 'actor-a' }); + + const hidden = await repo.findOne({ + where: Where.and( + Where.eq('id', 'private-1'), + Where.eq('userId', 'actor-b'), + ), + }); + + expect(hidden).toBeNull(); + }); + + it('supports id IN together with additional predicates', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + FirestoreRepositoryModule.forFeature( + [ + { + key: 'owned-widget', + entity: OwnedWidgetEntity, + collection: 'owned-widgets-id-in', + }, + ], + { backend: new InMemoryFirestoreBackend() }, + ), + ], + }).compile(); + + const repo = moduleRef.get>( + getDynamicRepositoryToken('owned-widget'), + ); + + await repo.create({ id: 'owned-a', title: 'A', userId: 'actor-a' }); + await repo.create({ id: 'owned-b', title: 'B', userId: 'actor-b' }); + await repo.create({ id: 'outside-set', title: 'C', userId: 'actor-a' }); + + const rows = await repo.find({ + where: Where.and( + Where.in('id', ['owned-a', 'owned-b']), + Where.eq('userId', 'actor-a'), + ), + }); + + expect(rows.map((row) => row.id)).toEqual(['owned-a']); + }); + + it('returns the generated id from upsert', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + FirestoreRepositoryModule.forFeature( + [ + { + key: 'widget', + entity: WidgetEntity, + collection: 'widget-upsert', + }, + ], + { backend: new InMemoryFirestoreBackend() }, + ), + ], + }).compile(); + + const repo = moduleRef.get>( + getDynamicRepositoryToken('widget'), + ); + + const result = await repo.upsert({ title: 'Generated id' }); + + expect(result.id).toEqual(expect.any(String)); + await expect( + repo.findOne({ where: Where.eq('id', result.id) }), + ).resolves.toMatchObject({ id: result.id, title: 'Generated id' }); + }); + + it('rejects create when the document id already exists', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + FirestoreRepositoryModule.forFeature( + [ + { + key: 'widget', + entity: WidgetEntity, + collection: 'widget-duplicate-create', + }, + ], + { backend: new InMemoryFirestoreBackend() }, + ), + ], + }).compile(); + + const repo = moduleRef.get>( + getDynamicRepositoryToken('widget'), + ); + + await repo.create({ id: 'same-id', title: 'Original' }); + + await expect( + repo.create({ id: 'same-id', title: 'Replacement' }), + ).rejects.toThrow(); + await expect( + repo.findOne({ where: Where.eq('id', 'same-id') }), + ).resolves.toMatchObject({ title: 'Original' }); + }); + + it('uses every order clause as a deterministic tie-breaker', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + FirestoreRepositoryModule.forFeature( + [ + { + key: 'ordered-widget', + entity: OrderedWidgetEntity, + collection: 'ordered-widgets', + }, + ], + { backend: new InMemoryFirestoreBackend() }, + ), + ], + }).compile(); + + const repo = moduleRef.get>( + getDynamicRepositoryToken('ordered-widget'), + ); + + await repo.create({ id: 'rank-2', group: 'same', rank: 2 }); + await repo.create({ id: 'rank-1', group: 'same', rank: 1 }); + + const rows = await repo.find({ + order: [ + { field: 'group', order: SortOrder.ASC }, + { field: 'rank', order: SortOrder.ASC }, + ], + }); + + expect(rows.map((row) => row.id)).toEqual(['rank-1', 'rank-2']); + }); }); diff --git a/packages/rockets-repository-firestore/src/__tests__/firestore-where.translator.spec.ts b/packages/rockets-repository-firestore/src/__tests__/firestore-where.translator.spec.ts index c757cb80a..10def99c7 100644 --- a/packages/rockets-repository-firestore/src/__tests__/firestore-where.translator.spec.ts +++ b/packages/rockets-repository-firestore/src/__tests__/firestore-where.translator.spec.ts @@ -25,6 +25,33 @@ describe('firestore-where.translator', () => { ]); }); + it('maps id IN to direct document lookups', () => { + const branch = translateDnfBranch([Where.in('id', ['doc-1', 'doc-2'])]); + + expect(branch.documentIds).toEqual(['doc-1', 'doc-2']); + expect(branch.filters).toEqual([]); + }); + + it('intersects multiple document-id predicates in an AND branch', () => { + const branch = translateDnfBranch([ + Where.in('id', ['doc-1', 'doc-2']), + Where.eq('id', 'doc-2'), + ]); + + expect(branch.documentId).toBe('doc-2'); + expect(branch.documentIds).toBeUndefined(); + }); + + it('represents a contradictory document-id branch as an empty lookup', () => { + const branch = translateDnfBranch([ + Where.in('id', ['doc-1']), + Where.eq('id', 'doc-2'), + ]); + + expect(branch.documentIds).toEqual([]); + expect(branch.documentId).toBeUndefined(); + }); + it('maps IS_NULL to post-filter', () => { const branch = translateDnfBranch([Where.isNull('note')]); expect(branch.postFilters).toEqual([{ kind: 'is_null', field: 'note' }]); diff --git a/packages/rockets-repository-firestore/src/backends/admin-firestore.backend.ts b/packages/rockets-repository-firestore/src/backends/admin-firestore.backend.ts index 282877862..293db9783 100644 --- a/packages/rockets-repository-firestore/src/backends/admin-firestore.backend.ts +++ b/packages/rockets-repository-firestore/src/backends/admin-firestore.backend.ts @@ -16,6 +16,8 @@ import type { FirestoreQueryBranch, } from '../interfaces/firestore-query.interface'; import { applyFirestorePostFilters } from '../repository/firestore-post-filter'; +import { applyFirestoreFilters } from '../repository/firestore-row-filter'; +import { sortFirestoreRows } from '../repository/firestore-sort'; export class AdminFirestoreBackend implements FirestoreBackend { private db() { @@ -45,6 +47,17 @@ export class AdminFirestoreBackend implements FirestoreBackend { .set(this.serialise(data), { merge }); } + async create( + collection: string, + documentId: string, + data: Record, + ): Promise { + await this.db() + .collection(collection) + .doc(documentId) + .create(this.serialise(data)); + } + async delete(collection: string, documentId: string): Promise { await this.db().collection(collection).doc(documentId).delete(); } @@ -57,13 +70,13 @@ export class AdminFirestoreBackend implements FirestoreBackend { const skip = options.skip ?? 0; const take = options.take; - if ( - branch.documentId || - (branch.documentIds && branch.documentIds.length > 0) - ) { + if (branch.documentId || branch.documentIds !== undefined) { const rows = await this.loadBranchRows(collection, branch); - const filtered = applyFirestorePostFilters(rows, branch.postFilters); - const ordered = this.sortRows(filtered, options.orderBy); + const filtered = applyFirestorePostFilters( + applyFirestoreFilters(rows, branch.filters), + branch.postFilters, + ); + const ordered = sortFirestoreRows(filtered, options.orderBy); const sliced = ordered.slice(skip); return typeof take === 'number' && take > 0 ? sliced.slice(0, take) @@ -103,10 +116,13 @@ export class AdminFirestoreBackend implements FirestoreBackend { if ( branch.postFilters.length > 0 || branch.documentId || - (branch.documentIds && branch.documentIds.length > 0) + branch.documentIds !== undefined ) { const rows = await this.loadBranchRows(collection, branch); - return applyFirestorePostFilters(rows, branch.postFilters).length; + return applyFirestorePostFilters( + applyFirestoreFilters(rows, branch.filters), + branch.postFilters, + ).length; } const query = this.buildCollectionQuery(collection, branch); @@ -123,7 +139,7 @@ export class AdminFirestoreBackend implements FirestoreBackend { return row ? [row] : []; } - if (branch.documentIds && branch.documentIds.length > 0) { + if (branch.documentIds !== undefined) { const rows: Record[] = []; for (const documentId of branch.documentIds) { const row = await this.get(collection, documentId); @@ -170,41 +186,6 @@ export class AdminFirestoreBackend implements FirestoreBackend { return query; } - private sortRows( - rows: Record[], - orderBy?: readonly FirestoreOrderBy[], - ): Record[] { - if (!orderBy || orderBy.length === 0) { - return rows; - } - - const clause = orderBy[0]; - const desc = clause.direction === 'desc'; - - return [...rows].sort((left, right) => { - const a = left[clause.field]; - const b = right[clause.field]; - if (a === b) { - return 0; - } - if (a === undefined || a === null) { - return 1; - } - if (b === undefined || b === null) { - return -1; - } - const aTime = toSortableTime(a); - const bTime = toSortableTime(b); - if (!Number.isNaN(aTime) && !Number.isNaN(bTime)) { - return desc ? bTime - aTime : aTime - bTime; - } - if (typeof a === 'string' && typeof b === 'string') { - return desc ? b.localeCompare(a) : a.localeCompare(b); - } - return desc ? (a < b ? 1 : -1) : a > b ? 1 : -1; - }); - } - /** * `Date` goes to the SDK untouched: Firestore stores it as a native * `Timestamp`, which {@link normalise} converts straight back to a @@ -229,13 +210,3 @@ export class AdminFirestoreBackend implements FirestoreBackend { return next; } } - -function toSortableTime(value: unknown): number { - if (value instanceof Date) { - return value.getTime(); - } - if (typeof value === 'string') { - return Date.parse(value); - } - return Number.NaN; -} diff --git a/packages/rockets-repository-firestore/src/backends/in-memory-firestore.backend.ts b/packages/rockets-repository-firestore/src/backends/in-memory-firestore.backend.ts index eeaaba3d8..24b979ec0 100644 --- a/packages/rockets-repository-firestore/src/backends/in-memory-firestore.backend.ts +++ b/packages/rockets-repository-firestore/src/backends/in-memory-firestore.backend.ts @@ -2,11 +2,10 @@ import type { FirestoreBackend, FirestoreBranchQueryOptions, } from '../interfaces/firestore-backend.interface'; -import type { - FirestoreQueryBranch, - FirestoreQueryFilter, -} from '../interfaces/firestore-query.interface'; +import type { FirestoreQueryBranch } from '../interfaces/firestore-query.interface'; import { applyFirestorePostFilters } from '../repository/firestore-post-filter'; +import { applyFirestoreFilters } from '../repository/firestore-row-filter'; +import { sortFirestoreRows } from '../repository/firestore-sort'; /** * In-memory Firestore backend for unit tests and explicit test harnesses. @@ -55,6 +54,20 @@ export class InMemoryFirestoreBackend implements FirestoreBackend { ); } + async create( + collection: string, + documentId: string, + data: Record, + ): Promise { + const store = this.collectionStore(collection); + if (store.has(documentId)) { + throw new Error( + `Firestore document "${collection}/${documentId}" already exists.`, + ); + } + store.set(documentId, { ...data, id: documentId }); + } + async delete(collection: string, documentId: string): Promise { this.collectionStore(collection).delete(documentId); } @@ -65,10 +78,10 @@ export class InMemoryFirestoreBackend implements FirestoreBackend { ): Promise[]> { const rows = await this.loadBranchRows(collection, options.branch); const filtered = applyFirestorePostFilters( - rows, + applyFirestoreFilters(rows, options.branch.filters), options.branch.postFilters, ); - const ordered = sortInMemory(filtered, options.orderBy); + const ordered = sortFirestoreRows(filtered, options.orderBy); const sliced = ordered.slice(options.skip ?? 0); if (typeof options.take === 'number' && options.take > 0) { @@ -82,7 +95,10 @@ export class InMemoryFirestoreBackend implements FirestoreBackend { branch: FirestoreQueryBranch, ): Promise { const rows = await this.loadBranchRows(collection, branch); - const filtered = applyFirestorePostFilters(rows, branch.postFilters); + const filtered = applyFirestorePostFilters( + applyFirestoreFilters(rows, branch.filters), + branch.postFilters, + ); return filtered.length; } @@ -95,7 +111,7 @@ export class InMemoryFirestoreBackend implements FirestoreBackend { return row ? [row] : []; } - if (branch.documentIds && branch.documentIds.length > 0) { + if (branch.documentIds !== undefined) { const rows: Record[] = []; for (const documentId of branch.documentIds) { const row = await this.get(collection, documentId); @@ -106,97 +122,6 @@ export class InMemoryFirestoreBackend implements FirestoreBackend { return rows; } - const rows = [...this.collectionStore(collection).values()]; - return rows.filter((row) => - branch.filters.every((filter) => matchesFilter(row, filter)), - ); - } -} - -function matchesFilter( - row: Record, - filter: FirestoreQueryFilter, -): boolean { - const value = row[filter.field]; - switch (filter.op) { - case '==': - return value === filter.value; - case '!=': - return value !== filter.value; - case '<': - return compare(value, filter.value) < 0; - case '<=': - return compare(value, filter.value) <= 0; - case '>': - return compare(value, filter.value) > 0; - case '>=': - return compare(value, filter.value) >= 0; - case 'in': - return ( - Array.isArray(filter.value) && - filter.value.some((candidate) => candidate === value) - ); - case 'not-in': - return ( - Array.isArray(filter.value) && - !filter.value.some((candidate) => candidate === value) - ); - case 'array-contains': - return Array.isArray(value) && value.includes(filter.value); - default: { - const _exhaustive: never = filter.op; - return _exhaustive; - } - } -} - -function compare(left: unknown, right: unknown): number { - const a = toComparable(left); - const b = toComparable(right); - if (a === undefined || b === undefined) { - return -1; + return [...this.collectionStore(collection).values()]; } - if (a < b) { - return -1; - } - if (a > b) { - return 1; - } - return 0; -} - -function toComparable(value: unknown): number | string | undefined { - if (value instanceof Date) { - return value.getTime(); - } - if (typeof value === 'number' || typeof value === 'string') { - return value; - } - return undefined; -} - -function sortInMemory( - rows: Record[], - orderBy?: FirestoreBranchQueryOptions['orderBy'], -): Record[] { - if (!orderBy || orderBy.length === 0) { - return rows; - } - const clause = orderBy[0]; - const desc = clause.direction === 'desc'; - return [...rows].sort((left, right) => { - const a = left[clause.field]; - const b = right[clause.field]; - if (a === b) { - return 0; - } - if (a === undefined || a === null) { - return 1; - } - if (b === undefined || b === null) { - return -1; - } - const cmp = compare(a, b); - return desc ? -cmp : cmp; - }); } diff --git a/packages/rockets-repository-firestore/src/integration/define-firestore-repository.ts b/packages/rockets-repository-firestore/src/integration/define-firestore-repository.ts index 1ebace977..b3b343f50 100644 --- a/packages/rockets-repository-firestore/src/integration/define-firestore-repository.ts +++ b/packages/rockets-repository-firestore/src/integration/define-firestore-repository.ts @@ -9,7 +9,7 @@ import { FirestoreRepositoryModule } from '../firestore-repository.module'; import type { DefineFirestoreRepositoryOptions } from './define-firestore-repository.config'; /** - * Same contract as app-local `defineTypeOrmRepository`: returns a + * Same contract as `defineTypeOrmRepository` from the TypeORM adapter: returns a * {@link RepositoryBootstrap} Rockets calls `forRoot` / `forFeature`. * * Does not read environment variables or pick a backend — the app must diff --git a/packages/rockets-repository-firestore/src/interfaces/firestore-backend.interface.ts b/packages/rockets-repository-firestore/src/interfaces/firestore-backend.interface.ts index 9f8767851..73b2376dd 100644 --- a/packages/rockets-repository-firestore/src/interfaces/firestore-backend.interface.ts +++ b/packages/rockets-repository-firestore/src/interfaces/firestore-backend.interface.ts @@ -21,6 +21,12 @@ export interface FirestoreBackend { collection: string, documentId: string, ): Promise | null>; + /** Atomically create a document and reject when its id already exists. */ + create( + collection: string, + documentId: string, + data: Record, + ): Promise; set( collection: string, documentId: string, diff --git a/packages/rockets-repository-firestore/src/repository/firestore-query-runner.ts b/packages/rockets-repository-firestore/src/repository/firestore-query-runner.ts index d27a622a0..dfb4d840a 100644 --- a/packages/rockets-repository-firestore/src/repository/firestore-query-runner.ts +++ b/packages/rockets-repository-firestore/src/repository/firestore-query-runner.ts @@ -1,10 +1,10 @@ import type { FirestoreBackend } from '../interfaces/firestore-backend.interface'; import type { - FirestoreOrderBy, FirestorePostFilter, FirestoreQueryBranch, FirestoreQueryRequest, } from '../interfaces/firestore-query.interface'; +import { sortFirestoreRows } from './firestore-sort'; export async function runFirestoreQuery( backend: FirestoreBackend, @@ -43,7 +43,7 @@ export async function runFirestoreQuery( let results = [...merged.values()]; if (!pushToServer) { - results = sortRows(results, request.orderBy); + results = sortFirestoreRows(results, request.orderBy); if (typeof request.skip === 'number' && request.skip > 0) { results = results.slice(request.skip); } @@ -107,48 +107,3 @@ function readDocumentId(row: Record): string | undefined { const id = row.id; return typeof id === 'string' && id.length > 0 ? id : undefined; } - -function sortRows( - rows: Record[], - orderBy?: readonly FirestoreOrderBy[], -): Record[] { - if (!orderBy || orderBy.length === 0) { - return rows; - } - - const clause = orderBy[0]; - const desc = clause.direction === 'desc'; - - return [...rows].sort((left, right) => { - const a = left[clause.field]; - const b = right[clause.field]; - if (a === b) { - return 0; - } - if (a === undefined || a === null) { - return 1; - } - if (b === undefined || b === null) { - return -1; - } - const aTime = toSortableTime(a); - const bTime = toSortableTime(b); - if (!Number.isNaN(aTime) && !Number.isNaN(bTime)) { - return desc ? bTime - aTime : aTime - bTime; - } - if (typeof a === 'string' && typeof b === 'string') { - return desc ? b.localeCompare(a) : a.localeCompare(b); - } - return desc ? (a < b ? 1 : -1) : a > b ? 1 : -1; - }); -} - -function toSortableTime(value: unknown): number { - if (value instanceof Date) { - return value.getTime(); - } - if (typeof value === 'string') { - return Date.parse(value); - } - return Number.NaN; -} diff --git a/packages/rockets-repository-firestore/src/repository/firestore-repository.ts b/packages/rockets-repository-firestore/src/repository/firestore-repository.ts index c583b3301..e3f79f633 100644 --- a/packages/rockets-repository-firestore/src/repository/firestore-repository.ts +++ b/packages/rockets-repository-firestore/src/repository/firestore-repository.ts @@ -98,7 +98,7 @@ export class FirestoreRepository< ): Promise { const id = this.resolveId(entity); const stored = this.toStore({ ...entity, id } as DeepPartial); - await this.options.backend.set(this.options.collection, id, stored, false); + await this.options.backend.create(this.options.collection, id, stored); return this.fromStore(stored); } @@ -129,7 +129,7 @@ export class FirestoreRepository< _options?: RepositoryUpsertOptions, ): Promise { const id = this.resolveId(entity); - const stored = this.toStore(entity); + const stored = this.toStore({ ...entity, id } as DeepPartial); await this.options.backend.set(this.options.collection, id, stored, true); return this.fromStore(stored); } diff --git a/packages/rockets-repository-firestore/src/repository/firestore-row-filter.ts b/packages/rockets-repository-firestore/src/repository/firestore-row-filter.ts new file mode 100644 index 000000000..18e85a0a4 --- /dev/null +++ b/packages/rockets-repository-firestore/src/repository/firestore-row-filter.ts @@ -0,0 +1,56 @@ +import type { FirestoreQueryFilter } from '../interfaces/firestore-query.interface'; +import { + compareFirestoreValues, + firestoreValuesEqual, +} from './firestore-value'; + +/** Apply the non-post-filter predicates that Firestore would normally execute. */ +export function applyFirestoreFilters( + rows: readonly Record[], + filters: readonly FirestoreQueryFilter[], +): Record[] { + if (filters.length === 0) return [...rows]; + return rows.filter((row) => filters.every((filter) => matches(row, filter))); +} + +function matches( + row: Record, + filter: FirestoreQueryFilter, +): boolean { + const value = row[filter.field]; + switch (filter.op) { + case '==': + return firestoreValuesEqual(value, filter.value); + case '!=': + return !firestoreValuesEqual(value, filter.value); + case '<': + return compareFirestoreValues(value, filter.value) < 0; + case '<=': + return compareFirestoreValues(value, filter.value) <= 0; + case '>': + return compareFirestoreValues(value, filter.value) > 0; + case '>=': + return compareFirestoreValues(value, filter.value) >= 0; + case 'in': + return ( + Array.isArray(filter.value) && + filter.value.some((candidate) => firestoreValuesEqual(value, candidate)) + ); + case 'not-in': + return ( + Array.isArray(filter.value) && + !filter.value.some((candidate) => + firestoreValuesEqual(value, candidate), + ) + ); + case 'array-contains': + return ( + Array.isArray(value) && + value.some((candidate) => firestoreValuesEqual(candidate, filter.value)) + ); + default: { + const exhaustive: never = filter.op; + return exhaustive; + } + } +} diff --git a/packages/rockets-repository-firestore/src/repository/firestore-sort.ts b/packages/rockets-repository-firestore/src/repository/firestore-sort.ts new file mode 100644 index 000000000..3cd38a65e --- /dev/null +++ b/packages/rockets-repository-firestore/src/repository/firestore-sort.ts @@ -0,0 +1,28 @@ +import type { FirestoreOrderBy } from '../interfaces/firestore-query.interface'; +import { compareFirestoreValues } from './firestore-value'; + +/** Sort rows by every declared clause, in order, so later clauses break ties. */ +export function sortFirestoreRows( + rows: readonly Record[], + orderBy?: readonly FirestoreOrderBy[], +): Record[] { + if (!orderBy || orderBy.length === 0) return [...rows]; + + return [...rows].sort((left, right) => { + for (const clause of orderBy) { + const leftValue = left[clause.field]; + const rightValue = right[clause.field]; + const leftMissing = leftValue === undefined || leftValue === null; + const rightMissing = rightValue === undefined || rightValue === null; + if (leftMissing || rightMissing) { + if (leftMissing && rightMissing) continue; + return leftMissing ? 1 : -1; + } + const result = compareFirestoreValues(leftValue, rightValue); + if (result !== 0) { + return clause.direction === 'desc' ? -result : result; + } + } + return 0; + }); +} diff --git a/packages/rockets-repository-firestore/src/repository/firestore-value.ts b/packages/rockets-repository-firestore/src/repository/firestore-value.ts new file mode 100644 index 000000000..99a358d5e --- /dev/null +++ b/packages/rockets-repository-firestore/src/repository/firestore-value.ts @@ -0,0 +1,25 @@ +/** Compare scalar values using the semantics shared by local filtering and sorting. */ +export function compareFirestoreValues(left: unknown, right: unknown): number { + const a = toComparable(left); + const b = toComparable(right); + + if (a === b) return 0; + if (a === undefined) return 1; + if (b === undefined) return -1; + return a < b ? -1 : 1; +} + +/** Firestore dates compare by value rather than JavaScript object identity. */ +export function firestoreValuesEqual(left: unknown, right: unknown): boolean { + if (left instanceof Date && right instanceof Date) { + return left.getTime() === right.getTime(); + } + return Object.is(left, right); +} + +function toComparable(value: unknown): number | string | undefined { + if (value instanceof Date) return value.getTime(); + if (typeof value === 'number' || typeof value === 'string') return value; + if (typeof value === 'boolean') return value ? 1 : 0; + return undefined; +} diff --git a/packages/rockets-repository-firestore/src/repository/firestore-where.translator.ts b/packages/rockets-repository-firestore/src/repository/firestore-where.translator.ts index 4792e5703..e6edbbbdc 100644 --- a/packages/rockets-repository-firestore/src/repository/firestore-where.translator.ts +++ b/packages/rockets-repository-firestore/src/repository/firestore-where.translator.ts @@ -73,17 +73,18 @@ function mergeAndBranch( ): FirestoreQueryBranch { const merged: FirestoreQueryFilter[] = []; const postFilters: FirestorePostFilter[] = []; - let documentId: string | undefined; + let candidateDocumentIds: string[] | undefined; for (const child of conditions) { if (isWhereCondition(child)) { const branch = translateCondition(child); - if (branch.documentId) { - documentId = branch.documentId; - } - if (branch.documentIds) { - throw new Error( - 'Firestore adapter: documentIds in an AND branch is not supported — restructure the where clause.', + const childDocumentIds = branch.documentId + ? [branch.documentId] + : branch.documentIds; + if (childDocumentIds !== undefined) { + candidateDocumentIds = intersectDocumentIds( + candidateDocumentIds, + childDocumentIds, ); } merged.push(...branch.filters); @@ -97,13 +98,30 @@ function mergeAndBranch( assertFirestoreFilterRules(merged); + const documentSelector = + candidateDocumentIds === undefined + ? {} + : candidateDocumentIds.length === 1 + ? { documentId: candidateDocumentIds[0] } + : { documentIds: candidateDocumentIds }; + return { - documentId, + ...documentSelector, filters: merged, postFilters, }; } +function intersectDocumentIds( + current: readonly string[] | undefined, + incoming: readonly string[], +): string[] { + const uniqueIncoming = [...new Set(incoming)]; + if (current === undefined) return uniqueIncoming; + const allowed = new Set(uniqueIncoming); + return current.filter((id) => allowed.has(id)); +} + function readScalarValue(condition: WhereConditionScalar): unknown { return condition.value; } From 094e9e533920fb0f38d0ab464a7a4e6e859a6c8b Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 8 Aug 2026 12:27:31 -0400 Subject: [PATCH 2/4] fix: align Firestore local query semantics --- .../rockets-repository-firestore/README.md | 9 + .../src/__tests__/firestore-value.spec.ts | 87 +++++++++ .../src/backends/admin-firestore.backend.ts | 4 +- .../src/repository/firestore-post-filter.ts | 49 +++--- .../src/repository/firestore-row-filter.ts | 60 ++++++- .../src/repository/firestore-sort.ts | 18 +- .../src/repository/firestore-value.ts | 165 ++++++++++++++++-- 7 files changed, 332 insertions(+), 60 deletions(-) create mode 100644 packages/rockets-repository-firestore/src/__tests__/firestore-value.spec.ts diff --git a/packages/rockets-repository-firestore/README.md b/packages/rockets-repository-firestore/README.md index 5f7f2ed2b..6ebf54e1d 100644 --- a/packages/rockets-repository-firestore/README.md +++ b/packages/rockets-repository-firestore/README.md @@ -177,6 +177,15 @@ override — name the column `dateRemoved` or `deletedAt` on the entity class. If neither name is present, `delete()` calls throw at runtime with a message naming both supported column names. +### Local query parity + +The in-memory backend and Admin SDK direct-document/post-filter paths match +Firestore for missing versus explicit `null`, nested field paths, structural +equality, and range type checks. Local `orderBy` parity is guaranteed for +`null`, boolean, number, timestamp/`Date`, and string values. Ordering any +other Firestore value type fails with a descriptive error instead of returning +a misleading order. + --- ## 4. Reference diff --git a/packages/rockets-repository-firestore/src/__tests__/firestore-value.spec.ts b/packages/rockets-repository-firestore/src/__tests__/firestore-value.spec.ts new file mode 100644 index 000000000..8dfe101df --- /dev/null +++ b/packages/rockets-repository-firestore/src/__tests__/firestore-value.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { applyFirestoreFilters } from '../repository/firestore-row-filter'; +import { sortFirestoreRows } from '../repository/firestore-sort'; +import { + firestoreValuesEqual, + normalizeFirestoreValue, + readFirestoreField, +} from '../repository/firestore-value'; + +describe('local Firestore value semantics', () => { + it('distinguishes missing nested fields from explicit null', () => { + expect( + readFirestoreField({ profile: { name: null } }, 'profile.name'), + ).toEqual({ + exists: true, + value: null, + }); + expect(readFirestoreField({ profile: {} }, 'profile.name')).toEqual({ + exists: false, + value: undefined, + }); + expect( + applyFirestoreFilters( + [{ id: 'missing' }, { id: 'null', value: null }], + [{ field: 'value', op: '==', value: null }], + ).map((row) => row.id), + ).toEqual(['null']); + }); + + it('excludes missing, null, and cross-type values from inequalities', () => { + const rows = [ + { id: 'missing' }, + { id: 'null', value: null }, + { id: 'string', value: '2' }, + { id: 'number', value: 2 }, + ]; + expect( + applyFirestoreFilters(rows, [{ field: 'value', op: '>', value: 1 }]).map( + (row) => row.id, + ), + ).toEqual(['number']); + expect( + applyFirestoreFilters(rows, [{ field: 'value', op: '!=', value: 3 }]).map( + (row) => row.id, + ), + ).toEqual(['string', 'number']); + }); + + it('compares arrays, maps, bytes, dates, and SDK values structurally', () => { + expect(firestoreValuesEqual([1, { ok: true }], [1, { ok: true }])).toBe( + true, + ); + expect( + firestoreValuesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2])), + ).toBe(true); + expect(firestoreValuesEqual(new Date(10), new Date(10))).toBe(true); + expect( + firestoreValuesEqual( + { isEqual: (other: unknown) => other === 'same' }, + 'same', + ), + ).toBe(true); + }); + + it('orders supported scalars and excludes missing ordered fields', () => { + const rows = [ + { id: 'missing' }, + { id: 'string', value: 'a' }, + { id: 'number', value: 1 }, + { id: 'true', value: true }, + { id: 'null', value: null }, + ]; + expect( + sortFirestoreRows(rows, [{ field: 'value', direction: 'asc' }]).map( + (row) => row.id, + ), + ).toEqual(['null', 'true', 'number', 'string']); + }); + + it('normalizes timestamps recursively', () => { + const date = new Date('2026-01-01T00:00:00.000Z'); + expect( + normalizeFirestoreValue({ nested: [{ at: { toDate: () => date } }] }), + ).toEqual({ nested: [{ at: date }] }); + }); +}); diff --git a/packages/rockets-repository-firestore/src/backends/admin-firestore.backend.ts b/packages/rockets-repository-firestore/src/backends/admin-firestore.backend.ts index 293db9783..76607aa65 100644 --- a/packages/rockets-repository-firestore/src/backends/admin-firestore.backend.ts +++ b/packages/rockets-repository-firestore/src/backends/admin-firestore.backend.ts @@ -1,7 +1,6 @@ import { getApp } from 'firebase-admin/app'; import { getFirestore, - Timestamp, type DocumentData, type Query, } from 'firebase-admin/firestore'; @@ -18,6 +17,7 @@ import type { import { applyFirestorePostFilters } from '../repository/firestore-post-filter'; import { applyFirestoreFilters } from '../repository/firestore-row-filter'; import { sortFirestoreRows } from '../repository/firestore-sort'; +import { normalizeFirestoreValue } from '../repository/firestore-value'; export class AdminFirestoreBackend implements FirestoreBackend { private db() { @@ -205,7 +205,7 @@ export class AdminFirestoreBackend implements FirestoreBackend { } const next: Record = { id: documentId }; for (const [key, value] of Object.entries(data)) { - next[key] = value instanceof Timestamp ? value.toDate() : value; + next[key] = normalizeFirestoreValue(value); } return next; } diff --git a/packages/rockets-repository-firestore/src/repository/firestore-post-filter.ts b/packages/rockets-repository-firestore/src/repository/firestore-post-filter.ts index cee3a927c..128bf270d 100644 --- a/packages/rockets-repository-firestore/src/repository/firestore-post-filter.ts +++ b/packages/rockets-repository-firestore/src/repository/firestore-post-filter.ts @@ -1,4 +1,10 @@ import type { FirestorePostFilter } from '../interfaces/firestore-query.interface'; +import { + compareFirestoreValues, + firestoreValuesEqual, + readFirestoreField, + sameFirestoreRangeType, +} from './firestore-value'; export function applyFirestorePostFilters( rows: readonly Record[], @@ -17,13 +23,14 @@ function matchesPostFilter( row: Record, filter: FirestorePostFilter, ): boolean { - const value = row[filter.field]; + const field = readFirestoreField(row, filter.field); + const value = field.value; switch (filter.kind) { case 'is_null': - return value === null || value === undefined; + return field.exists && value === null; case 'is_not_null': - return value !== null && value !== undefined; + return field.exists && value !== null; case 'contains': return containsValue(value, filter.value); case 'not_contains': @@ -37,7 +44,13 @@ function matchesPostFilter( case 'not_ends': return typeof value !== 'string' || !value.endsWith(filter.value); case 'nin': - return !filter.values.some((candidate) => candidate === value); + return ( + field.exists && + value !== null && + !filter.values.some((candidate) => + firestoreValuesEqual(candidate, value), + ) + ); case 'between': return compareBetween(value, filter.min, filter.max); case 'soft_delete_excluded': @@ -59,25 +72,11 @@ function containsValue(fieldValue: unknown, needle: string): boolean { } function compareBetween(value: unknown, min: unknown, max: unknown): boolean { - const sortable = toComparable(value); - const minComparable = toComparable(min); - const maxComparable = toComparable(max); - if ( - sortable === undefined || - minComparable === undefined || - maxComparable === undefined - ) { - return false; - } - return sortable >= minComparable && sortable <= maxComparable; -} - -function toComparable(value: unknown): number | string | undefined { - if (value instanceof Date) { - return value.getTime(); - } - if (typeof value === 'number' || typeof value === 'string') { - return value; - } - return undefined; + return ( + value !== null && + sameFirestoreRangeType(value, min) && + sameFirestoreRangeType(value, max) && + compareFirestoreValues(value, min) >= 0 && + compareFirestoreValues(value, max) <= 0 + ); } diff --git a/packages/rockets-repository-firestore/src/repository/firestore-row-filter.ts b/packages/rockets-repository-firestore/src/repository/firestore-row-filter.ts index 18e85a0a4..b304c58b0 100644 --- a/packages/rockets-repository-firestore/src/repository/firestore-row-filter.ts +++ b/packages/rockets-repository-firestore/src/repository/firestore-row-filter.ts @@ -2,6 +2,8 @@ import type { FirestoreQueryFilter } from '../interfaces/firestore-query.interfa import { compareFirestoreValues, firestoreValuesEqual, + readFirestoreField, + sameFirestoreRangeType, } from './firestore-value'; /** Apply the non-post-filter predicates that Firestore would normally execute. */ @@ -17,27 +19,55 @@ function matches( row: Record, filter: FirestoreQueryFilter, ): boolean { - const value = row[filter.field]; + const field = readFirestoreField(row, filter.field); + const value = field.value; switch (filter.op) { case '==': - return firestoreValuesEqual(value, filter.value); + return field.exists && firestoreValuesEqual(value, filter.value); case '!=': - return !firestoreValuesEqual(value, filter.value); + return ( + field.exists && + value !== null && + !firestoreValuesEqual(value, filter.value) + ); case '<': - return compareFirestoreValues(value, filter.value) < 0; + return rangeMatch( + field.exists, + value, + filter.value, + (result) => result < 0, + ); case '<=': - return compareFirestoreValues(value, filter.value) <= 0; + return rangeMatch( + field.exists, + value, + filter.value, + (result) => result <= 0, + ); case '>': - return compareFirestoreValues(value, filter.value) > 0; + return rangeMatch( + field.exists, + value, + filter.value, + (result) => result > 0, + ); case '>=': - return compareFirestoreValues(value, filter.value) >= 0; + return rangeMatch( + field.exists, + value, + filter.value, + (result) => result >= 0, + ); case 'in': return ( + field.exists && Array.isArray(filter.value) && filter.value.some((candidate) => firestoreValuesEqual(value, candidate)) ); case 'not-in': return ( + field.exists && + value !== null && Array.isArray(filter.value) && !filter.value.some((candidate) => firestoreValuesEqual(value, candidate), @@ -45,6 +75,7 @@ function matches( ); case 'array-contains': return ( + field.exists && Array.isArray(value) && value.some((candidate) => firestoreValuesEqual(candidate, filter.value)) ); @@ -54,3 +85,18 @@ function matches( } } } + +function rangeMatch( + exists: boolean, + value: unknown, + candidate: unknown, + predicate: (result: number) => boolean, +): boolean { + return ( + exists && + value !== null && + candidate !== null && + sameFirestoreRangeType(value, candidate) && + predicate(compareFirestoreValues(value, candidate)) + ); +} diff --git a/packages/rockets-repository-firestore/src/repository/firestore-sort.ts b/packages/rockets-repository-firestore/src/repository/firestore-sort.ts index 3cd38a65e..2a2e903d3 100644 --- a/packages/rockets-repository-firestore/src/repository/firestore-sort.ts +++ b/packages/rockets-repository-firestore/src/repository/firestore-sort.ts @@ -1,5 +1,5 @@ import type { FirestoreOrderBy } from '../interfaces/firestore-query.interface'; -import { compareFirestoreValues } from './firestore-value'; +import { compareFirestoreValues, readFirestoreField } from './firestore-value'; /** Sort rows by every declared clause, in order, so later clauses break ties. */ export function sortFirestoreRows( @@ -8,16 +8,14 @@ export function sortFirestoreRows( ): Record[] { if (!orderBy || orderBy.length === 0) return [...rows]; - return [...rows].sort((left, right) => { + const present = rows.filter((row) => + orderBy.every((clause) => readFirestoreField(row, clause.field).exists), + ); + + return [...present].sort((left, right) => { for (const clause of orderBy) { - const leftValue = left[clause.field]; - const rightValue = right[clause.field]; - const leftMissing = leftValue === undefined || leftValue === null; - const rightMissing = rightValue === undefined || rightValue === null; - if (leftMissing || rightMissing) { - if (leftMissing && rightMissing) continue; - return leftMissing ? 1 : -1; - } + const leftValue = readFirestoreField(left, clause.field).value; + const rightValue = readFirestoreField(right, clause.field).value; const result = compareFirestoreValues(leftValue, rightValue); if (result !== 0) { return clause.direction === 'desc' ? -result : result; diff --git a/packages/rockets-repository-firestore/src/repository/firestore-value.ts b/packages/rockets-repository-firestore/src/repository/firestore-value.ts index 99a358d5e..f6356f6de 100644 --- a/packages/rockets-repository-firestore/src/repository/firestore-value.ts +++ b/packages/rockets-repository-firestore/src/repository/firestore-value.ts @@ -1,25 +1,158 @@ -/** Compare scalar values using the semantics shared by local filtering and sorting. */ -export function compareFirestoreValues(left: unknown, right: unknown): number { - const a = toComparable(left); - const b = toComparable(right); +export interface FirestoreFieldValue { + readonly exists: boolean; + readonly value: unknown; +} + +/** Read a dotted Firestore field path while preserving missing-vs-null. */ +export function readFirestoreField( + row: Record, + field: string, +): FirestoreFieldValue { + let current: unknown = row; + for (const segment of field.split('.')) { + if ( + !isPlainMap(current) || + !Object.prototype.hasOwnProperty.call(current, segment) + ) { + return { exists: false, value: undefined }; + } + current = current[segment]; + if (current === undefined) return { exists: false, value: undefined }; + } + return { exists: true, value: current }; +} - if (a === b) return 0; - if (a === undefined) return 1; - if (b === undefined) return -1; - return a < b ? -1 : 1; +/** Compare values in the scalar subset supported by local ordering. */ +export function compareFirestoreValues(left: unknown, right: unknown): number { + const a = sortable(left); + const b = sortable(right); + if (!a || !b) { + throw new Error( + `Firestore local ordering supports only null, boolean, number, timestamp, and string values; received ${describeValue( + !a ? left : right, + )}.`, + ); + } + if (a.rank !== b.rank) return a.rank < b.rank ? -1 : 1; + if (a.value === b.value) return 0; + return a.value < b.value ? -1 : 1; } -/** Firestore dates compare by value rather than JavaScript object identity. */ +/** Structural equality for Firestore values used by local query paths. */ export function firestoreValuesEqual(left: unknown, right: unknown): boolean { - if (left instanceof Date && right instanceof Date) { - return left.getTime() === right.getTime(); + if (Object.is(left, right)) return true; + if (isEqualValue(left)) return left.isEqual(right); + if (isEqualValue(right)) return right.isEqual(left); + + const leftDate = asDate(left); + const rightDate = asDate(right); + if (leftDate && rightDate) return leftDate.getTime() === rightDate.getTime(); + + if (isBytes(left) && isBytes(right)) { + if (left.byteLength !== right.byteLength) return false; + return left.every((value, index) => value === right[index]); + } + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((value, index) => firestoreValuesEqual(value, right[index])) + ); + } + if (isPlainMap(left) && isPlainMap(right)) { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key, index) => + key === rightKeys[index] && + firestoreValuesEqual(left[key], right[key]), + ) + ); + } + return false; +} + +/** Recursively convert Firestore timestamp-like SDK values to Date. */ +export function normalizeFirestoreValue(value: unknown): unknown { + const date = asDate(value); + if (date) return date; + if (Array.isArray(value)) return value.map(normalizeFirestoreValue); + if (isPlainMap(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + normalizeFirestoreValue(child), + ]), + ); } - return Object.is(left, right); + return value; +} + +export function sameFirestoreRangeType(left: unknown, right: unknown): boolean { + return ( + scalarKind(left) !== undefined && scalarKind(left) === scalarKind(right) + ); } -function toComparable(value: unknown): number | string | undefined { - if (value instanceof Date) return value.getTime(); - if (typeof value === 'number' || typeof value === 'string') return value; - if (typeof value === 'boolean') return value ? 1 : 0; +function sortable( + value: unknown, +): { rank: number; value: number | string } | undefined { + if (value === null) return { rank: 0, value: 0 }; + if (typeof value === 'boolean') return { rank: 1, value: value ? 1 : 0 }; + if (typeof value === 'number') return { rank: 2, value }; + const date = asDate(value); + if (date) return { rank: 3, value: date.getTime() }; + if (typeof value === 'string') return { rank: 4, value }; return undefined; } + +function scalarKind(value: unknown): string | undefined { + if (typeof value === 'boolean') return 'boolean'; + if (typeof value === 'number') return 'number'; + if (asDate(value)) return 'timestamp'; + if (typeof value === 'string') return 'string'; + return undefined; +} + +function asDate(value: unknown): Date | undefined { + if (value instanceof Date) return value; + if ( + typeof value === 'object' && + value !== null && + 'toDate' in value && + typeof value.toDate === 'function' + ) { + const date = value.toDate(); + return date instanceof Date ? date : undefined; + } + return undefined; +} + +function isPlainMap(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isBytes(value: unknown): value is Uint8Array { + return value instanceof Uint8Array; +} + +function isEqualValue( + value: unknown, +): value is { isEqual(other: unknown): boolean } { + return ( + typeof value === 'object' && + value !== null && + 'isEqual' in value && + typeof value.isEqual === 'function' + ); +} + +function describeValue(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} From 006a02ff273d6a2f644a4e5c3a3b1b83fb16a581 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 9 Aug 2026 12:49:38 -0400 Subject: [PATCH 3/4] fix: address Firestore parity review findings --- .../rockets-repository-firestore/CHANGELOG.md | 7 +- .../rockets-repository-firestore/README.md | 18 +- .../firestore-repository.module.spec.ts | 221 +++++++++++++++- .../src/__tests__/firestore-value.spec.ts | 106 +++++++- .../firestore-where.translator.spec.ts | 23 ++ .../src/backends/admin-firestore.backend.ts | 19 +- .../backends/in-memory-firestore.backend.ts | 2 +- .../src/repository/firestore-post-filter.ts | 10 +- .../src/repository/firestore-row-filter.ts | 59 ++--- .../src/repository/firestore-sort.ts | 8 +- .../src/repository/firestore-value.ts | 244 ++++++++++++++++-- .../repository/firestore-where.translator.ts | 50 ++-- 12 files changed, 670 insertions(+), 97 deletions(-) diff --git a/packages/rockets-repository-firestore/CHANGELOG.md b/packages/rockets-repository-firestore/CHANGELOG.md index 197d8abc3..2f9158340 100644 --- a/packages/rockets-repository-firestore/CHANGELOG.md +++ b/packages/rockets-repository-firestore/CHANGELOG.md @@ -13,8 +13,6 @@ - Soft delete / restore when `dateRemoved` or `deletedAt` exists on the entity. - `withDeleted` on find options. - Exported `ensureFirebaseAdminApp()` for shared Admin bootstrap with auth. -- Atomic create semantics: duplicate document ids are rejected instead of - silently overwritten. ### Changed @@ -23,10 +21,15 @@ `defineFirestoreRepository().forRoot()` delegates here. - Backend API: `query()` replaced by `queryBranch()` / `countBranch()` with structured query plans. +- Backend API now requires `create()`, providing atomic single-document create + semantics that reject duplicate ids. `createMany()` remains sequential and + non-atomic if a later create fails. - Document-id `EQ` / `IN` predicates compose with ownership and other filters; contradictory id predicates resolve to an empty result. - Generated ids are persisted and returned from `upsert`, and every order clause participates in deterministic sorting. - Admin SDK and in-memory backends share the same local filter and sort semantics for direct document lookups and post-filtered queries. +- Document-id `IN` accepts at most 500 ids and uses one Admin SDK `getAll` + request; pagination is applied after all requested ids are fetched. - README documents supported features and Firestore platform limits. diff --git a/packages/rockets-repository-firestore/README.md b/packages/rockets-repository-firestore/README.md index 6ebf54e1d..de65ea2a7 100644 --- a/packages/rockets-repository-firestore/README.md +++ b/packages/rockets-repository-firestore/README.md @@ -181,10 +181,20 @@ naming both supported column names. The in-memory backend and Admin SDK direct-document/post-filter paths match Firestore for missing versus explicit `null`, nested field paths, structural -equality, and range type checks. Local `orderBy` parity is guaranteed for -`null`, boolean, number, timestamp/`Date`, and string values. Ordering any -other Firestore value type fails with a descriptive error instead of returning -a misleading order. +equality, and ranges across types using Firestore's deterministic type order. +`between` is a client-side Rockets operator and intentionally requires its +value and bounds to share one scalar type. Local `orderBy` supports every +Firestore value type, including `NaN`, bytes, references, geographical points, +arrays, vectors, and maps. + +Document-id `IN` queries accept at most 500 ids and use one Admin SDK `getAll` +request. The direct-document path fetches all requested ids before applying +`skip` and `take`, so a page limit does not reduce document reads. + +`createMany()` writes documents sequentially and is not atomic: if a later +document id already exists, earlier documents from the same call remain +created. Atomic batched creation is intentionally deferred to a future backend +contract change. --- diff --git a/packages/rockets-repository-firestore/src/__tests__/firestore-repository.module.spec.ts b/packages/rockets-repository-firestore/src/__tests__/firestore-repository.module.spec.ts index 9b94b6c47..7a2f38bc6 100644 --- a/packages/rockets-repository-firestore/src/__tests__/firestore-repository.module.spec.ts +++ b/packages/rockets-repository-firestore/src/__tests__/firestore-repository.module.spec.ts @@ -14,6 +14,7 @@ class WidgetEntity { id!: string; title!: string; dateCreated!: Date; + note?: string | null; } class SoftWidgetEntity { @@ -31,7 +32,8 @@ class OwnedWidgetEntity { class OrderedWidgetEntity { id!: string; group!: string; - rank!: number; + rank?: number; + nested?: { rank: number }; } describe(FirestoreRepositoryModule.name, () => { @@ -272,6 +274,98 @@ describe(FirestoreRepositoryModule.name, () => { expect(rows.map((row) => row.id)).toEqual(['owned-a']); }); + it('returns no rows for an empty id IN without falling back to a collection scan', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + FirestoreRepositoryModule.forFeature( + [ + { + key: 'owned-widget', + entity: OwnedWidgetEntity, + collection: 'owned-widgets-empty-id-in', + }, + ], + { backend: new InMemoryFirestoreBackend() }, + ), + ], + }).compile(); + + const repo = moduleRef.get>( + getDynamicRepositoryToken('owned-widget'), + ); + + await repo.create({ id: 'existing', title: 'A', userId: 'actor-a' }); + + await expect( + repo.find({ where: Where.in('id', []) }), + ).resolves.toEqual([]); + }); + + it('rejects an empty id instead of dropping the id predicate', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + FirestoreRepositoryModule.forFeature( + [ + { + key: 'owned-widget', + entity: OwnedWidgetEntity, + collection: 'owned-widgets-empty-id', + }, + ], + { backend: new InMemoryFirestoreBackend() }, + ), + ], + }).compile(); + + const repo = moduleRef.get>( + getDynamicRepositoryToken('owned-widget'), + ); + + await repo.create({ id: 'existing', title: 'A', userId: 'actor-a' }); + + await expect( + repo.find({ + where: Where.and( + Where.in('id', ['']), + Where.eq('userId', 'actor-a'), + ), + }), + ).rejects.toThrow(/query the owned-widget repository/); + }); + + it('distinguishes missing fields from null for isNull and nin', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + FirestoreRepositoryModule.forFeature( + [ + { + key: 'widget', + entity: WidgetEntity, + collection: 'widgets-missing-null', + }, + ], + { backend: new InMemoryFirestoreBackend() }, + ), + ], + }).compile(); + + const repo = moduleRef.get>( + getDynamicRepositoryToken('widget'), + ); + + await repo.create({ id: 'missing', title: 'Missing' }); + await repo.create({ id: 'null', title: 'Null', note: null }); + await repo.create({ id: 'kept', title: 'Kept', note: 'kept' }); + await repo.create({ id: 'blocked', title: 'Blocked', note: 'blocked' }); + + await expect(repo.find({ where: Where.isNull('note') })).resolves.toEqual([ + expect.objectContaining({ id: 'null' }), + ]); + await expect( + repo.find({ where: Where.notIn('note', ['blocked']) }), + ).resolves.toEqual([expect.objectContaining({ id: 'kept' })]); + }); + it('returns the generated id from upsert', async () => { const moduleRef = await Test.createTestingModule({ imports: [ @@ -300,6 +394,43 @@ describe(FirestoreRepositoryModule.name, () => { ).resolves.toMatchObject({ id: result.id, title: 'Generated id' }); }); + it('merges repeated upserts with the same explicit id', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + FirestoreRepositoryModule.forFeature( + [ + { + key: 'widget', + entity: WidgetEntity, + collection: 'widget-explicit-upsert', + }, + ], + { backend: new InMemoryFirestoreBackend() }, + ), + ], + }).compile(); + + const repo = moduleRef.get>( + getDynamicRepositoryToken('widget'), + ); + const createdAt = new Date('2026-01-01T00:00:00.000Z'); + + await repo.upsert({ + id: 'stable-id', + title: 'First', + dateCreated: createdAt, + }); + await repo.upsert({ id: 'stable-id', title: 'Second' }); + + await expect(repo.find()).resolves.toEqual([ + expect.objectContaining({ + id: 'stable-id', + title: 'Second', + dateCreated: createdAt, + }), + ]); + }); + it('rejects create when the document id already exists', async () => { const moduleRef = await Test.createTestingModule({ imports: [ @@ -362,4 +493,92 @@ describe(FirestoreRepositoryModule.name, () => { expect(rows.map((row) => row.id)).toEqual(['rank-1', 'rank-2']); }); + + it('orders descending by a nested field path', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + FirestoreRepositoryModule.forFeature( + [ + { + key: 'ordered-widget', + entity: OrderedWidgetEntity, + collection: 'ordered-widgets-nested', + }, + ], + { backend: new InMemoryFirestoreBackend() }, + ), + ], + }).compile(); + + const repo = moduleRef.get>( + getDynamicRepositoryToken('ordered-widget'), + ); + + await repo.create({ id: 'low', group: 'same', nested: { rank: 1 } }); + await repo.create({ id: 'high', group: 'same', nested: { rank: 2 } }); + + const rows = await repo.find({ + order: [{ field: 'nested.rank', order: SortOrder.DESC }], + }); + + expect(rows.map((row) => row.id)).toEqual(['high', 'low']); + }); + + it('excludes documents missing the ordered field', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + FirestoreRepositoryModule.forFeature( + [ + { + key: 'ordered-widget', + entity: OrderedWidgetEntity, + collection: 'ordered-widgets-missing', + }, + ], + { backend: new InMemoryFirestoreBackend() }, + ), + ], + }).compile(); + + const repo = moduleRef.get>( + getDynamicRepositoryToken('ordered-widget'), + ); + + await repo.create({ id: 'missing', group: 'same' }); + await repo.create({ id: 'present', group: 'same', rank: 1 }); + + const rows = await repo.find({ + order: [{ field: 'rank', order: SortOrder.ASC }], + }); + + expect(rows.map((row) => row.id)).toEqual(['present']); + }); + + it('treats a missing soft-delete field as live by default', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + FirestoreRepositoryModule.forFeature( + [ + { + key: 'soft-widget', + entity: SoftWidgetEntity, + collection: 'soft-widgets-missing-field', + softDeleteField: 'dateRemoved', + }, + ], + { backend: new InMemoryFirestoreBackend() }, + ), + ], + }).compile(); + + const repo = moduleRef.get>( + getDynamicRepositoryToken('soft-widget'), + ); + + await repo.create({ id: 'live', title: 'Live' }); + + await expect(repo.find()).resolves.toEqual([ + expect.objectContaining({ id: 'live' }), + ]); + }); }); diff --git a/packages/rockets-repository-firestore/src/__tests__/firestore-value.spec.ts b/packages/rockets-repository-firestore/src/__tests__/firestore-value.spec.ts index 8dfe101df..474ef870e 100644 --- a/packages/rockets-repository-firestore/src/__tests__/firestore-value.spec.ts +++ b/packages/rockets-repository-firestore/src/__tests__/firestore-value.spec.ts @@ -3,11 +3,43 @@ import { describe, expect, it } from 'vitest'; import { applyFirestoreFilters } from '../repository/firestore-row-filter'; import { sortFirestoreRows } from '../repository/firestore-sort'; import { + compareFirestoreValues, firestoreValuesEqual, normalizeFirestoreValue, readFirestoreField, } from '../repository/firestore-value'; +class TimestampStub { + constructor(private readonly date: Date) {} + + toDate(): Date { + return this.date; + } + + isEqual(other: unknown): boolean { + return ( + other instanceof TimestampStub && + other.toDate().getTime() === this.date.getTime() + ); + } +} + +class DocumentReferenceStub { + constructor(readonly path: string) {} +} + +class GeoPointStub { + constructor(readonly latitude: number, readonly longitude: number) {} +} + +class VectorValueStub { + constructor(private readonly values: readonly number[]) {} + + toArray(): number[] { + return [...this.values]; + } +} + describe('local Firestore value semantics', () => { it('distinguishes missing nested fields from explicit null', () => { expect( @@ -28,7 +60,7 @@ describe('local Firestore value semantics', () => { ).toEqual(['null']); }); - it('excludes missing, null, and cross-type values from inequalities', () => { + it('excludes missing and null values while comparing ranges across types', () => { const rows = [ { id: 'missing' }, { id: 'null', value: null }, @@ -39,7 +71,7 @@ describe('local Firestore value semantics', () => { applyFirestoreFilters(rows, [{ field: 'value', op: '>', value: 1 }]).map( (row) => row.id, ), - ).toEqual(['number']); + ).toEqual(['string', 'number']); expect( applyFirestoreFilters(rows, [{ field: 'value', op: '!=', value: 3 }]).map( (row) => row.id, @@ -63,6 +95,64 @@ describe('local Firestore value semantics', () => { ).toBe(true); }); + it('compares timestamp-like SDK values with dates before SDK equality', () => { + const date = new Date('2026-01-01T00:00:00.000Z'); + const timestamp = new TimestampStub(date); + + expect(firestoreValuesEqual(date, timestamp)).toBe(true); + expect(firestoreValuesEqual(timestamp, date)).toBe(true); + }); + + it('orders NaN below every other number with a consistent comparator', () => { + expect(compareFirestoreValues(Number.NaN, 5)).toBeLessThan(0); + expect(compareFirestoreValues(5, Number.NaN)).toBeGreaterThan(0); + expect(compareFirestoreValues(Number.NaN, Number.NaN)).toBe(0); + }); + + it('orders every Firestore value type', () => { + const values = [ + { z: 1 }, + new VectorValueStub([1]), + [1], + new GeoPointStub(1, 2), + new DocumentReferenceStub('widgets/one'), + new Uint8Array([1]), + 'a', + new Date(1), + 1, + true, + null, + ]; + + expect([...values].sort(compareFirestoreValues)).toEqual([ + null, + true, + 1, + new Date(1), + 'a', + new Uint8Array([1]), + new DocumentReferenceStub('widgets/one'), + new GeoPointStub(1, 2), + [1], + new VectorValueStub([1]), + { z: 1 }, + ]); + }); + + it('orders compound Firestore values by their documented contents', () => { + expect(compareFirestoreValues([1, 2], [1, 2, 3])).toBeLessThan(0); + expect(compareFirestoreValues({ a: 1 }, { a: 2 })).toBeLessThan(0); + expect( + compareFirestoreValues(new GeoPointStub(1, 9), new GeoPointStub(2, 0)), + ).toBeLessThan(0); + expect( + compareFirestoreValues( + new VectorValueStub([999]), + new VectorValueStub([0, 0]), + ), + ).toBeLessThan(0); + }); + it('orders supported scalars and excludes missing ordered fields', () => { const rows = [ { id: 'missing' }, @@ -78,6 +168,18 @@ describe('local Firestore value semantics', () => { ).toEqual(['null', 'true', 'number', 'string']); }); + it('names the ordered field when a value cannot be ordered locally', () => { + expect(() => + sortFirestoreRows( + [ + { id: 'one', unsupported: Symbol('one') }, + { id: 'two', unsupported: Symbol('two') }, + ], + [{ field: 'unsupported', direction: 'asc' }], + ), + ).toThrow(/field "unsupported"/); + }); + it('normalizes timestamps recursively', () => { const date = new Date('2026-01-01T00:00:00.000Z'); expect( diff --git a/packages/rockets-repository-firestore/src/__tests__/firestore-where.translator.spec.ts b/packages/rockets-repository-firestore/src/__tests__/firestore-where.translator.spec.ts index 10def99c7..c1b6a7252 100644 --- a/packages/rockets-repository-firestore/src/__tests__/firestore-where.translator.spec.ts +++ b/packages/rockets-repository-firestore/src/__tests__/firestore-where.translator.spec.ts @@ -32,6 +32,29 @@ describe('firestore-where.translator', () => { expect(branch.filters).toEqual([]); }); + it('rejects empty and non-string document ids', () => { + expect(() => translateDnfBranch([Where.eq('id', '')])).toThrow( + /non-empty string/, + ); + expect(() => translateDnfBranch([Where.eq('id', null)])).toThrow( + /non-empty string/, + ); + expect(() => translateDnfBranch([Where.in('id', [''])])).toThrow( + /non-empty string/, + ); + }); + + it('caps direct document lookups at 500 ids', () => { + expect(() => + translateDnfBranch([ + Where.in( + 'id', + Array.from({ length: 501 }, (_, index) => `doc-${index}`), + ), + ]), + ).toThrow(/at most 500/); + }); + it('intersects multiple document-id predicates in an AND branch', () => { const branch = translateDnfBranch([ Where.in('id', ['doc-1', 'doc-2']), diff --git a/packages/rockets-repository-firestore/src/backends/admin-firestore.backend.ts b/packages/rockets-repository-firestore/src/backends/admin-firestore.backend.ts index 76607aa65..527889d5e 100644 --- a/packages/rockets-repository-firestore/src/backends/admin-firestore.backend.ts +++ b/packages/rockets-repository-firestore/src/backends/admin-firestore.backend.ts @@ -70,7 +70,7 @@ export class AdminFirestoreBackend implements FirestoreBackend { const skip = options.skip ?? 0; const take = options.take; - if (branch.documentId || branch.documentIds !== undefined) { + if (branch.documentId !== undefined || branch.documentIds !== undefined) { const rows = await this.loadBranchRows(collection, branch); const filtered = applyFirestorePostFilters( applyFirestoreFilters(rows, branch.filters), @@ -115,7 +115,7 @@ export class AdminFirestoreBackend implements FirestoreBackend { ): Promise { if ( branch.postFilters.length > 0 || - branch.documentId || + branch.documentId !== undefined || branch.documentIds !== undefined ) { const rows = await this.loadBranchRows(collection, branch); @@ -134,17 +134,22 @@ export class AdminFirestoreBackend implements FirestoreBackend { collection: string, branch: FirestoreQueryBranch, ): Promise[]> { - if (branch.documentId) { + if (branch.documentId !== undefined) { const row = await this.get(collection, branch.documentId); return row ? [row] : []; } if (branch.documentIds !== undefined) { + if (branch.documentIds.length === 0) return []; + const collectionRef = this.db().collection(collection); + const refs = branch.documentIds.map((documentId) => + collectionRef.doc(documentId), + ); + const snapshots = await this.db().getAll(...refs); const rows: Record[] = []; - for (const documentId of branch.documentIds) { - const row = await this.get(collection, documentId); - if (row) { - rows.push(row); + for (const snapshot of snapshots) { + if (snapshot.exists) { + rows.push(this.normalise(snapshot.data(), snapshot.id)); } } return rows; diff --git a/packages/rockets-repository-firestore/src/backends/in-memory-firestore.backend.ts b/packages/rockets-repository-firestore/src/backends/in-memory-firestore.backend.ts index 24b979ec0..29f80ecb1 100644 --- a/packages/rockets-repository-firestore/src/backends/in-memory-firestore.backend.ts +++ b/packages/rockets-repository-firestore/src/backends/in-memory-firestore.backend.ts @@ -106,7 +106,7 @@ export class InMemoryFirestoreBackend implements FirestoreBackend { collection: string, branch: FirestoreQueryBranch, ): Promise[]> { - if (branch.documentId) { + if (branch.documentId !== undefined) { const row = await this.get(collection, branch.documentId); return row ? [row] : []; } diff --git a/packages/rockets-repository-firestore/src/repository/firestore-post-filter.ts b/packages/rockets-repository-firestore/src/repository/firestore-post-filter.ts index 128bf270d..920d2dc1f 100644 --- a/packages/rockets-repository-firestore/src/repository/firestore-post-filter.ts +++ b/packages/rockets-repository-firestore/src/repository/firestore-post-filter.ts @@ -2,6 +2,7 @@ import type { FirestorePostFilter } from '../interfaces/firestore-query.interfac import { compareFirestoreValues, firestoreValuesEqual, + hasNonNullFirestoreValue, readFirestoreField, sameFirestoreRangeType, } from './firestore-value'; @@ -10,10 +11,6 @@ export function applyFirestorePostFilters( rows: readonly Record[], postFilters: readonly FirestorePostFilter[], ): Record[] { - if (postFilters.length === 0) { - return [...rows]; - } - return rows.filter((row) => postFilters.every((filter) => matchesPostFilter(row, filter)), ); @@ -45,8 +42,7 @@ function matchesPostFilter( return typeof value !== 'string' || !value.endsWith(filter.value); case 'nin': return ( - field.exists && - value !== null && + hasNonNullFirestoreValue(field) && !filter.values.some((candidate) => firestoreValuesEqual(candidate, value), ) @@ -72,6 +68,8 @@ function containsValue(fieldValue: unknown, needle: string): boolean { } function compareBetween(value: unknown, min: unknown, max: unknown): boolean { + // BETWEEN is a Rockets-only client operator, so it deliberately requires + // all three values to share one scalar range type. return ( value !== null && sameFirestoreRangeType(value, min) && diff --git a/packages/rockets-repository-firestore/src/repository/firestore-row-filter.ts b/packages/rockets-repository-firestore/src/repository/firestore-row-filter.ts index b304c58b0..3970716a1 100644 --- a/packages/rockets-repository-firestore/src/repository/firestore-row-filter.ts +++ b/packages/rockets-repository-firestore/src/repository/firestore-row-filter.ts @@ -1,17 +1,30 @@ -import type { FirestoreQueryFilter } from '../interfaces/firestore-query.interface'; +import type { + FirestoreFilterOp, + FirestoreQueryFilter, +} from '../interfaces/firestore-query.interface'; import { compareFirestoreValues, firestoreValuesEqual, + hasNonNullFirestoreValue, readFirestoreField, - sameFirestoreRangeType, + type FirestoreFieldValue, } from './firestore-value'; +type FirestoreRangeOp = Extract' | '>='>; + +const RANGE_PREDICATES: Record boolean> = + { + '<': (result) => result < 0, + '<=': (result) => result <= 0, + '>': (result) => result > 0, + '>=': (result) => result >= 0, + }; + /** Apply the non-post-filter predicates that Firestore would normally execute. */ export function applyFirestoreFilters( rows: readonly Record[], filters: readonly FirestoreQueryFilter[], ): Record[] { - if (filters.length === 0) return [...rows]; return rows.filter((row) => filters.every((filter) => matches(row, filter))); } @@ -26,37 +39,18 @@ function matches( return field.exists && firestoreValuesEqual(value, filter.value); case '!=': return ( - field.exists && - value !== null && + hasNonNullFirestoreValue(field) && !firestoreValuesEqual(value, filter.value) ); case '<': - return rangeMatch( - field.exists, - value, - filter.value, - (result) => result < 0, - ); case '<=': - return rangeMatch( - field.exists, - value, - filter.value, - (result) => result <= 0, - ); case '>': - return rangeMatch( - field.exists, - value, - filter.value, - (result) => result > 0, - ); case '>=': return rangeMatch( - field.exists, - value, + field, filter.value, - (result) => result >= 0, + RANGE_PREDICATES[filter.op], + filter.field, ); case 'in': return ( @@ -66,8 +60,7 @@ function matches( ); case 'not-in': return ( - field.exists && - value !== null && + hasNonNullFirestoreValue(field) && Array.isArray(filter.value) && !filter.value.some((candidate) => firestoreValuesEqual(value, candidate), @@ -87,16 +80,14 @@ function matches( } function rangeMatch( - exists: boolean, - value: unknown, + field: FirestoreFieldValue, candidate: unknown, predicate: (result: number) => boolean, + fieldName: string, ): boolean { return ( - exists && - value !== null && + hasNonNullFirestoreValue(field) && candidate !== null && - sameFirestoreRangeType(value, candidate) && - predicate(compareFirestoreValues(value, candidate)) + predicate(compareFirestoreValues(field.value, candidate, fieldName)) ); } diff --git a/packages/rockets-repository-firestore/src/repository/firestore-sort.ts b/packages/rockets-repository-firestore/src/repository/firestore-sort.ts index 2a2e903d3..6b9e9cc05 100644 --- a/packages/rockets-repository-firestore/src/repository/firestore-sort.ts +++ b/packages/rockets-repository-firestore/src/repository/firestore-sort.ts @@ -12,11 +12,15 @@ export function sortFirestoreRows( orderBy.every((clause) => readFirestoreField(row, clause.field).exists), ); - return [...present].sort((left, right) => { + return present.sort((left, right) => { for (const clause of orderBy) { const leftValue = readFirestoreField(left, clause.field).value; const rightValue = readFirestoreField(right, clause.field).value; - const result = compareFirestoreValues(leftValue, rightValue); + const result = compareFirestoreValues( + leftValue, + rightValue, + clause.field, + ); if (result !== 0) { return clause.direction === 'desc' ? -result : result; } diff --git a/packages/rockets-repository-firestore/src/repository/firestore-value.ts b/packages/rockets-repository-firestore/src/repository/firestore-value.ts index f6356f6de..364298446 100644 --- a/packages/rockets-repository-firestore/src/repository/firestore-value.ts +++ b/packages/rockets-repository-firestore/src/repository/firestore-value.ts @@ -1,7 +1,23 @@ -export interface FirestoreFieldValue { - readonly exists: boolean; +export type FirestoreFieldValue = + | { readonly exists: false; readonly value: undefined } + | { readonly exists: true; readonly value: unknown }; + +type SortableFirestoreValue = { + readonly rank: number; + readonly kind: + | 'null' + | 'boolean' + | 'number' + | 'timestamp' + | 'string' + | 'bytes' + | 'reference' + | 'geopoint' + | 'array' + | 'vector' + | 'map'; readonly value: unknown; -} +}; /** Read a dotted Firestore field path while preserving missing-vs-null. */ export function readFirestoreField( @@ -22,31 +38,102 @@ export function readFirestoreField( return { exists: true, value: current }; } -/** Compare values in the scalar subset supported by local ordering. */ -export function compareFirestoreValues(left: unknown, right: unknown): number { +export function hasNonNullFirestoreValue( + field: FirestoreFieldValue, +): field is { readonly exists: true; readonly value: {} } { + return field.exists && field.value !== null; +} + +/** Compare values using Firestore's total type and within-type ordering. */ +export function compareFirestoreValues( + left: unknown, + right: unknown, + field?: string, +): number { const a = sortable(left); const b = sortable(right); if (!a || !b) { + const context = field ? ` for field "${field}"` : ''; throw new Error( - `Firestore local ordering supports only null, boolean, number, timestamp, and string values; received ${describeValue( + `Firestore local ordering${context} does not support ${describeValue( !a ? left : right, - )}.`, + )} values.`, ); } if (a.rank !== b.rank) return a.rank < b.rank ? -1 : 1; - if (a.value === b.value) return 0; - return a.value < b.value ? -1 : 1; + + switch (a.kind) { + case 'null': + return 0; + case 'boolean': + case 'number': + case 'timestamp': + return compareNumbers(a.value as number, b.value as number); + case 'string': + return compareStrings(a.value as string, b.value as string); + case 'bytes': + return compareSequences( + a.value as Uint8Array, + b.value as Uint8Array, + compareNumbers, + ); + case 'reference': + return compareSequences( + a.value as readonly string[], + b.value as readonly string[], + compareStrings, + ); + case 'geopoint': + return compareSequences( + a.value as readonly number[], + b.value as readonly number[], + compareNumbers, + ); + case 'array': + return compareSequences( + a.value as readonly unknown[], + b.value as readonly unknown[], + (leftValue, rightValue) => + compareFirestoreValues(leftValue, rightValue, field), + ); + case 'vector': { + const leftVector = a.value as readonly number[]; + const rightVector = b.value as readonly number[]; + const dimensionOrder = compareNumbers( + leftVector.length, + rightVector.length, + ); + return dimensionOrder === 0 + ? compareSequences(leftVector, rightVector, compareNumbers) + : dimensionOrder; + } + case 'map': + return compareMaps( + a.value as Record, + b.value as Record, + field, + ); + } } /** Structural equality for Firestore values used by local query paths. */ export function firestoreValuesEqual(left: unknown, right: unknown): boolean { if (Object.is(left, right)) return true; - if (isEqualValue(left)) return left.isEqual(right); - if (isEqualValue(right)) return right.isEqual(left); const leftDate = asDate(left); const rightDate = asDate(right); - if (leftDate && rightDate) return leftDate.getTime() === rightDate.getTime(); + if (leftDate || rightDate) { + if (leftDate && rightDate) { + return leftDate.getTime() === rightDate.getTime(); + } + if (isEqualValue(left) && isEqualValue(right)) { + return left.isEqual(right); + } + return false; + } + + if (isEqualValue(left)) return left.isEqual(right); + if (isEqualValue(right)) return right.isEqual(left); if (isBytes(left) && isBytes(right)) { if (left.byteLength !== right.byteLength) return false; @@ -90,23 +177,96 @@ export function normalizeFirestoreValue(value: unknown): unknown { } export function sameFirestoreRangeType(left: unknown, right: unknown): boolean { - return ( - scalarKind(left) !== undefined && scalarKind(left) === scalarKind(right) - ); + const leftKind = scalarKind(left); + return leftKind !== undefined && leftKind === scalarKind(right); } -function sortable( - value: unknown, -): { rank: number; value: number | string } | undefined { - if (value === null) return { rank: 0, value: 0 }; - if (typeof value === 'boolean') return { rank: 1, value: value ? 1 : 0 }; - if (typeof value === 'number') return { rank: 2, value }; +function sortable(value: unknown): SortableFirestoreValue | undefined { + if (value === null) return { rank: 0, kind: 'null', value: 0 }; + if (typeof value === 'boolean') { + return { rank: 1, kind: 'boolean', value: value ? 1 : 0 }; + } + if (typeof value === 'number') { + return { rank: 2, kind: 'number', value }; + } const date = asDate(value); - if (date) return { rank: 3, value: date.getTime() }; - if (typeof value === 'string') return { rank: 4, value }; + if (date) return { rank: 3, kind: 'timestamp', value: date.getTime() }; + if (typeof value === 'string') { + return { rank: 4, kind: 'string', value }; + } + if (isBytes(value)) return { rank: 5, kind: 'bytes', value }; + if (isDocumentReference(value)) { + return { rank: 6, kind: 'reference', value: value.path.split('/') }; + } + if (isGeoPoint(value)) { + return { + rank: 7, + kind: 'geopoint', + value: [value.latitude, value.longitude], + }; + } + if (Array.isArray(value)) return { rank: 8, kind: 'array', value }; + if (isVectorValue(value)) { + return { rank: 9, kind: 'vector', value: value.toArray() }; + } + if (isPlainMap(value)) return { rank: 10, kind: 'map', value }; return undefined; } +function compareMaps( + left: Record, + right: Record, + field?: string, +): number { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + const commonLength = Math.min(leftKeys.length, rightKeys.length); + + for (let index = 0; index < commonLength; index += 1) { + const leftKey = leftKeys[index]!; + const rightKey = rightKeys[index]!; + const keyOrder = compareStrings(leftKey, rightKey); + if (keyOrder !== 0) return keyOrder; + const valueOrder = compareFirestoreValues( + left[leftKey], + right[rightKey], + field, + ); + if (valueOrder !== 0) return valueOrder; + } + + return compareNumbers(leftKeys.length, rightKeys.length); +} + +function compareSequences( + left: ArrayLike, + right: ArrayLike, + compare: (leftValue: T, rightValue: T) => number, +): number { + const commonLength = Math.min(left.length, right.length); + for (let index = 0; index < commonLength; index += 1) { + const result = compare(left[index]!, right[index]!); + if (result !== 0) return result; + } + return compareNumbers(left.length, right.length); +} + +function compareNumbers(left: number, right: number): number { + const leftNaN = Number.isNaN(left); + const rightNaN = Number.isNaN(right); + if (leftNaN || rightNaN) { + if (leftNaN && rightNaN) return 0; + return leftNaN ? -1 : 1; + } + if (left === right) return 0; + return left < right ? -1 : 1; +} + +function compareStrings(left: string, right: string): number { + if (left === right) return 0; + return left < right ? -1 : 1; +} + function scalarKind(value: unknown): string | undefined { if (typeof value === 'boolean') return 'boolean'; if (typeof value === 'number') return 'number'; @@ -140,6 +300,44 @@ function isBytes(value: unknown): value is Uint8Array { return value instanceof Uint8Array; } +function isDocumentReference( + value: unknown, +): value is { readonly path: string } { + return ( + typeof value === 'object' && + value !== null && + !isPlainMap(value) && + 'path' in value && + typeof value.path === 'string' + ); +} + +function isGeoPoint( + value: unknown, +): value is { readonly latitude: number; readonly longitude: number } { + return ( + typeof value === 'object' && + value !== null && + !isPlainMap(value) && + 'latitude' in value && + typeof value.latitude === 'number' && + 'longitude' in value && + typeof value.longitude === 'number' + ); +} + +function isVectorValue( + value: unknown, +): value is { toArray(): readonly number[] } { + return ( + typeof value === 'object' && + value !== null && + !isPlainMap(value) && + 'toArray' in value && + typeof value.toArray === 'function' + ); +} + function isEqualValue( value: unknown, ): value is { isEqual(other: unknown): boolean } { diff --git a/packages/rockets-repository-firestore/src/repository/firestore-where.translator.ts b/packages/rockets-repository-firestore/src/repository/firestore-where.translator.ts index e6edbbbdc..2c08e4803 100644 --- a/packages/rockets-repository-firestore/src/repository/firestore-where.translator.ts +++ b/packages/rockets-repository-firestore/src/repository/firestore-where.translator.ts @@ -26,6 +26,8 @@ const RANGE_OPS: ReadonlySet = new Set([ '!=', ]); +const MAX_DIRECT_DOCUMENT_IDS = 500; + /** Builds one conjunctive branch from DNF conditions (use with `RepositoryAdapter.toDnf`). */ export function translateDnfBranch( conditions: readonly WhereCondition[], @@ -78,9 +80,10 @@ function mergeAndBranch( for (const child of conditions) { if (isWhereCondition(child)) { const branch = translateCondition(child); - const childDocumentIds = branch.documentId - ? [branch.documentId] - : branch.documentIds; + const childDocumentIds = + branch.documentId !== undefined + ? [branch.documentId] + : branch.documentIds; if (childDocumentIds !== undefined) { candidateDocumentIds = intersectDocumentIds( candidateDocumentIds, @@ -98,20 +101,22 @@ function mergeAndBranch( assertFirestoreFilterRules(merged); - const documentSelector = - candidateDocumentIds === undefined - ? {} - : candidateDocumentIds.length === 1 - ? { documentId: candidateDocumentIds[0] } - : { documentIds: candidateDocumentIds }; - return { - ...documentSelector, + ...selectDocuments(candidateDocumentIds), filters: merged, postFilters, }; } +function selectDocuments( + candidateDocumentIds: readonly string[] | undefined, +): Pick { + if (candidateDocumentIds === undefined) return {}; + return candidateDocumentIds.length === 1 + ? { documentId: candidateDocumentIds[0] } + : { documentIds: candidateDocumentIds }; +} + function intersectDocumentIds( current: readonly string[] | undefined, incoming: readonly string[], @@ -167,14 +172,20 @@ function translateIdCondition(condition: WhereCondition): FirestoreQueryBranch { switch (condition.operator) { case WhereOperator.EQ: return { - documentId: String(readScalarValue(condition as WhereConditionScalar)), + documentId: readDocumentId( + readScalarValue(condition as WhereConditionScalar), + ), filters: [], postFilters: [], }; case WhereOperator.IN: { - const values = asArray(readArrayValue(condition)).map((value) => - String(value), - ); + const rawValues = asArray(readArrayValue(condition)); + if (rawValues.length > MAX_DIRECT_DOCUMENT_IDS) { + throw new Error( + `Firestore adapter: id "in" supports at most ${MAX_DIRECT_DOCUMENT_IDS} document ids (received ${rawValues.length}).`, + ); + } + const values = rawValues.map(readDocumentId); return { documentIds: values, filters: [], @@ -186,6 +197,15 @@ function translateIdCondition(condition: WhereCondition): FirestoreQueryBranch { } } +function readDocumentId(value: unknown): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error( + 'Firestore adapter: document ids must be non-empty string values.', + ); + } + return value; +} + function translateNullaryCondition( condition: WhereCondition, ): FirestoreQueryBranch { From 50053156d4ae899f3fa09797436df2de9548f291 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 10 Aug 2026 13:25:25 -0400 Subject: [PATCH 4/4] chore: enforce release readiness (#35) --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/ci-pr-test.yml | 2 + .github/workflows/release-readiness.yml | 32 + CHANGELOG.md | 4 +- CONFIGURATION.md | 131 +- MIGRATION-SUMMARY.md | 123 - README.md | 199 +- examples/sample-code-review/README.md | 4 +- .../sample-code-review/apps/api/package.json | 6 +- .../apps/api/src/app.module.ts | 2 +- .../src/auth-api-key/define-api-key-auth.ts | 14 +- .../repository/define-typeorm-repository.ts | 27 - ...st.e2e.config.ts => vitest.e2e.config.mts} | 7 +- examples/sample-server-auth/package.json | 4 +- examples/sample-server-auth/src/app.module.ts | 12 +- .../repository/define-typeorm-repository.ts | 47 - ...st.e2e.config.ts => vitest.e2e.config.mts} | 4 +- examples/sample-server/README.md | 1 - examples/sample-server/package.json | 4 +- examples/sample-server/src/app.module.ts | 58 +- .../src/auth/define-sample-auth.ts | 14 +- examples/sample-server/src/main.ts | 4 +- .../repository/define-typeorm-repository.ts | 46 - ...st.e2e.config.ts => vitest.e2e.config.mts} | 4 +- firebase.json | 14 + firestore.rules | 8 + package.json | 17 +- .../firestore-backend.emulator-spec.ts | 76 + .../tsconfig.json | 2 +- .../rockets-server-auth/swagger/swagger.json | 1982 ---------- scripts/verify-package-artifacts.mjs | 103 + vitest.config.ts => vitest.config.mts | 15 +- vitest.firestore.config.mts | 16 + vitest.shared.ts => vitest.shared.mts | 0 yarn.lock | 3362 ++++++++++++++++- 35 files changed, 3749 insertions(+), 2597 deletions(-) create mode 100644 .github/workflows/release-readiness.yml delete mode 100644 MIGRATION-SUMMARY.md delete mode 100644 examples/sample-code-review/apps/api/src/repository/define-typeorm-repository.ts rename examples/sample-code-review/apps/api/{vitest.e2e.config.ts => vitest.e2e.config.mts} (84%) delete mode 100644 examples/sample-server-auth/src/repository/define-typeorm-repository.ts rename examples/sample-server-auth/{vitest.e2e.config.ts => vitest.e2e.config.mts} (87%) delete mode 100644 examples/sample-server/src/repository/define-typeorm-repository.ts rename examples/sample-server/{vitest.e2e.config.ts => vitest.e2e.config.mts} (84%) create mode 100644 firebase.json create mode 100644 firestore.rules create mode 100644 packages/rockets-repository-firestore/src/__tests__/firestore-backend.emulator-spec.ts delete mode 100644 packages/rockets-server-auth/swagger/swagger.json create mode 100644 scripts/verify-package-artifacts.mjs rename vitest.config.ts => vitest.config.mts (88%) create mode 100644 vitest.firestore.config.mts rename vitest.shared.ts => vitest.shared.mts (100%) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3b3f4f651..693c19c36 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -55,4 +55,4 @@ body: - type: input attributes: label: NestJS version - placeholder: "^11.0.0" + placeholder: "12.0.0-alpha.5" diff --git a/.github/workflows/ci-pr-test.yml b/.github/workflows/ci-pr-test.yml index d6aa3ff32..3dbfe056b 100644 --- a/.github/workflows/ci-pr-test.yml +++ b/.github/workflows/ci-pr-test.yml @@ -37,6 +37,8 @@ jobs: run: yarn lint:all - name: Typecheck tests run: yarn typecheck:spec + - name: Verify native Vitest config loading + run: yarn test:config-native - name: Unit tests run: yarn test:ci - name: E2E coverage diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml new file mode 100644 index 000000000..62bd79a8f --- /dev/null +++ b/.github/workflows/release-readiness.yml @@ -0,0 +1,32 @@ +name: release-readiness + +on: + pull_request: + branches: ['main'] + push: + branches: ['main'] + +jobs: + release-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Enable Corepack + run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: yarn + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - name: Cache Firestore emulator + uses: actions/cache@v4 + with: + path: ~/.cache/firebase/emulators + key: firestore-emulator-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + - name: Install + run: corepack yarn install --immutable + - name: Release readiness + run: corepack yarn release:check diff --git a/CHANGELOG.md b/CHANGELOG.md index be8beb5ae..a2bec985e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,9 @@ Vitest 4. What this deletes, permanently: dependency. The root `scripts/` directory no longer exists. The setup follows Vitest 4's official monorepo guidance — the -`projects` model: the root `vitest.config.ts` declares every project +`projects` model: the root `vitest.config.mts` declares every project (`unit`, `e2e-packages`, one per example workspace) and -`vitest.shared.ts` carries the shared plugin/settings (deliberately not +`vitest.shared.mts` carries the shared plugin/settings (deliberately not the root config — merging a projects-bearing config into a project is a documented pitfall). Example configs are `defineProject` + `mergeConfig(shared, …)`; one SWC block exists instead of five. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index d35552996..1e4ca5aa8 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -1,7 +1,7 @@ # Rockets — Configuration Entry Point -> Configuration reference for the **v2 DSL** (shipped). Field names below match -> the current `packages/*/src/**` (code wins over READMEs). The v2 redesign +> Configuration reference for the **1.0-preview DSL**. Field names below match +> the current `packages/*/src/**` (code wins over prose). The original DSL > rationale, convertibility proof, and change-set live in §12. > Diagrams are Mermaid — they render on GitHub and in most Markdown viewers. @@ -11,10 +11,11 @@ You never hand NestJS a tree of modules. You write **declarative bundles** (`defineResource`, `defineSubResource`, `defineModuleResource`) and a few -top-level fields (`repository`, `userMetadata`, `auth`), pass them to **one** -`RocketsModule.forRoot({...})`, and the module-definition transform converts -that into a single global `DynamicModule` (controllers, providers, repository -tokens, CQRS handlers, Swagger). +top-level fields (`repository`, `userMetadata`, `auth`), pass them to +`createServer({...})`, and the module-definition transform converts that into a +single global `DynamicModule` (controllers, providers, repository tokens, CQRS +handlers, Swagger). Use `RocketsModule.forRoot({...})` directly when a larger +Nest host module must compose Rockets with other imports or providers. ```mermaid flowchart LR @@ -24,7 +25,8 @@ flowchart LR M["defineModuleResource()"] OPT["repository / userMetadata / auth / swagger"] end - YOU --> FORROOT["RocketsModule.forRoot( ... )"] + YOU --> CREATE["createServer( ... )"] + CREATE --> FORROOT["RocketsModule.forRoot( ... )"] FORROOT --> XFORM["definitionTransform\n(build time)"] XFORM --> PLAN["buildAppRegistrationPlan()"] PLAN --> DM["one global DynamicModule\nimports / providers / controllers / exports"] @@ -34,12 +36,13 @@ flowchart LR **Two layers, one surface.** `@concepta/rockets` (server) is a thin presentation layer over `@concepta/rockets-core`. Server adds the `MeController`, the global guard opt-in, and the `auth` chain; core does the actual resource→module -conversion. You only ever call `RocketsModule.forRoot` (server). Core's -`forRootAsync` is called internally. +conversion. `createServer` is the canonical definition-first facade; +`RocketsModule.forRoot` is the lower-level composition surface. Core's +`forRootAsync` is called internally in either case. --- -## 1. The entry point — `RocketsModule.forRoot` / `forRootAsync` +## 1. The entry point — `createServer` and `RocketsModule` The options object is split by NestJS's `ConfigurableModuleBuilder` into two buckets with very different lifecycles: @@ -58,20 +61,24 @@ buckets with very different lifecycles: | Field | Type | Req? | Default | Purpose | |---|---|---|---|---| | `resources` | `ReadonlyArray` | optional | `[]` | The feature bundles (CRUD + module + sub flattened). | -| `repository` | `RepositoryModuleInterface \| RepositoryBootstrap` | optional* | — | Root persistence adapter (TypeORM/Firestore/…). | -| `userMetadata` | `RocketsUserMetadataConfig` | **required at runtime** | — | `/me` entity + DTOs. Omit → throws when the metadata DTO token resolves. | +| `repository` | `RepositoryModuleInterface \| RepositoryBootstrap` | optional† | — | Root persistence adapter (TypeORM/Firestore/…). | +| `userMetadata` | `RocketsUserMetadataConfig` | optional | — | `/me` entity + DTOs. When omitted, Rockets does not mount `/me` or register metadata handlers/providers. | | `auth` | `AuthBootstrap \| AuthBootstrap[]` | optional | `[]` | Auth chain (external adapter and/or built-in). | | `swagger` | `SwaggerUiOptionsInterface` | optional | — | Doc builder + UI. The **only** runtime field forwarded to core. | | `settings` | `RocketsSettingsInterface` (empty today) | optional | — | Reserved; no fields yet. | | `handlers` | `{ upsertUserMetadata?, getUserMetadata? }` | optional | built-ins | Override the user-metadata CQRS handlers. | -| `enableGlobalGuard` | `boolean` | optional | **on** (opt-out) | Register `AuthServerGuard` as `APP_GUARD` unless `=== false`. | +| `enableGlobalGuard` | `boolean` | optional | **on‡** | Register `AuthServerGuard` as `APP_GUARD` unless `=== false`. | | `disableController` | `{ me?: boolean }` | optional | `{}` | Disable built-in `MeController`. | | `controllers` | `DynamicModule['controllers']` | optional | — | Replace the auto controller set. | | `global` | `boolean` | optional | **forced `true`** | `forRoot` always makes the module global. | -\* `repository` is optional in the type but persistence resolution throws if an +† `repository` is optional in the type but persistence resolution throws if an entity has neither a per-entity override nor a root adapter. +‡ The built-in `defineRocketsAuth()` integration contributes `false` because +its upstream authentication module already owns a JWT global guard. Explicit +server options always win; mixed-auth hosts can opt the Rockets chain back in. + `*-server` / `*-core` split — what server forwards vs keeps: ```mermaid @@ -436,9 +443,19 @@ accepts one bootstrap or a **chain** (array, tried in order). interface AuthBootstrap { adapter: Type; forRoot?: () => DynamicModule; // host module: provides+exports the adapter + contributes?: { // integration-owned app defaults + resources?: ReadonlyArray; + userMetadata?: RocketsUserMetadataConfig; + repository?: RepositoryModuleInterface | RepositoryBootstrap; + enableGlobalGuard?: boolean; + }; } ``` +Explicit server options override contributed defaults. Resource contributions +are prepended to application resources; incompatible single-value defaults from +multiple auth integrations fail fast instead of depending on import order. + ```mermaid flowchart TD REQ["incoming request"] --> GUARD["AuthServerGuard (APP_GUARD)"] @@ -469,33 +486,26 @@ interface AuthAdapterInterface { ``` **Global guard (default-on / opt-out):** `AuthServerGuard` is registered as -`APP_GUARD` **unless `enableGlobalGuard === false`** — enabled by default; you -opt **out**, never in. Routes are guarded unless explicitly made public -(`@AuthPublic()`) or the global guard is disabled. +`APP_GUARD` **unless `enableGlobalGuard === false`**. Auth integrations may +contribute a different default; explicit app configuration wins. Routes are +guarded unless explicitly made public (`@AuthPublic()`) or the global guard is +disabled. ### 7a. External auth (`@concepta/rockets`) — you own `authenticate()` -Minimum (core stub shape): +Minimum: ```ts -function createStubAuthBootstrap(adapter) { - return { adapter, forRoot: () => ({ module: class {}, providers: [adapter], exports: [adapter] }) }; -} +const auth = defineAuthAdapter(MyAuthAdapter); ``` Complete (`examples/sample-server/src/auth/define-sample-auth.ts`): ```ts export function defineSampleAuth(): AuthBootstrap { - return { - adapter: SampleAuthAdapter, - forRoot: () => ({ - module: class SampleAuthHostModule {}, - providers: [SampleAuthAdapter], - controllers: [AuthController], - exports: [SampleAuthAdapter], // controller + entity stay internal - }), - }; + return defineAuthAdapter(SampleAuthAdapter, { + controllers: [AuthController], // controller stays integration-private + }); } RocketsModule.forRoot({ @@ -533,10 +543,10 @@ Concept → field map: |---|---| | JWT secrets/signing | `authentication.settings.jwt.{access,refresh}` | | login/strategies | `authentication.settings.strategies` | -| recovery | `authentication.ports.recoveryNotification` (**required**) + `verifyNotification` | +| recovery | `/recovery/*` controllers (enabled by default) + required `authentication.ports.recoveryNotification`; verification uses `verifyNotification` | | otp | `otp` block + `settings.otp` + `disableController.otp` | | signup / admin | `userCrud` (+ `handlers.*`) + `disableController.{signup,admin}` | -| oauth / federated | `federated` block — **OAuth provider modules are NOT in v8 yet (G1 gap)** | +| oauth / federated | `federated` persistence block; OAuth provider routes are deferred from the current 1.0 scope | Complete (`examples/sample-server-auth/src/app.module.ts`): @@ -558,20 +568,20 @@ const rocketsAuthInput: DefineRocketsAuthInput = { }; const rocketsAuth = defineRocketsAuth(rocketsAuthInput); -const rocketsAuthResources = buildRocketsAuthResources(rocketsAuthInput.persistence, rocketsAuthInput.invitationEntity); RocketsModule.forRoot({ auth: rocketsAuth, - userMetadata: rocketsAuthInput.userMetadata, - enableGlobalGuard: false, // auth uses per-controller guards - repository: repo, // SAME instance as persistence.module (reference equality!) - resources: [ ...rocketsAuthResources, createPetResource(), /* … */ ], + resources: [createPetResource(), /* … */], }); ``` -> **Reference-equality trap:** the `repo` passed to `RocketsModule.repository` -> and to `defineRocketsAuth({ persistence: { module: repo } })` must be the -> **same object** — entities are grouped per adapter by identity. +`defineRocketsAuth` contributes its persistence resources, metadata contract, +repository bootstrap, and guard preference to the surrounding server. The host +only declares application-owned resources. Explicit server options remain the +escape hatch and take precedence over those contributed defaults. Its Rockets +guard preference is `false` because `AuthenticationModule` already owns the JWT +global guard; mixed-auth hosts can set `rocketsDefaults.enableGlobalGuard: true` +and `auth.appGuard: false` to make the ordered Rockets adapter chain the owner. --- @@ -596,16 +606,16 @@ Minimum: repository: defineTypeOrmRepository({ type: 'sqlite', database: ':memory:', synchronize: true }) ``` -Selecting TypeORM (`examples/sample-server/src/repository/define-typeorm-repository.ts`): +Selecting TypeORM: ```ts -export function defineTypeOrmRepository(connection): RepositoryBootstrap { - return { - name: 'typeorm-bootstrap', - forFeature: (entities) => TypeOrmRepositoryModule.forFeature(entities), // one repo token per key - forRoot: (entities) => TypeOrmModule.forRoot({ ...connection, entities: [...entities] }), - }; -} +import { defineTypeOrmRepository } from '@concepta/rockets-repository-typeorm'; + +const repository = defineTypeOrmRepository({ + type: 'sqlite', + database: ':memory:', + synchronize: true, +}); ``` Swap to Firestore = pass a `defineFirestoreRepository(...)` instead — **no @@ -631,7 +641,7 @@ interface RocketsUserMetadataConfig { } ``` -Minimum (required at runtime — omit and the metadata DTO token throws): +Enable the optional `/me` surface by supplying: ```ts userMetadata: { @@ -681,13 +691,10 @@ flowchart TD `sampleAuthUserResource` + `defineSampleAuth`). 3. **`SafeCrudContextInterceptor`** — a live workaround replacing upstream's global `CrudContextOverlay`; flagged for removal once upstream is mixed-app safe. -4. **OAuth (G1)** — federated/OAuth provider modules are not ported to v8 yet. +4. **OAuth** — federated identity persistence exists, but provider-specific + OAuth routes are deferred from the current 1.0 scope. 5. **`settings`** — both server and core `settings` are empty interfaces today (reserved slot). -6. **Pre-existing e2e/typecheck gaps on this branch** (independent of the v2 - work): `rockets-crud` photo-CRUD e2e ×2 + `rockets-server-auth` password e2e - ×2 fail (31/35); `sample-server` has 2 `CrudCommandHandler` ctor typecheck - errors and `sample-server-auth` 1 deep crud-import error. --- @@ -697,11 +704,12 @@ flowchart TD ## 12. Signature v2 — design rationale & change-set (SHIPPED) -> **Status: implemented.** The v2 DSL described in §1–§10 is live in -> `packages/rockets-core/src/**`, with both sample apps migrated. Verified: -> build green, core unit 296/296, core e2e 47/47, package e2e 31/35 (the 4 -> failures pre-date this work). This section keeps the *why* — the constraint, -> the convertibility proof, the locked naming, and the change-set. +> **Status: implemented.** The DSL described in §1–§10 is live in +> `packages/rockets-core/src/**`, with all sample apps migrated. The root +> `release:check` gate verifies builds, spec typechecking, code and Markdown +> linting, unit and package E2E tests, sample builds/E2E tests, and dry-run +> package artifacts. This section keeps the *why* — the constraint, the +> convertibility proof, the locked naming, and the change-set. > > **Constraint:** "no breaking" meant **no functional / feature regression** — > NOT "cannot change the entry config". The input DSL was ours to redesign; the @@ -746,8 +754,9 @@ do not present join as the parent-child association mechanism. - **Per-operation DTOs: `input` / `output`.** Not `body` (write-only word) and not `request` — `request` is already the `{ params, body, bodyBatch, validation }` envelope in crud, so it is taken. `input → request.body`, `output → response.resource`. -- **`repository` everywhere.** Single name for "which adapter": root, per-resource, - per-entity, `userMetadata`. Replaces `persistence.module`. +- **`repository` at the application/resource layer.** Single name for "which + adapter": root, per-resource, per-entity, and `userMetadata`. Built-in auth + retains `persistence.module` because that input also owns the auth entity map. ### 12.3 Final signatures diff --git a/MIGRATION-SUMMARY.md b/MIGRATION-SUMMARY.md deleted file mode 100644 index fffa2ea76..000000000 --- a/MIGRATION-SUMMARY.md +++ /dev/null @@ -1,123 +0,0 @@ -# @bitwild Self-Contained Migration — Session Summary - -**Branch:** `feature/module-migration` -**Date:** 2026-06-04 -**Status:** build GREEN · lint PASS · e2e 31/35 suites (248 tests) · NOT committed - ---- - -## Goal - -Turn the `@bitwild` repository / crud / app packages from **thin wrappers over -upstream `@concepta/nestjs-*`** into **self-contained source**, by adopting the -original concepta source packages (copied in as `*-concepta` folders) and -deleting the wrappers. Consolidate `rockets-common` into `rockets-app`. - -## End-state package layout - -```text -app ← repository ← crud (lowest → highest layer) - ↑ ↖ - repository-typeorm core ← server / server-auth -``` - -| Package | What it is now | -|---|---| -| `@concepta/rockets-app` | **Foundation/kernel.** Context overlay (`AppContextHost`, `getAppContext`, `OverlayRef`, `Ctx`, `ContextOverlayInterceptor`), `RuntimeException`, hooks (`HookResolverService`, `Spec`), references, audit, `DomainAggregate`, `AuthUser`, SwaggerUi module, utils (`deriveEntityKey`/`resolveEntityKey`, `createRepositoryContext`, `whitelistedFromDto`, `stripUndefined`). **Replaced `rockets-common` (deleted).** Zero `@concepta/nestjs-*` deps. | -| `@concepta/rockets-repository` | Self-contained dynamic repository (module, adapter, transactions, federation, hooks, query helpers). DB-agnostic. `@InjectDynamicRepository(string \| Type)`. | -| `@concepta/rockets-repository-typeorm` | TypeORM implementation. | -| `@concepta/rockets-crud` | Self-contained CRUD module + builder + CQRS handlers. `@InjectCrudAdapter(string \| Type)`. | - -## What changed (the 5 phases) - -- **Phase 0** — Recorded baseline: `@bitwild` ecosystem was green; the copied - concepta packages had real TS compile errors. -- **Phase 1** — `rockets-app` became the self-contained superset of `common`. - Ported 7 utils + `AuthUser` (5 lines) + a **fresh** SwaggerUi module + - model interfaces. Renamed `@concepta/rockets-app` → `@concepta/rockets-app`. -- **Phase 2** — Adopted repository: merged `InjectDynamicRepository` to - `string | Type`, fixed `super.context` → `this.context` + `declare context`. -- **Phase 3** — Adopted crud + repository-typeorm: fixed the TypeORM `upsert` - typing (normalize `DeepPartial` via `repo.create()` — no cast), fixed a - union-narrowing bug that only fails under `strict:false` (distributive - conditional structural view), merged `InjectCrudAdapter`, fixed fixture - generic inference, aligned tsconfigs to exclude test files (project convention). -- **Phase 4** — Atomic cutover: - - Deleted wrappers: `rockets-common`, old `rockets-repository`, old `rockets-crud`. - - Renamed concepta folders/packages → `@concepta/*`. - - Swapped consumer imports: `rockets-common`→`rockets-app` (72 files); - upstream `@concepta/nestjs-repository`/`-typeorm`/`-crud` → `@bitwild` (93 files); - `@concepta/nestjs-common` kernel symbols split to app (52 files, 7 upstream-only - symbols kept). - - Rewired core `HookModule.forRoot({})` → `RocketsAppModule.forRoot()`. - - This resolved the silent `AppContextHost`-identity bug (the #1 risk - flagged up front). - -## Why the `@bitwild` repository now DIVERGES from upstream (important) - -The OLD `@concepta/rockets-repository` was a wrapper that re-exported upstream -`@concepta/nestjs-repository`, so they shared the **same** `AppContextHost` / -`TransactionScope` classes. The NEW `@concepta/rockets-repository` is independent -source — **different classes**. Anything that mixes the new `@bitwild` stack with -upstream `@concepta/nestjs-*` packages hits a cross-identity mismatch -("Expected AppContextHost, got object"). - -## The 4 failing e2e suites (both are documented boundaries, not bugs introduced) - -1. **`rockets-crud`: `crud.operations`, `crud.adapter`** — PRE-EXISTING - crud-internal test infra (dist-module `DataSource` DI wiring; ctx-overlay - generics in the test helper). Never passed in this repo's baseline. Source - is clean. - -2. **`rockets-server-auth`: `me-password`, `password-history`** — ARCHITECTURAL - BOUNDARY. server-auth composes `@bitwild` core (→ forces the `@bitwild` - repository for its resource layer + global `SafeCrudContextInterceptor`) - **and** upstream `@concepta/nestjs-invitation` / `nestjs-user` (→ need upstream - `TransactionScope`). One app cannot provide both repository identities. - This worked before only because the wrapper === upstream. - -## OPEN DECISION for next session (server-auth) - -Closing the server-auth boundary needs one of: - -- **(A)** Migrate the upstream auth stack (`nestjs-invitation`, `nestjs-user`, - `nestjs-otp`, `nestjs-role`, `nestjs-password`, `nestjs-federated`) to the - `@bitwild` stack. Large, separate effort — makes server-auth fully self-contained. -- **(B)** Re-introduce a repository compat layer so `@bitwild` repository stays - upstream-identity-compatible. Defeats the self-contained goal; also a "bridge" - (forbidden by AGENTS.md rule #9). -- **(C)** Accept the 2 suites as a known boundary and ship the rest. - -> You picked "keep auth on upstream" last session, but that is **structurally -> unachievable** while server-auth uses `@bitwild` core. Needs a real call between -> A / B / C. - -## How to reproduce the current state - -```bash -yarn install -yarn build # GREEN -yarn lint # PASS (4 warnings) -yarn test:e2e # 31/35 suites, 248 tests pass; 4 fail (above) -``` - -## Notes / gotchas discovered - -- Build is `tsc --build` (incremental) — it does NOT delete orphaned `dist` - outputs. After renames, `rm -rf packages/*/dist *.tsbuildinfo` before a build, - or stale `.js` files reference deleted packages at runtime. -- Tests historically transpiled with `strict:false` (now via the Vitest/SWC -pipeline); some adopted code had - bugs that only surface there (union narrowing). Fixes must pass BOTH modes. -- Adding `reflect-metadata` as a dep to `rockets-app` made yarn nest a - redundant `@nestjs/common` under `packages/rockets-app/node_modules`, which - broke a portable-type emit (`TS2742`). Reverted; the spec's `import - 'reflect-metadata'` was redundant (it's a hoisted root dep). -- Upstream `@concepta/nestjs-user`/`invitation` peer-depend on - `@concepta/nestjs-crud`/`-repository`; those deps were restored to package.json - (source uses `@bitwild`, upstream auth packages keep their upstream peer). - -## Detailed step-by-step log - -See `.context/migration-baseline.md` (gitignored) for the full Phase 0→4 record -with exact files, errors, and fixes. diff --git a/README.md b/README.md index 37f8d2a64..0a7ed080a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/conceptadev/rockets/ci-merge.yml?branch=main&label=CI)](https://github.com/conceptadev/rockets/actions/workflows/ci-merge.yml) [![Codecov](https://codecov.io/gh/conceptadev/rockets/branch/main/graph/badge.svg)](https://codecov.io/gh/conceptadev/rockets) -[![NestJS](https://img.shields.io/badge/NestJS-11-ea2845?logo=nestjs&logoColor=white)](https://nestjs.com/) +[![NestJS](https://img.shields.io/badge/NestJS-12-ea2845?logo=nestjs&logoColor=white)](https://nestjs.com/) [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178c6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![License](https://img.shields.io/badge/license-BSD--3--Clause-green.svg)](LICENSE.txt) @@ -13,9 +13,9 @@ **Status:** pre-1.0 (`0.0.1-dev.0`, on npm under `@concepta/*` with dist-tag `alpha`). The public surface (`AuthAdapterInterface`, `defineResource`, -`defineModuleResource`, `RepositoryInterface`, the `RocketsModule.forRoot` -options shape) is stable; field renames are still possible before 1.0. Pin exact -versions in production. +`defineModuleResource`, `RepositoryInterface`, `createServer`) is being +prepared for 1.0; breaking refinements are still possible until that release. +Pin exact versions in production. ## Table of contents @@ -257,7 +257,7 @@ access-control rules. Rockets does not pretend to write those for you. ### Prerequisites -- Node 18+. +- Node 20+ (required by NestJS 12). - A package manager (yarn 4 / npm / pnpm — examples below use yarn). - A database adapter — TypeORM with any supported driver is the most common. Firestore works via `@concepta/rockets-repository-firestore`. @@ -307,7 +307,7 @@ yarn add @concepta/rockets@alpha \ | Pulled in for you | Packages | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| Other `@concepta/*` | `rockets-core`, `rockets-repository-typeorm` | +| Other `@concepta/*` | `rockets-core` | | Upstream motor | `@concepta/nestjs-{core,repository,crud,authentication,access-control}` (via `@concepta/rockets-core` re-exports) | | Nest (Rockets runtime) | `@nestjs/common`, `@nestjs/core`, `@nestjs/cqrs`, `@nestjs/swagger`, `@nestjs/config` | @@ -342,6 +342,7 @@ import { AuthAdapterInterface, AuthAttemptResult, AuthRequest, + defineAuthAdapter, extractBearerToken, } from '@concepta/rockets'; @@ -364,6 +365,8 @@ export class JwtAdapter implements AuthAdapterInterface { } } } + +export const jwtAuth = defineAuthAdapter(JwtAdapter); ``` Declare a resource — this is the entire CRUD definition: @@ -381,42 +384,24 @@ export class PetEntity { } ``` -Add a small TypeORM bootstrap helper in your app — the adapter is -`@concepta/rockets-repository-typeorm`, but the connection-options wrapper stays -app-local so core never takes a TypeORM dependency. It implements -`RepositoryBootstrap` so the planner calls `forRoot(entities)` once from -`resources[]` + `userMetadata`, without a hand-maintained entity list: +Create the TypeORM bootstrap at the boundary. The adapter owns the wrapper, so +applications do not copy infrastructure helpers: ```typescript -// src/repository/define-typeorm-repository.ts -import type { DynamicModule, PlainLiteralObject, Type } from '@nestjs/common'; -import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { TypeOrmRepositoryModule } from '@concepta/rockets-repository-typeorm'; -import type { RepositoryBootstrap } from '@concepta/rockets-core'; -import type { - DynamicRepositoryModule, - RepositoryProviderOptions, -} from '@concepta/nestjs-repository'; - -export function defineTypeOrmRepository< - Connection extends TypeOrmModuleOptions, ->(connection: Connection): RepositoryBootstrap { - return { - name: 'typeorm-bootstrap', - forFeature(entities: RepositoryProviderOptions[]): DynamicRepositoryModule { - return TypeOrmRepositoryModule.forFeature(entities); - }, - forRoot(entities: ReadonlyArray>): DynamicModule { - return TypeOrmModule.forRoot({ ...connection, entities: [...entities] }); - }, - }; -} +import { defineTypeOrmRepository } from '@concepta/rockets-repository-typeorm'; + +const repository = defineTypeOrmRepository({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + dropSchema: true, +}); ``` **Why this exists:** you pass only connection options (`type`, `database`, `synchronize`, …). You never maintain `entities: [PetEntity, UserMetadataEntity, …]` on `TypeOrmModule.forRoot`. When -`RocketsModule` boots, the registration planner walks `resources[]`, +the server boots, the registration planner walks `resources[]`, `userMetadata.entity`, and any entities contributed by auth integrations, then calls `forRoot(mergedEntities)` once and `forFeature` per table. Services use `@InjectDynamicRepository(PetEntity)` and get a `RepositoryInterface` @@ -425,41 +410,41 @@ calls `forRoot(mergedEntities)` once and `forFeature` per table. Services use Compose the app: ```typescript -// src/app.module.ts -import { Module } from '@nestjs/common'; -import { RocketsModule, defineResource } from '@concepta/rockets'; +// src/server.ts +import { NestFactory } from '@nestjs/core'; +import { createServer, defineResource } from '@concepta/rockets'; +import { defineTypeOrmRepository } from '@concepta/rockets-repository-typeorm'; import { OwnerStampHook, OwnerScopeHook } from '@concepta/rockets-core'; -import { JwtAdapter } from './auth/jwt.adapter'; +import { jwtAuth } from './auth/jwt.adapter'; import { PetEntity } from './pet/pet.entity'; import { UserMetadataEntity } from './user/user-metadata.entity'; import { UserMetadataCreateDto, UserMetadataUpdateDto } from './user/dto'; -import { defineTypeOrmRepository } from './repository/define-typeorm-repository'; -@Module({ - imports: [ - RocketsModule.forRoot({ - auth: JwtAdapter, - userMetadata: { - entity: UserMetadataEntity, - createDto: UserMetadataCreateDto, - updateDto: UserMetadataUpdateDto, - }, - repository: defineTypeOrmRepository({ - type: 'sqlite', - database: ':memory:', - synchronize: true, - dropSchema: true, - }), - resources: [ - defineResource({ - entity: PetEntity, - hooks: [OwnerStampHook.for(PetEntity), OwnerScopeHook.for(PetEntity)], - }), - ], +const repository = defineTypeOrmRepository({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + dropSchema: true, +}); + +export const server = createServer({ + auth: jwtAuth, + userMetadata: { + entity: UserMetadataEntity, + createDto: UserMetadataCreateDto, + updateDto: UserMetadataUpdateDto, + }, + repository, + resources: [ + defineResource({ + entity: PetEntity, + hooks: [OwnerStampHook.for(PetEntity), OwnerScopeHook.for(PetEntity)], }), ], -}) -export class AppModule {} +}); + +const app = await NestFactory.create(server); +await app.listen(3000); ``` Run it: @@ -486,20 +471,15 @@ Install the same packages as above plus `@concepta/rockets-auth` and the upstrea `@concepta/nestjs-*` line (most are transitive dependencies; `yarn install` will pull them). -Compose with `defineRocketsAuth()`. Reuse the same `defineTypeOrmRepository` -helper from path A and pass the **same instance** to both -`defineRocketsAuth({ persistence: { module: repo } })` and -`RocketsModule.forRoot({ repository: repo })`. Register auth persistence rows -via `buildRocketsAuthResources()` on `resources`: +Compose with `defineRocketsAuth()`. Give it the TypeORM bootstrap once; the +integration contributes its auth rows, root repository, metadata contract, and +guard preference to the surrounding server: ```typescript import { Module } from '@nestjs/common'; -import { - defineRocketsAuth, - buildRocketsAuthResources, -} from '@concepta/rockets-auth'; +import { defineRocketsAuth } from '@concepta/rockets-auth'; import { RocketsModule } from '@concepta/rockets'; -import { defineTypeOrmRepository } from './repository/define-typeorm-repository'; +import { defineTypeOrmRepository } from '@concepta/rockets-repository-typeorm'; const repo = defineTypeOrmRepository({ type: 'sqlite', @@ -542,18 +522,11 @@ const rocketsAuthInput = { }), }; -const rocketsAuth = defineRocketsAuth(rocketsAuthInput); -const rocketsAuthResources = buildRocketsAuthResources( - rocketsAuthInput.persistence, - rocketsAuthInput.invitationEntity, -); - @Module({ imports: [ RocketsModule.forRoot({ - auth: rocketsAuth, - repository: repo, - resources: [...rocketsAuthResources /* your defineResource bundles */], + auth: defineRocketsAuth(rocketsAuthInput), + resources: [/* your application defineResource bundles */], }), ], }) @@ -575,16 +548,17 @@ The monorepo ships runnable sample apps for both paths (`yarn sample:dev` and `auth` accepts a single `AuthBootstrap` or an array. Each entry is one of: -- `defineFirebaseAuth({ forRoot | forRootAsync })` — Firebase Admin + +- `defineFirebaseAuth({ firebaseApp })` or the explicit `{ forRootAsync }` + variant — Firebase Admin + `FirebaseAuthAdapter` (`@concepta/rockets-adapter-firebase`). -- `defineRocketsAuth(...)` — built-in signup/login stack - (`@concepta/rockets-auth`); pair with `buildRocketsAuthResources()` on - `resources`. -- App-local `AuthBootstrap` — `{ adapter, forRoot? }` for custom adapters (see +- `defineRocketsAuth(...)` — complete built-in signup/login stack and its owned + persistence contributions (`@concepta/rockets-auth`). +- `defineAuthAdapter(Adapter, options?)` — complete host wiring for a custom + adapter (see `defineApiKeyAuth()` in sample-code-review). -Entity rows for auth-owned tables belong on `resources[]`, not inside the auth -helper. +Explicit server options override integration-contributed defaults. Conflicting +defaults from two integrations fail at startup instead of depending on order. ```typescript import { defineFirebaseAuth } from '@concepta/rockets-adapter-firebase'; @@ -668,11 +642,10 @@ the caller owns the parent via `PathScopeGuard`. ### Wire TypeORM without hand-registering entities -Use a small app-local `defineTypeOrmRepository` helper (full sample in **Path -A** above). It implements `RepositoryBootstrap` from `@concepta/rockets-core` -and wraps `TypeOrmRepositoryModule` from `@concepta/rockets-repository-typeorm`; -only the helper (your connection options) lives in the app, so core never takes -a TypeORM dependency. Firestore-only apps skip it and use +Import `defineTypeOrmRepository` from +`@concepta/rockets-repository-typeorm`. It implements `RepositoryBootstrap` +and keeps TypeORM connection concerns in the adapter package while core stays +storage-agnostic. Firestore-only apps skip it and use `@concepta/rockets-repository-firestore` instead. #### What you declare vs what the framework registers @@ -737,8 +710,8 @@ No `TypeOrmModule.forFeature([PetEntity])` in feature modules. No `@InjectRepository`. If the entity is in the registration plan, `@InjectDynamicRepository` resolves at runtime. -**Built-in auth (path B):** pass the **same** `repository` instance to both -entry points so one connection serves app tables and auth tables: +**Built-in auth (path B):** pass the repository to `defineRocketsAuth`; its +composition contribution makes the same connection serve app and auth tables: ```typescript const repository = defineTypeOrmRepository({ @@ -758,7 +731,6 @@ const rocketsAuth = defineRocketsAuth({ @Module({ imports: [ RocketsModule.forRoot({ - repository, auth: rocketsAuth, resources: [ /* pet resources — no per-resource persistence block */ @@ -956,20 +928,20 @@ it: one `RocketsModule.forRoot({ ... })` object is split by | `@concepta/nestjs-crud` | `@concepta/rockets-core` (re-export) | Generated controllers, CQRS commands/queries, default handlers | | `@concepta/nestjs-core`, `@concepta/nestjs-authentication` | `@concepta/rockets-core` | Hook resolution (`CoreModule`), shared exceptions, auth primitives | | `@concepta/nestjs-access-control` | opt-in `accessControl` option (import symbols from upstream) | Grant table, `AccessControlGuard`, route decorators | -| `@concepta/nestjs-repository-typeorm` | `@concepta/rockets-repository-typeorm` (thin wrapper) + app-local bootstrap | SQL adapter — `@concepta/rockets-repository-typeorm`'s main entry re-exports the upstream package verbatim; wrapped by `defineTypeOrmRepository` | +| `@concepta/nestjs-repository-typeorm` | `@concepta/rockets-repository-typeorm` | SQL adapter plus `defineTypeOrmRepository`, which supplies connection options and accepts the planner-derived entity list | | `@concepta/nestjs-user`, `role`, `otp`, `password`, `invitation`, `federated`, `email`, `event` | wired inside `@concepta/rockets-auth` | Built-in auth HTTP + persistence rows (path B only) | | Rockets layer | Role | | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `@concepta/rockets-core` | **Planner and contracts**: `defineResource`, `buildAppRegistrationPlan`, `AuthServerGuard`, owner/path hooks, swagger registration | | `@concepta/rockets` (server) | **External-auth presentation**: `MeController`, default `APP_GUARD`, `auth` chain merge | -| `@concepta/rockets-auth` | **Built-in identity bundle**: `defineRocketsAuth()` + `buildRocketsAuthResources()` | +| `@concepta/rockets-auth` | **Built-in identity bundle**: `defineRocketsAuth()` with owned composition contributions | **Path B uses both** `@concepta/rockets` and `@concepta/rockets-auth`: -`defineRocketsAuth()` supplies the auth bootstrap; spread -`buildRocketsAuthResources()` into `resources`; -`RocketsModule.forRoot({ auth, repository, resources })` still comes from the -server package. They are sibling packages over core, not parent/child. +`defineRocketsAuth()` supplies the auth bootstrap plus its persistence, +metadata, and guard defaults; `createServer({ auth, resources })` (or the +lower-level `RocketsModule.forRoot`) still comes from the server package. They +are sibling packages over core, not parent/child. **Repository injection (upstream contract, Rockets-local decorator):** @@ -995,7 +967,7 @@ configuration façade** — not a fork. | **Core re-exports (former `@concepta/rockets-common`)** | `@concepta/rockets-common` was deleted; its helpers (`AuthUser`, `InjectDynamicRepository`, `SwaggerUiModule`, `deriveEntityKey`, …) and upstream re-exports now live inside `@concepta/rockets-core`. This is **not** a replacement for the upstream **app-module** composition pattern — that wiring still lives in Concepta; Rockets adds a **second** entry point (`RocketsModule.forRoot`) that feeds the same motors. | | **Port backlog (server path)** | On v8 today: `core`, `repository`, `crud`, `hook`, `common`, `authentication`, `access-control`. Still on v7 in this monorepo: `swagger-ui` (and `email` / `event` on the auth path) — version-mismatched intentionally and tested in CI. | | **Repo migration** | Moving all of `nestjs-modules` into this git repo is **optional** for product validation. Shipping fixes against published `@concepta/*` alphas is fine; monorepo colocation is for AI context and version lock, not a prerequisite to use Rockets. | -| **Safe to keep building on** | These are intentional, tested surfaces — not throwaway experiments: `AuthAdapterInterface.authenticate`, `RepositoryInterface` + dynamic repository keys (class **or** string token), `defineResource` / planner-driven entity registration, `defineRocketsAuth({ persistence: { module } })` sharing one `repository` instance with `RocketsModule.forRoot`. | +| **Safe to keep building on** | These are intentional, tested surfaces — not throwaway experiments: `createServer`, `AuthAdapterInterface.authenticate`, `RepositoryInterface` + dynamic repository keys (class **or** string token), `defineResource` / planner-driven entity registration, and complete `defineRocketsAuth({ persistence })` contributions. | **Custom validation / business rules:** use `defineHook` from `@concepta/rockets-core` for simple entity lifecycle rules, upstream @@ -1008,12 +980,12 @@ to 4xx — a bare `Error` in a hook often surfaces as 500. | Package | npm name | Purpose | Docs | Status | | --------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- | ------- | -| `packages/rockets-core` | `@concepta/rockets-core` | Composition planner. Auth chain, `buildAppRegistrationPlan`, `defineResource` / `defineModuleResource` / `defineSubResource`, `defineHook`, owner/path hooks, swagger registration, shared helpers, zod layer at `@concepta/rockets-core/zod`, opt-in `accessControl`. | [README](packages/rockets-core/README.md) | stable | -| `packages/rockets-repository-typeorm` | `@concepta/rockets-repository-typeorm` | TypeORM adapter for the dynamic repository contract — a thin wrapper whose main entry re-exports upstream `@concepta/nestjs-repository-typeorm` verbatim, plus the zod `SchemaEntityCompiler` at `@concepta/rockets-repository-typeorm/zod`. | [README](packages/rockets-repository-typeorm/README.md) | stable | +| `packages/rockets-core` | `@concepta/rockets-core` | Composition planner. Auth chain, `buildAppRegistrationPlan`, `defineResource` / `defineModuleResource` / `defineSubResource`, `defineHook`, owner/path hooks, swagger registration, shared helpers, zod layer at `@concepta/rockets-core/zod`, opt-in `accessControl`. | [README](packages/rockets-core/README.md) | preview | +| `packages/rockets-repository-typeorm` | `@concepta/rockets-repository-typeorm` | TypeORM adapter and `defineTypeOrmRepository` bootstrap for planner-derived entity registration, plus the zod `SchemaEntityCompiler` at `@concepta/rockets-repository-typeorm/zod`. | [README](packages/rockets-repository-typeorm/README.md) | preview | | `packages/rockets-repository-firestore` | `@concepta/rockets-repository-firestore` | Firestore adapter implementing `RepositoryAdapter`. Per-entity opt-in. | [README](packages/rockets-repository-firestore/README.md) | preview | | `packages/rockets-adapter-firebase` | `@concepta/rockets-adapter-firebase` | Firebase Auth adapter implementing `AuthAdapterInterface`. | [README](packages/rockets-adapter-firebase/README.md) | preview | -| `packages/rockets-server` | `@concepta/rockets` | External-auth presentation layer. `MeController`, `APP_GUARD` opt-in, `auth` chain. | [README](packages/rockets-server/README.md) | stable | -| `packages/rockets-server-auth` | `@concepta/rockets-auth` | Built-in auth: signup, login, OTP, recovery, invitations, roles, admin user CRUD. `defineRocketsAuth()`. | [README](packages/rockets-server-auth/README.md) | alpha | +| `packages/rockets-server` | `@concepta/rockets` | Launch-facing `createServer`, external-auth presentation, optional `/me`, default guard, and auth chain. | [README](packages/rockets-server/README.md) | preview | +| `packages/rockets-server-auth` | `@concepta/rockets-auth` | Built-in auth: signup, login, recovery, OTP, invitations, roles, throttling, and admin user CRUD. | [README](packages/rockets-server-auth/README.md) | preview | ### Repository layout @@ -1035,8 +1007,9 @@ rockets/ - **Rockets packages**: `0.0.1-dev.0` on npm (`yarn add @concepta/rockets@alpha`, or pin `0.0.1-dev.0`). Monorepo packages keep `workspace:^` for local development. -- **Upstream Concepta packages**: v8 line at `8.0.0-alpha.7` (`nestjs-common` / - `nestjs-hook` at `8.0.0-alpha.6`). Two modules still on v7 +- **Upstream Concepta packages**: v8 modules are pinned to `8.0.0-alpha.8`; + `@concepta/nestjs-common` remains at its latest published v8 build, + `8.0.0-alpha.6`. Two modules remain on v7 (`@concepta/nestjs-email`, `@concepta/nestjs-event`) pending the v8 port. Swagger UI ships from `@concepta/rockets-core`. Auth persistence entities are app-owned TypeORM classes — do not use `@concepta/nestjs-typeorm-ext`. @@ -1044,13 +1017,14 @@ rockets/ `testing`); satellite packages (`cqrs`, `typeorm`, `jwt`, `passport`, `config`, `throttler`) remain on their current stable majors until a Nest 12 line is published. -- **Node**: `>=18.0.0`. +- **Node**: `>=20.0.0` (the minimum supported by NestJS 12). ### Common scripts (from the monorepo root) | Command | Purpose | | ----------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `yarn publish:conceptadev` | Build + publish all `@concepta/*` packages to npm (`--tag alpha`). | +| `yarn release:check` | Run the complete build, package, type, lint, unit, e2e, and sample release gate. | +| `yarn release:dry` | Build publish archives for every public `@concepta/*` workspace without publishing. | | `yarn install && yarn build` | Bootstrap + compile every local `@concepta/*` package. | | `yarn test` | Unit tests (Vitest). | | `yarn typecheck:spec` | Type-checks test files — the runner only transpiles them. | @@ -1060,7 +1034,6 @@ rockets/ | `yarn sample:dev` | Run `sample-server` in watch mode. | | `yarn sample-auth:dev` | Run `sample-server-auth` in watch mode. | | `yarn sample-code-review:dev` | Build + run the full-stack example. | -| `yarn generate-swagger` | Dump the OpenAPI spec from `sample-server-auth`. | --- diff --git a/examples/sample-code-review/README.md b/examples/sample-code-review/README.md index 27973cd04..32c69f604 100644 --- a/examples/sample-code-review/README.md +++ b/examples/sample-code-review/README.md @@ -75,7 +75,7 @@ chain so server-to-server callers can authenticate with `X-Api-Key`. ### Prerequisites -- Node 18+, Yarn 4. +- Node 20+, Yarn 4. - A Firebase project for real auth (or `FIREBASE_USE_FAKE=true` for in-process verification). - A GitHub OAuth App if you want the GitHub connect flow to work. @@ -227,7 +227,7 @@ examples/sample-code-review │ │ ├── auth-firebase/ defineFirebaseAuth wiring │ │ ├── auth-api-key/ Second AuthBootstrap in the chain — ApiKeyEntity + POST /api-keys (mint) + adapter │ │ ├── github/ GitHub OAuth + repo browse -│ │ ├── repository/ defineTypeOrmRepository + Firestore persistence helpers +│ │ ├── repository/ Firestore persistence helper │ │ ├── config/ GithubConfig / OpenaiConfig (env-var readers) │ │ ├── zod-bindings.ts bindZodResources(typeOrmZodEntityCompiler) │ │ ├── user-metadata.schema.ts zod schema -> { entity, createDto, updateDto, responseDto } diff --git a/examples/sample-code-review/apps/api/package.json b/examples/sample-code-review/apps/api/package.json index 5ac77ea54..fa819b786 100644 --- a/examples/sample-code-review/apps/api/package.json +++ b/examples/sample-code-review/apps/api/package.json @@ -10,8 +10,8 @@ "type-check": "tsc --noEmit -p tsconfig.json", "lint": "echo \"lint: configure eslint when needed\"", "clean": "rm -rf dist", - "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:e2e:auth": "vitest run --config vitest.e2e.config.ts test/auth-connection.e2e-spec.ts" + "test:e2e": "vitest run --config vitest.e2e.config.mts", + "test:e2e:auth": "vitest run --config vitest.e2e.config.mts test/auth-connection.e2e-spec.ts" }, "dependencies": { "@concepta/nestjs-core": "8.0.0-alpha.8", @@ -43,7 +43,7 @@ "@nestjs/cli": "12.0.0-alpha.6", "@nestjs/testing": "12.0.0-alpha.5", "@swc/core": "^1.15.47", - "@types/node": "^18.19.44", + "@types/node": "^20.19.0", "@types/supertest": "^6.0.2", "supertest": "^6.3.4", "ts-node": "^10.9.2", diff --git a/examples/sample-code-review/apps/api/src/app.module.ts b/examples/sample-code-review/apps/api/src/app.module.ts index 402d0c5e6..f207213ca 100644 --- a/examples/sample-code-review/apps/api/src/app.module.ts +++ b/examples/sample-code-review/apps/api/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { RocketsModule } from '@concepta/rockets'; +import { defineTypeOrmRepository } from '@concepta/rockets-repository-typeorm'; import { defineFirebaseAuth } from '@concepta/rockets-adapter-firebase'; import { defineModuleResource } from '@concepta/rockets-core'; import { createFirebaseAdminApp } from './auth-firebase'; @@ -13,7 +14,6 @@ import { UserMetadataCreateDto, UserMetadataUpdateDto, } from './user-metadata.schema'; -import { defineTypeOrmRepository } from './repository/define-typeorm-repository'; import { githubFeature } from './github'; import { analysisFeature } from './analysis'; diff --git a/examples/sample-code-review/apps/api/src/auth-api-key/define-api-key-auth.ts b/examples/sample-code-review/apps/api/src/auth-api-key/define-api-key-auth.ts index 6188de25d..c05fc54c1 100644 --- a/examples/sample-code-review/apps/api/src/auth-api-key/define-api-key-auth.ts +++ b/examples/sample-code-review/apps/api/src/auth-api-key/define-api-key-auth.ts @@ -1,4 +1,4 @@ -import { defineModuleResource } from '@concepta/rockets-core'; +import { defineAuthAdapter, defineModuleResource } from '@concepta/rockets-core'; import type { AuthBootstrap } from '@concepta/rockets-core'; import { ApiKeyAuthAdapter } from './api-key.adapter'; import { ApiKeyController } from './api-key.controller'; @@ -12,13 +12,7 @@ export const apiKeyAuthResource = defineModuleResource({ * API key auth chain entry. Pair with `apiKeyAuthResource` in `resources[]`. */ export function defineApiKeyAuth(): AuthBootstrap { - return { - adapter: ApiKeyAuthAdapter, - forRoot: () => ({ - module: class ApiKeyAuthHostModule {}, - providers: [ApiKeyAuthAdapter], - controllers: [ApiKeyController], - exports: [ApiKeyAuthAdapter], - }), - }; + return defineAuthAdapter(ApiKeyAuthAdapter, { + controllers: [ApiKeyController], + }); } diff --git a/examples/sample-code-review/apps/api/src/repository/define-typeorm-repository.ts b/examples/sample-code-review/apps/api/src/repository/define-typeorm-repository.ts deleted file mode 100644 index 38fed55b7..000000000 --- a/examples/sample-code-review/apps/api/src/repository/define-typeorm-repository.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { DynamicModule, PlainLiteralObject, Type } from '@nestjs/common'; -import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { TypeOrmRepositoryModule } from '@concepta/rockets-repository-typeorm'; -import type { RepositoryBootstrap } from '@concepta/rockets-core'; -import type { - DynamicRepositoryModule, - RepositoryProviderOptions, -} from '@concepta/nestjs-repository'; - -export function defineTypeOrmRepository( - connection: Connection, -): RepositoryBootstrap { - return { - name: 'typeorm-bootstrap', - forFeature(entities: RepositoryProviderOptions[]): DynamicRepositoryModule { - return TypeOrmRepositoryModule.forFeature(entities); - }, - forRoot( - entities: ReadonlyArray>, - ): DynamicModule { - return TypeOrmModule.forRoot({ - ...connection, - entities: [...entities], - }); - }, - }; -} diff --git a/examples/sample-code-review/apps/api/vitest.e2e.config.ts b/examples/sample-code-review/apps/api/vitest.e2e.config.mts similarity index 84% rename from examples/sample-code-review/apps/api/vitest.e2e.config.ts rename to examples/sample-code-review/apps/api/vitest.e2e.config.mts index 65de1ad8a..8a78e0004 100644 --- a/examples/sample-code-review/apps/api/vitest.e2e.config.ts +++ b/examples/sample-code-review/apps/api/vitest.e2e.config.mts @@ -1,10 +1,11 @@ import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { defineProject, mergeConfig } from 'vitest/config'; -import shared from '../../../../vitest.shared'; +import shared from '../../../../vitest.shared.mts'; /** * E2E project for the sample-code-review API — registered in the root - * `vitest.config.ts` `projects` list and runnable standalone via + * `vitest.config.mts` `projects` list and runnable standalone via * `--config` from the workspace. The Jest `moduleNameMapper` stub swap (real * Firestore persistence -> in-memory backend stub) is ported as a regex * alias below. @@ -20,7 +21,7 @@ export default mergeConfig( // moduleNameMapper). find: /^\.\.\/repository\/code-review-reports\.persistence$/, replacement: path.resolve( - __dirname, + path.dirname(fileURLToPath(import.meta.url)), 'test/stubs/code-review-reports.persistence.stub.ts', ), }, diff --git a/examples/sample-server-auth/package.json b/examples/sample-server-auth/package.json index 3cb358205..e8d2225bc 100644 --- a/examples/sample-server-auth/package.json +++ b/examples/sample-server-auth/package.json @@ -9,7 +9,7 @@ "build": "cd ../.. && ./node_modules/.bin/tsc -p examples/sample-server-auth/tsconfig.json", "lint": "cd ../.. && eslint \"examples/sample-server-auth/{src,test}/**/*.{ts,js}\"", "pretest:e2e": "cd ../.. && yarn workspace @concepta/rockets build && yarn workspace @concepta/rockets-auth build && ./node_modules/.bin/tsc -p examples/sample-server-auth/tsconfig.json", - "test:e2e": "vitest run --config vitest.e2e.config.ts" + "test:e2e": "vitest run --config vitest.e2e.config.mts" }, "dependencies": { "@concepta/nestjs-access-control": "8.0.0-alpha.8", @@ -46,7 +46,7 @@ "@nestjs/testing": "12.0.0-alpha.5", "@swc/core": "^1.15.47", "@types/jsonwebtoken": "^9.0.3", - "@types/node": "^18.19.44", + "@types/node": "^20.19.0", "@types/supertest": "^6.0.2", "supertest": "^6.3.4", "ts-node": "^10.9.2", diff --git a/examples/sample-server-auth/src/app.module.ts b/examples/sample-server-auth/src/app.module.ts index 7b404a7e4..b540c2691 100644 --- a/examples/sample-server-auth/src/app.module.ts +++ b/examples/sample-server-auth/src/app.module.ts @@ -6,6 +6,7 @@ import { type EmailSendOptionsInterface, } from '@concepta/rockets-auth'; import { RocketsModule } from '@concepta/rockets'; +import { defineTypeOrmRepository } from '@concepta/rockets-repository-typeorm'; import { ACService } from './access-control.service'; import { acRules } from './app.acl'; @@ -41,9 +42,8 @@ import { } from './modules/user'; import { RoleEntity, RoleDto, RoleUpdateDto } from './modules/role'; import { RoleCreateDto } from './modules/role/role.dto'; -import { defineTypeOrmRepository } from './repository/define-typeorm-repository'; -// Single TypeORM bootstrap shared by every persistence consumer below. +// Single TypeORM bootstrap owned by the auth integration below. // `defineTypeOrmRepository` returns a `RepositoryBootstrap`, which the // planner uses for both: // - `forRoot(planEntities)` — DB connection + the union of every @@ -51,9 +51,8 @@ import { defineTypeOrmRepository } from './repository/define-typeorm-repository' // `defineRocketsAuth({ persistence })`. // - `forFeature(entities)` — one `DYNAMIC_REPOSITORY_TOKEN_` // provider per registered entity. -// Reference equality matters: pass the SAME `repo` instance everywhere, -// otherwise the planner splits the entity list across two adapters and -// `TypeOrmModule.forRoot` boots with an incomplete entity set. +// `defineRocketsAuth` contributes this same bootstrap to Rockets, so the host +// does not need to repeat it in `RocketsModule.forRoot`. const repo = defineTypeOrmRepository({ type: 'sqlite', database: ':memory:', @@ -181,9 +180,6 @@ const rocketsAuth = defineRocketsAuth(rocketsAuthInput); PetModule, RocketsModule.forRoot({ auth: rocketsAuth, - userMetadata: rocketsAuthInput.userMetadata, - enableGlobalGuard: false, - repository: repo, resources: [ createPetResource(), createPetVaccinationResource(), diff --git a/examples/sample-server-auth/src/repository/define-typeorm-repository.ts b/examples/sample-server-auth/src/repository/define-typeorm-repository.ts deleted file mode 100644 index 4cbb1cc9c..000000000 --- a/examples/sample-server-auth/src/repository/define-typeorm-repository.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { DynamicModule, PlainLiteralObject, Type } from '@nestjs/common'; -import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { TypeOrmRepositoryModule } from '@concepta/rockets-repository-typeorm'; -import type { RepositoryBootstrap } from '@concepta/rockets-core'; -import type { - DynamicRepositoryModule, - RepositoryProviderOptions, -} from '@concepta/nestjs-repository'; - -/** - * Returns a `RepositoryBootstrap` that: - * - Forwards `forFeature(entities)` to upstream `TypeOrmRepositoryModule`. - * - Implements `forRoot(entities)` by wrapping `TypeOrmModule.forRoot` - * with the connection options the caller passed in plus the entity set - * the Rockets planner derived from `resources[]`, `userMetadata`, and - * `defineRocketsAuth({ persistence })`. - * - * Pass the SAME instance to every persistence consumer in the app: - * - `RocketsModule.forRoot({ repository: repo })` (root adapter) - * - `defineRocketsAuth({ persistence: { module: repo } })` - * - * The planner uses reference equality to group entities per adapter and - * to decide whether to call `forRoot`. Splitting `repo` into two - * distinct objects would split the entity list and break the connection. - * - * Identical to the helper in `examples/sample-server` — duplicated here so - * each sample app boots without depending on the other's source tree. - * - */ -export function defineTypeOrmRepository< - Connection extends TypeOrmModuleOptions, ->(connection: Connection): RepositoryBootstrap { - return { - name: 'typeorm-bootstrap', - - forFeature(entities: RepositoryProviderOptions[]): DynamicRepositoryModule { - return TypeOrmRepositoryModule.forFeature(entities); - }, - - forRoot(entities: ReadonlyArray>): DynamicModule { - return TypeOrmModule.forRoot({ - ...connection, - entities: [...entities], - }); - }, - }; -} diff --git a/examples/sample-server-auth/vitest.e2e.config.ts b/examples/sample-server-auth/vitest.e2e.config.mts similarity index 87% rename from examples/sample-server-auth/vitest.e2e.config.ts rename to examples/sample-server-auth/vitest.e2e.config.mts index 69e67b0f9..5acaa6214 100644 --- a/examples/sample-server-auth/vitest.e2e.config.ts +++ b/examples/sample-server-auth/vitest.e2e.config.mts @@ -1,9 +1,9 @@ import { defineProject, mergeConfig } from 'vitest/config'; -import shared from '../../vitest.shared'; +import shared from '../../vitest.shared.mts'; /** * E2E project for the sample-server-auth example — registered in the root - * `vitest.config.ts` `projects` list and runnable standalone via + * `vitest.config.mts` `projects` list and runnable standalone via * `--config` from the workspace. The former Jest `moduleNameMapper` * entries are not ported: workspace packages (`@concepta/*`) resolve to their built `dist` * through normal node resolution, and the `@nestjs/*` / `@concepta/*` / diff --git a/examples/sample-server/README.md b/examples/sample-server/README.md index d01a3c961..4d12fc3e4 100644 --- a/examples/sample-server/README.md +++ b/examples/sample-server/README.md @@ -139,7 +139,6 @@ Handwritten entity + DTO path still demonstrated in `pet-vaccination/` for compa examples/sample-server ├── src/ │ ├── auth/ AuthBootstrap + JWT signup/login -│ ├── repository/ defineTypeOrmRepository bootstrap │ ├── zod-bindings.ts bindZodResources(typeOrmZodEntityCompiler) │ ├── user-metadata.schema.ts zod schema -> { entity, createDto, updateDto, responseDto } │ ├── resources/ CRUD + sub-resource + module bundles diff --git a/examples/sample-server/package.json b/examples/sample-server/package.json index c6a6ce8fd..24b021c1f 100644 --- a/examples/sample-server/package.json +++ b/examples/sample-server/package.json @@ -7,7 +7,7 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "build": "tsc -p tsconfig.json", - "test:e2e": "vitest run --config vitest.e2e.config.ts" + "test:e2e": "vitest run --config vitest.e2e.config.mts" }, "dependencies": { "@concepta/nestjs-core": "8.0.0-alpha.8", @@ -39,7 +39,7 @@ "@nestjs/testing": "12.0.0-alpha.5", "@swc/core": "^1.15.47", "@types/jsonwebtoken": "^9.0.3", - "@types/node": "^18.19.44", + "@types/node": "^20.19.0", "@types/supertest": "^6.0.2", "supertest": "^6.3.4", "ts-loader": "^9.5.1", diff --git a/examples/sample-server/src/app.module.ts b/examples/sample-server/src/app.module.ts index 494fb5d90..6b907411f 100644 --- a/examples/sample-server/src/app.module.ts +++ b/examples/sample-server/src/app.module.ts @@ -1,8 +1,8 @@ import { Module } from '@nestjs/common'; -import { RocketsModule } from '@concepta/rockets'; +import { createServer } from '@concepta/rockets'; +import { defineTypeOrmRepository } from '@concepta/rockets-repository-typeorm'; import { userMetadataConfig } from './user-metadata.schema'; import { defineSampleAuth, sampleAuthUserResource } from './auth'; -import { defineTypeOrmRepository } from './repository/define-typeorm-repository'; import { petResource } from './resources/pet'; import { petVaccinationResource } from './resources/pet-vaccination'; // `/tags` is fully zod-driven (nestjs-zod DTOs + generated entity from @@ -26,33 +26,33 @@ import { adminFeature } from './admin'; import { auditFeature } from './audit'; import { eventsFeature } from './events'; -@Module({ - imports: [ - RocketsModule.forRoot({ - auth: defineSampleAuth(), - userMetadata: userMetadataConfig, - repository: defineTypeOrmRepository({ - type: 'sqlite', - database: ':memory:', - synchronize: true, - dropSchema: true, - }), - resources: [ - sampleAuthUserResource, - petResource, - petVaccinationResource, - tagZodResource, - authorZodResource, - bookZodResource, - appointmentResource, - reminderZodResource, - petShareFeature, - petTransferFeature, - adminFeature, - auditFeature, - eventsFeature, - ], - }), +export const server = createServer({ + auth: defineSampleAuth(), + userMetadata: userMetadataConfig, + repository: defineTypeOrmRepository({ + type: 'sqlite', + database: ':memory:', + synchronize: true, + dropSchema: true, + }), + resources: [ + sampleAuthUserResource, + petResource, + petVaccinationResource, + tagZodResource, + authorZodResource, + bookZodResource, + appointmentResource, + reminderZodResource, + petShareFeature, + petTransferFeature, + adminFeature, + auditFeature, + eventsFeature, ], +}); + +@Module({ + imports: [server], }) export class AppModule {} diff --git a/examples/sample-server/src/auth/define-sample-auth.ts b/examples/sample-server/src/auth/define-sample-auth.ts index f8f5bc18c..25921639d 100644 --- a/examples/sample-server/src/auth/define-sample-auth.ts +++ b/examples/sample-server/src/auth/define-sample-auth.ts @@ -1,5 +1,5 @@ import type { AuthBootstrap } from '@concepta/rockets-core'; -import { defineModuleResource } from '@concepta/rockets-core'; +import { defineAuthAdapter, defineModuleResource } from '@concepta/rockets-core'; import { UserEntity } from './user.entity'; import { AuthController } from './auth.controller'; import { SampleAuthAdapter } from './auth.adapter'; @@ -14,13 +14,7 @@ export const sampleAuthUserResource = defineModuleResource({ * `RocketsModule.forRoot({ resources: [...] })`. */ export function defineSampleAuth(): AuthBootstrap { - return { - adapter: SampleAuthAdapter, - forRoot: () => ({ - module: class SampleAuthHostModule {}, - providers: [SampleAuthAdapter], - controllers: [AuthController], - exports: [SampleAuthAdapter], - }), - }; + return defineAuthAdapter(SampleAuthAdapter, { + controllers: [AuthController], + }); } diff --git a/examples/sample-server/src/main.ts b/examples/sample-server/src/main.ts index 82347c744..21d6c3973 100644 --- a/examples/sample-server/src/main.ts +++ b/examples/sample-server/src/main.ts @@ -3,7 +3,7 @@ import { HttpAdapterHost, NestFactory } from '@nestjs/core'; import { StandardSchemaValidationPipe, ValidationPipe } from '@nestjs/common'; import { SwaggerModule } from '@nestjs/swagger'; import { cleanupOpenApiDoc } from 'nestjs-zod'; -import { AppModule } from './app.module'; +import { server } from './app.module'; import { ExceptionsFilter } from '@concepta/rockets'; import helmet from 'helmet'; @@ -12,7 +12,7 @@ import { patchMePatchOpenApi } from './swagger/patch-me-openapi'; import { SwaggerUiService } from '@concepta/rockets-core'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(server); app.use(helmet()); app.enableCors({ diff --git a/examples/sample-server/src/repository/define-typeorm-repository.ts b/examples/sample-server/src/repository/define-typeorm-repository.ts deleted file mode 100644 index 196818d75..000000000 --- a/examples/sample-server/src/repository/define-typeorm-repository.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { DynamicModule, PlainLiteralObject, Type } from '@nestjs/common'; -import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { TypeOrmRepositoryModule } from '@concepta/rockets-repository-typeorm'; -import type { RepositoryBootstrap } from '@concepta/rockets-core'; -import type { - DynamicRepositoryModule, - RepositoryProviderOptions, -} from '@concepta/nestjs-repository'; - -/** - * Returns a `RepositoryBootstrap` that: - * - Forwards `forFeature(entities)` to upstream `TypeOrmRepositoryModule`. - * - Implements `forRoot(entities)` by wrapping `TypeOrmModule.forRoot` - * with the connection options the user passed in plus the entity set - * the Rockets registration plan derived from `resources[]` and - * `userMetadata`. - * - * The user passes one factory call to `RocketsModule.forRoot(...)`; the - * connection and the per-entity registration come out of a single - * source of truth. Any `entities` key on the supplied connection is - * overridden with the registration plan's entity list. - * - * The `Connection` generic carries the concrete union member (e.g. - * `SqliteConnectionOptions`) chosen at the callsite, preserving - * driver-specific discrimination through the spread. - */ -export function defineTypeOrmRepository( - connection: Connection, -): RepositoryBootstrap { - return { - name: 'typeorm-bootstrap', - - forFeature(entities: RepositoryProviderOptions[]): DynamicRepositoryModule { - return TypeOrmRepositoryModule.forFeature(entities); - }, - - forRoot( - entities: ReadonlyArray>, - ): DynamicModule { - return TypeOrmModule.forRoot({ - ...connection, - entities: [...entities], - }); - }, - }; -} diff --git a/examples/sample-server/vitest.e2e.config.ts b/examples/sample-server/vitest.e2e.config.mts similarity index 84% rename from examples/sample-server/vitest.e2e.config.ts rename to examples/sample-server/vitest.e2e.config.mts index 62e44af8c..a131ff13f 100644 --- a/examples/sample-server/vitest.e2e.config.ts +++ b/examples/sample-server/vitest.e2e.config.mts @@ -1,9 +1,9 @@ import { defineProject, mergeConfig } from 'vitest/config'; -import shared from '../../vitest.shared'; +import shared from '../../vitest.shared.mts'; /** * E2E project for the sample-server example — registered in the root - * `vitest.config.ts` `projects` list and runnable standalone via + * `vitest.config.mts` `projects` list and runnable standalone via * `--config` from the workspace. */ export default mergeConfig( diff --git a/firebase.json b/firebase.json new file mode 100644 index 000000000..ecfc838f2 --- /dev/null +++ b/firebase.json @@ -0,0 +1,14 @@ +{ + "firestore": { + "rules": "firestore.rules" + }, + "emulators": { + "firestore": { + "port": 8088 + }, + "ui": { + "enabled": false + }, + "singleProjectMode": true + } +} diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 000000000..b9dd67c55 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,8 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + allow read, write: if false; + } + } +} diff --git a/package.json b/package.json index aa0321827..da2a6c4e8 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "0.0.1-dev.0", "license": "BSD-3-Clause", "private": true, + "engines": { + "node": ">=20.0.0" + }, "workspaces": { "packages": [ "packages/*", @@ -22,7 +25,6 @@ "@nestjs/swagger": "12.0.0-alpha.2", "jws": "3.2.3", "qs": "6.15.2", - "path-to-regexp": "8.4.0", "form-data": "4.0.6", "multer": "2.2.0", "tar-fs": "2.1.4", @@ -66,7 +68,7 @@ "@nestjs/testing": "12.0.0-alpha.5", "@swc/core": "^1.15.47", "@types/express": "^4.17.21", - "@types/node": "^18.19.44", + "@types/node": "^20.19.0", "@types/nodemailer": "^6.4.15", "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^5.62.0", @@ -81,6 +83,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-tsdoc": "^0.3.0", + "firebase-tools": "15.15.0", "husky": "^7.0.4", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -116,15 +119,16 @@ "lint:all": "yarn lint && yarn lint:md", "test": "vitest run --project unit", "test:watch": "vitest --project unit", + "test:config-native": "vitest list --project unit --configLoader native", "test:cov": "vitest run --project unit --coverage --coverage.thresholds.statements=50 --coverage.thresholds.branches=50 --coverage.thresholds.functions=40 --coverage.thresholds.lines=50", "test:ci": "vitest run --project unit --coverage --coverage.thresholds.statements=50 --coverage.thresholds.branches=50 --coverage.thresholds.functions=40 --coverage.thresholds.lines=50 --reporter=default --reporter=junit --outputFile=junit.xml", "test:debug": "vitest --inspect-brk --no-file-parallelism", "test:e2e": "vitest run --project e2e-packages", + "test:firestore-emulator": "firebase emulators:exec --only firestore --project demo-rockets --config firebase.json 'vitest run --config vitest.firestore.config.mts'", "test:e2e:cov": "vitest run --project e2e-packages --coverage --coverage.reportsDirectory=coverage-e2e --maxWorkers=1", "test:all": "vitest run", "doc": "rimraf ./docs && typedoc", "doc:cov": "yarn doc --coverageOutputType all", - "generate-swagger": "cd packages/rockets-server-auth && yarn generate-swagger", "sample:start": "yarn workspace sample-server start:dev", "sample:go": "yarn build && yarn workspace sample-server start:dev", "sample:once": "yarn workspace sample-server start", @@ -140,14 +144,15 @@ "sample-code-review:dev": "yarn build && yarn workspace sample-code-review dev", "sample-code-review:build": "yarn build && yarn workspace sample-code-review build", "sample-code-review:test:e2e": "yarn build && yarn workspace sample-code-review test:e2e", - "samples:build": "yarn sample:build && yarn sample-auth:build", - "samples:test:e2e": "yarn sample:test:e2e && yarn sample-auth:test:e2e", + "samples:build": "yarn sample:build && yarn sample-auth:build && yarn workspace sample-code-review build", + "samples:test:e2e": "yarn sample:test:e2e && yarn sample-auth:test:e2e && yarn workspace sample-code-review test:e2e", "codacy:analyze": ".codacy/cli.sh analyze", "codacy:analyze:fix": ".codacy/cli.sh analyze --fix", "codacy:analyze:file": ".codacy/cli.sh analyze", "codacy:analyze:security": ".codacy/cli.sh analyze -t trivy", "typecheck:spec": "tsc --noEmit -p tsconfig.spec.json && yarn workspace @concepta/rockets-core test:typetests", - "release:check": "node -e \"const v=process.env.npm_config_user_agent||'';if(!/yarn\\\\/4/.test(v)){console.error('\\\\n Release commands require Yarn 4. Run them via: corepack yarn