@xbg.solutions/create-backend 1.0.1 → 1.0.2
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.
- package/lib/commands/init.js +55 -12
- package/lib/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/src/project-template/.claude/settings.local.json +57 -0
- package/src/project-template/.claude/skills/bpbe/api/skill.md +403 -0
- package/src/project-template/.claude/skills/bpbe/data/skill.md +442 -0
- package/src/project-template/.claude/skills/bpbe/services/skill.md +497 -0
- package/src/project-template/.claude/skills/bpbe/setup/skill.md +301 -0
- package/src/project-template/.claude/skills/bpbe/skill.md +153 -0
- package/src/project-template/.claude/skills/bpbe/utils/skill.md +527 -0
- package/src/project-template/.claude/skills/skill.md +30 -0
|
@@ -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
|
+
```
|