@xbg.solutions/create-backend 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,442 @@
1
+ ---
2
+ description: "Data layer for the XBG boilerplate backend: defining entities with BaseEntity, writing repositories with BaseRepository, declaring DataModelSpecification, and using the code generator."
3
+ ---
4
+
5
+ # XBG Boilerplate Backend — Data Layer
6
+
7
+ Covers: `BaseEntity`, `BaseRepository`, `DataModelSpecification`, the generator, and Firestore patterns.
8
+
9
+ All base classes are imported from `@xbg.solutions/backend-core`.
10
+
11
+ ---
12
+
13
+ ## BaseEntity — All Entities Extend This
14
+
15
+ **Package:** `@xbg.solutions/backend-core`
16
+
17
+ Every domain entity extends `BaseEntity`, which provides timestamps, soft-delete, versioning, and validation scaffolding.
18
+
19
+ ### Implementing an Entity
20
+
21
+ ```typescript
22
+ import { BaseEntity, BaseEntityData, ValidationHelper, ValidationResult } from '@xbg.solutions/backend-core';
23
+ import { Timestamp } from 'firebase-admin/firestore';
24
+
25
+ interface ProductData extends BaseEntityData {
26
+ name?: string;
27
+ price?: number;
28
+ status?: 'active' | 'archived';
29
+ categoryId?: string;
30
+ }
31
+
32
+ export class Product extends BaseEntity {
33
+ name: string;
34
+ price: number;
35
+ status: 'active' | 'archived';
36
+ categoryId: string;
37
+
38
+ constructor(data: ProductData) {
39
+ super(data);
40
+ this.name = data.name ?? '';
41
+ this.price = data.price ?? 0;
42
+ this.status = data.status ?? 'active';
43
+ this.categoryId = data.categoryId ?? '';
44
+ }
45
+
46
+ // REQUIRED: serialize domain fields for Firestore
47
+ protected getEntityData(): Record<string, any> {
48
+ return {
49
+ name: this.name,
50
+ price: this.price,
51
+ status: this.status,
52
+ categoryId: this.categoryId,
53
+ };
54
+ }
55
+
56
+ // REQUIRED: validate entity state before save
57
+ validate(): ValidationResult {
58
+ const errors = ValidationHelper.collectErrors(
59
+ ValidationHelper.required(this.name, 'name'),
60
+ ValidationHelper.minLength(this.name, 3, 'name'),
61
+ ValidationHelper.maxLength(this.name, 100, 'name'),
62
+ ValidationHelper.required(this.price, 'price'),
63
+ ValidationHelper.range(this.price, 0.01, 1_000_000, 'price'),
64
+ ValidationHelper.required(this.categoryId, 'categoryId'),
65
+ ValidationHelper.oneOf(this.status, ['active', 'archived'], 'status'),
66
+ );
67
+ return ValidationHelper.isValidResult(errors);
68
+ }
69
+ }
70
+ ```
71
+
72
+ ### BaseEntity Fields (Always Present)
73
+
74
+ | Field | Type | Description |
75
+ |---|---|---|
76
+ | `id` | `string \| undefined` | Firestore doc ID (set on create) |
77
+ | `createdAt` | `Timestamp \| FieldValue` | Server-set on first write |
78
+ | `updatedAt` | `Timestamp \| FieldValue` | Server-set on every write |
79
+ | `deletedAt` | `Timestamp \| null` | Non-null = soft deleted |
80
+ | `version` | `number` | Increments on each update (optimistic lock) |
81
+
82
+ ### ValidationHelper Reference
83
+
84
+ ```typescript
85
+ ValidationHelper.required(value, 'fieldName') // must be non-null/empty
86
+ ValidationHelper.minLength(str, 3, 'fieldName') // string min chars
87
+ ValidationHelper.maxLength(str, 100, 'fieldName') // string max chars
88
+ ValidationHelper.email(str, 'fieldName') // valid email format
89
+ ValidationHelper.range(num, 0, 1000, 'fieldName') // numeric range
90
+ ValidationHelper.oneOf(val, ['a', 'b'], 'fieldName') // enum membership
91
+ ValidationHelper.pattern(str, /regex/, 'fieldName') // regex match
92
+ ValidationHelper.collectErrors(err1, err2, ...) // filter nulls
93
+ ValidationHelper.isValidResult(errors) // { valid, errors }
94
+ ```
95
+
96
+ ---
97
+
98
+ ## BaseRepository — Data Access Layer
99
+
100
+ **Package:** `@xbg.solutions/backend-core`
101
+
102
+ ### Implementing a Repository
103
+
104
+ ```typescript
105
+ import { Firestore, DocumentData } from 'firebase-admin/firestore';
106
+ import { BaseRepository } from '@xbg.solutions/backend-core';
107
+ import { Product } from '../entities/Product';
108
+
109
+ export class ProductRepository extends BaseRepository<Product> {
110
+ protected collectionName = 'products';
111
+
112
+ constructor(db: Firestore) {
113
+ super(db);
114
+ }
115
+
116
+ // REQUIRED: deserialize Firestore doc → entity
117
+ protected fromFirestore(id: string, data: DocumentData): Product {
118
+ return new Product({
119
+ id,
120
+ name: data.name,
121
+ price: data.price,
122
+ status: data.status,
123
+ categoryId: data.categoryId,
124
+ createdAt: data.createdAt,
125
+ updatedAt: data.updatedAt,
126
+ deletedAt: data.deletedAt ?? null,
127
+ version: data.version,
128
+ });
129
+ }
130
+ }
131
+ ```
132
+
133
+ ### Built-In CRUD Methods
134
+
135
+ ```typescript
136
+ // CREATE
137
+ const product = await repo.create(new Product({ name: 'Widget', price: 9.99, ... }));
138
+
139
+ // READ
140
+ const product = await repo.findById('product-id'); // null if not found
141
+ const product = await repo.findByIdCached('product-id'); // with cache layer
142
+ const products = await repo.findAll({ limit: 20 });
143
+ const exists = await repo.exists('product-id');
144
+ const count = await repo.count();
145
+
146
+ // UPDATE — always takes full entity, not a patch
147
+ product.price = 12.99;
148
+ const updated = await repo.update(product); // auto-increments version
149
+
150
+ // DELETE
151
+ await repo.delete('product-id'); // soft delete (sets deletedAt)
152
+ await repo.delete('product-id', true); // hard delete (permanent)
153
+
154
+ // BATCH
155
+ await repo.batchCreate([product1, product2, product3]);
156
+ ```
157
+
158
+ ### QueryOptions — Filtering and Sorting
159
+
160
+ ```typescript
161
+ import { QueryOptions } from '@xbg.solutions/backend-core';
162
+
163
+ const options: QueryOptions = {
164
+ limit: 20,
165
+ offset: 0,
166
+ orderBy: [
167
+ { field: 'price', direction: 'asc' },
168
+ { field: 'name', direction: 'asc' },
169
+ ],
170
+ where: [
171
+ { field: 'status', operator: '==', value: 'active' },
172
+ { field: 'price', operator: '<=', value: 100 },
173
+ { field: 'categoryId', operator: '==', value: 'cat-123' },
174
+ ],
175
+ includeSoftDeleted: false, // default false — exclude deletedAt != null
176
+ };
177
+
178
+ const products = await repo.findAll(options);
179
+ ```
180
+
181
+ **Supported operators:** `==`, `!=`, `<`, `<=`, `>`, `>=`, `in`, `not-in`, `array-contains`, `array-contains-any`
182
+
183
+ ### Pagination
184
+
185
+ ```typescript
186
+ const page = await repo.findPaginated(1, 20, {
187
+ where: [{ field: 'status', operator: '==', value: 'active' }],
188
+ orderBy: [{ field: 'createdAt', direction: 'desc' }],
189
+ });
190
+ // page.data: Product[]
191
+ // page.total: number
192
+ // page.hasMore: boolean
193
+ // page.page: number
194
+ // page.pageSize: number
195
+ ```
196
+
197
+ ### Caching (opt-in per repository)
198
+
199
+ ```typescript
200
+ export class ProductRepository extends BaseRepository<Product> {
201
+ protected collectionName = 'products';
202
+
203
+ // Opt into caching
204
+ protected cacheConfig = {
205
+ enabled: true, // must also set CACHE_ENABLED=true in .env
206
+ provider: 'memory' as const, // 'memory' | 'firestore' | 'redis'
207
+ ttl: 300, // seconds
208
+ keyPrefix: 'product',
209
+ tags: ['products'], // for bulk invalidation
210
+ };
211
+
212
+ // ...fromFirestore...
213
+ }
214
+
215
+ // Usage
216
+ const product = await repo.findByIdCached('p-123');
217
+ const fresh = await repo.findByIdCached('p-123', { forceRefresh: true });
218
+ ```
219
+
220
+ Mutations (`create`, `update`, `delete`) automatically invalidate the cache. You never need to do this manually.
221
+
222
+ ### Custom Query Methods
223
+
224
+ Add domain-specific finders to the repository:
225
+
226
+ ```typescript
227
+ export class ProductRepository extends BaseRepository<Product> {
228
+ protected collectionName = 'products';
229
+
230
+ // Custom finder by category
231
+ async findByCategory(categoryId: string): Promise<Product[]> {
232
+ return this.findAll({
233
+ where: [{ field: 'categoryId', operator: '==', value: categoryId }],
234
+ orderBy: [{ field: 'name', direction: 'asc' }],
235
+ });
236
+ }
237
+
238
+ // Custom finder for price range
239
+ async findByPriceRange(min: number, max: number): Promise<Product[]> {
240
+ return this.findAll({
241
+ where: [
242
+ { field: 'price', operator: '>=', value: min },
243
+ { field: 'price', operator: '<=', value: max },
244
+ { field: 'status', operator: '==', value: 'active' },
245
+ ],
246
+ });
247
+ }
248
+
249
+ protected fromFirestore(id: string, data: DocumentData): Product { ... }
250
+ }
251
+ ```
252
+
253
+ ---
254
+
255
+ ## DataModelSpecification — Generator Input Format
256
+
257
+ **Package:** `@xbg.solutions/backend-core` (exported type)
258
+
259
+ The generator takes a `DataModelSpecification` and produces Entity/Repository/Service/Controller files.
260
+
261
+ ### Complete Example
262
+
263
+ ```typescript
264
+ import { DataModelSpecification } from '@xbg.solutions/backend-core';
265
+
266
+ export const EcommerceModel: DataModelSpecification = {
267
+ entities: {
268
+ Product: {
269
+ description: 'A product available for purchase',
270
+
271
+ fields: {
272
+ name: { type: 'string', required: true, minLength: 3, maxLength: 100 },
273
+ description: { type: 'string', required: false },
274
+ price: { type: 'number', required: true, min: 0.01 },
275
+ status: { type: 'enum', values: ['active', 'archived'], default: 'active' },
276
+ categoryId: { type: 'reference', entity: 'Category', required: true },
277
+ imageUrl: { type: 'url', required: false },
278
+ inStock: { type: 'boolean', default: true },
279
+ tags: { type: 'array' },
280
+ },
281
+
282
+ relationships: {
283
+ category: { type: 'many-to-one', entity: 'Category', foreignKey: 'categoryId' },
284
+ reviews: { type: 'one-to-many', entity: 'Review', foreignKey: 'productId' },
285
+ },
286
+
287
+ access: {
288
+ create: ['admin'],
289
+ read: ['public'],
290
+ update: ['admin'],
291
+ delete: ['admin'],
292
+ },
293
+
294
+ validation: {
295
+ price: 'Must be greater than 0',
296
+ name: 'Must be unique within category',
297
+ },
298
+
299
+ indexes: [
300
+ { fields: ['categoryId', 'status'] },
301
+ { fields: ['name'], unique: true },
302
+ ],
303
+
304
+ businessRules: [
305
+ 'Products with active orders cannot be archived',
306
+ 'Price changes trigger a price-history event',
307
+ ],
308
+ },
309
+ },
310
+ };
311
+ ```
312
+
313
+ ### Field Types
314
+
315
+ | Type | TypeScript | Notes |
316
+ |---|---|---|
317
+ | `string` | `string` | General text |
318
+ | `number` | `number` | Integer or float |
319
+ | `boolean` | `boolean` | |
320
+ | `timestamp` | `Timestamp` | Firestore timestamp |
321
+ | `date` | `string` | ISO date string |
322
+ | `email` | `string` | Validated format |
323
+ | `url` | `string` | Validated URL |
324
+ | `uuid` | `string` | |
325
+ | `enum` | `string` | Requires `values: [...]` |
326
+ | `array` | `any[]` | |
327
+ | `reference` | `string` | Requires `entity: 'EntityName'` |
328
+ | `json` | `Record<string, any>` | Arbitrary object |
329
+
330
+ ### Relationship Types
331
+
332
+ | Type | Usage |
333
+ |---|---|
334
+ | `one-to-one` | `foreignKey` on either side |
335
+ | `one-to-many` | `foreignKey` on the child entity |
336
+ | `many-to-one` | `foreignKey` on this entity (common join) |
337
+ | `many-to-many` | Requires a junction entity (e.g., `PostTag`) |
338
+
339
+ ### Access Control Values
340
+
341
+ ```
342
+ 'public' → unauthenticated users
343
+ 'authenticated' → any logged-in user
344
+ 'self' → only the resource owner (userId == entity.userId)
345
+ 'admin' → users with role 'admin'
346
+ 'custom-role' → any string role you define
347
+ ```
348
+
349
+ ### Running the Generator
350
+
351
+ ```bash
352
+ # From functions/ directory:
353
+ npm run generate __examples__/ecommerce.model.ts
354
+
355
+ # Generates into functions/src/generated/:
356
+ # ├── entities/Product.ts
357
+ # ├── repositories/ProductRepository.ts
358
+ # ├── services/ProductService.ts
359
+ # └── controllers/ProductController.ts
360
+ ```
361
+
362
+ Generated files are a **starting point**. Copy to your own directory (e.g., `src/products/`) and modify. Don't edit in `src/generated/` — that's overwritten on re-generation.
363
+
364
+ ---
365
+
366
+ ## Firestore-Specific Patterns
367
+
368
+ ### Soft Delete Query Requirement
369
+
370
+ `BaseRepository.findAll()` always adds `where('deletedAt', '==', null)`. If you add other `where` clauses on different fields, Firestore requires a **composite index**:
371
+
372
+ ```
373
+ Collection: products
374
+ Fields: deletedAt ASC, categoryId ASC, price ASC
375
+ ```
376
+
377
+ Create via Firebase Console or `firestore.indexes.json`.
378
+
379
+ ### Multi-Database Setup
380
+
381
+ The project supports multiple Firestore databases. Configure via environment variables and the database config exported from `@xbg.solutions/backend-core`:
382
+
383
+ ```typescript
384
+ import { getFirestoreDb } from '@xbg.solutions/backend-core';
385
+
386
+ const mainDb = getFirestoreDb('main');
387
+ const productRepo = new ProductRepository(mainDb);
388
+ ```
389
+
390
+ ### Timestamps — Always Use ServerTimestamp
391
+
392
+ ```typescript
393
+ // ✅ Correct — server-authoritative timestamp
394
+ createdAt: FieldValue.serverTimestamp()
395
+
396
+ // ❌ Wrong — client clock can drift
397
+ createdAt: new Date()
398
+ createdAt: Timestamp.now()
399
+ ```
400
+
401
+ `BaseEntity` handles this automatically in `toFirestore()` — `updatedAt` is always server timestamp on write.
402
+
403
+ ---
404
+
405
+ ## Anti-Examples
406
+
407
+ ```typescript
408
+ // ❌ Don't put Firestore logic directly in a service
409
+ class ProductService extends BaseService<Product> {
410
+ async getByCategory(categoryId: string) {
411
+ const db = getFirestore(); // ← wrong layer
412
+ const snap = await db.collection('products').where(...).get();
413
+ // ...
414
+ }
415
+ }
416
+
417
+ // ✅ Put it in the repository
418
+ class ProductRepository extends BaseRepository<Product> {
419
+ async findByCategory(categoryId: string): Promise<Product[]> {
420
+ return this.findAll({ where: [{ field: 'categoryId', operator: '==', value: categoryId }] });
421
+ }
422
+ }
423
+
424
+ // ❌ Don't skip validate() before saving
425
+ const product = new Product(data);
426
+ await repo.create(product); // BaseRepository.create() calls validate() automatically — fine
427
+ // But if you call getCollection().doc().set() directly, you bypass validation
428
+
429
+ // ❌ Don't use hard delete by default
430
+ await repo.delete(id, true); // Only use when legally required (GDPR right to erasure)
431
+ // Soft delete is the default and preserves audit trail
432
+
433
+ // ❌ Don't forget to handle null from findById
434
+ const product = await repo.findById(id);
435
+ product.name; // ← TypeError if null!
436
+
437
+ // ✅ Always guard
438
+ const product = await repo.findById(id);
439
+ if (!product) {
440
+ return { success: false, error: { code: 'NOT_FOUND', message: 'Product not found' } };
441
+ }
442
+ ```