@pattern-stack/codegen 0.4.0 → 0.4.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.
Files changed (136) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/src/cli/index.js +1616 -1070
  3. package/dist/src/cli/index.js.map +1 -1
  4. package/package.json +3 -1
  5. package/runtime/analytics/index.ts +31 -0
  6. package/runtime/analytics/metrics.ts +85 -0
  7. package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
  8. package/runtime/analytics/packs/index.ts +5 -0
  9. package/runtime/analytics/packs/monetary-measures.ts +20 -0
  10. package/runtime/analytics/specs.ts +54 -0
  11. package/runtime/analytics/types.ts +105 -0
  12. package/runtime/base-classes/activity-entity-repository.ts +50 -0
  13. package/runtime/base-classes/activity-entity-service.ts +48 -0
  14. package/runtime/base-classes/base-read-use-cases.ts +88 -0
  15. package/runtime/base-classes/base-repository.ts +289 -0
  16. package/runtime/base-classes/base-service.ts +183 -0
  17. package/runtime/base-classes/index.ts +38 -0
  18. package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
  19. package/runtime/base-classes/knowledge-entity-service.ts +14 -0
  20. package/runtime/base-classes/lifecycle-events.ts +152 -0
  21. package/runtime/base-classes/metadata-entity-repository.ts +80 -0
  22. package/runtime/base-classes/metadata-entity-service.ts +48 -0
  23. package/runtime/base-classes/synced-entity-repository.ts +57 -0
  24. package/runtime/base-classes/synced-entity-service.ts +50 -0
  25. package/runtime/base-classes/with-analytics.ts +22 -0
  26. package/runtime/constants/tokens.ts +29 -0
  27. package/runtime/eav-helpers.ts +74 -0
  28. package/runtime/pipes/zod-validation.pipe.ts +64 -0
  29. package/runtime/shared/openapi/error-response.dto.ts +24 -0
  30. package/runtime/shared/openapi/errors.ts +39 -0
  31. package/runtime/shared/openapi/index.ts +20 -0
  32. package/runtime/shared/openapi/registry.tokens.ts +13 -0
  33. package/runtime/shared/openapi/registry.ts +151 -0
  34. package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
  35. package/runtime/subsystems/analytics/analytics.module.ts +64 -0
  36. package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
  37. package/runtime/subsystems/analytics/cube-backend.ts +75 -0
  38. package/runtime/subsystems/analytics/index.ts +15 -0
  39. package/runtime/subsystems/analytics/noop-backend.ts +27 -0
  40. package/runtime/subsystems/auth/auth.module.ts +91 -0
  41. package/runtime/subsystems/auth/auth.tokens.ts +27 -0
  42. package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
  43. package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
  44. package/runtime/subsystems/auth/index.ts +77 -0
  45. package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
  46. package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
  47. package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
  48. package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
  49. package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
  50. package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
  51. package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
  52. package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
  53. package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
  54. package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
  55. package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
  56. package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
  57. package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
  58. package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
  59. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
  60. package/runtime/subsystems/bridge/bridge.module.ts +160 -0
  61. package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
  62. package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
  63. package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
  64. package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
  65. package/runtime/subsystems/bridge/generated/registry.ts +6 -0
  66. package/runtime/subsystems/bridge/index.ts +84 -0
  67. package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
  68. package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
  69. package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
  70. package/runtime/subsystems/cache/cache.module.ts +115 -0
  71. package/runtime/subsystems/cache/cache.protocol.ts +45 -0
  72. package/runtime/subsystems/cache/cache.schema.ts +27 -0
  73. package/runtime/subsystems/cache/cache.tokens.ts +17 -0
  74. package/runtime/subsystems/cache/index.ts +22 -0
  75. package/runtime/subsystems/events/domain-events.schema.ts +77 -0
  76. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
  77. package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
  78. package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
  79. package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
  80. package/runtime/subsystems/events/events-errors.ts +30 -0
  81. package/runtime/subsystems/events/events.module.ts +230 -0
  82. package/runtime/subsystems/events/events.tokens.ts +62 -0
  83. package/runtime/subsystems/events/generated/bus.ts +103 -0
  84. package/runtime/subsystems/events/generated/index.ts +7 -0
  85. package/runtime/subsystems/events/generated/registry.ts +84 -0
  86. package/runtime/subsystems/events/generated/schemas.ts +59 -0
  87. package/runtime/subsystems/events/generated/types.ts +94 -0
  88. package/runtime/subsystems/events/index.ts +21 -0
  89. package/runtime/subsystems/index.ts +63 -0
  90. package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
  91. package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
  92. package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
  93. package/runtime/subsystems/jobs/index.ts +120 -0
  94. package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
  95. package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
  96. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
  97. package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +850 -0
  98. package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
  99. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
  100. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
  101. package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
  102. package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
  103. package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
  104. package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
  105. package/runtime/subsystems/jobs/job-worker.module.ts +302 -0
  106. package/runtime/subsystems/jobs/job-worker.ts +615 -0
  107. package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
  108. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
  109. package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
  110. package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
  111. package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
  112. package/runtime/subsystems/storage/index.ts +18 -0
  113. package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
  114. package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
  115. package/runtime/subsystems/storage/storage.module.ts +60 -0
  116. package/runtime/subsystems/storage/storage.protocol.ts +78 -0
  117. package/runtime/subsystems/storage/storage.tokens.ts +9 -0
  118. package/runtime/subsystems/storage/storage.utils.ts +20 -0
  119. package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
  120. package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
  121. package/runtime/subsystems/sync/index.ts +98 -0
  122. package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
  123. package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
  124. package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
  125. package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
  126. package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
  127. package/runtime/subsystems/sync/sync-errors.ts +54 -0
  128. package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
  129. package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
  130. package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
  131. package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
  132. package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
  133. package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
  134. package/runtime/subsystems/sync/sync.module.ts +156 -0
  135. package/runtime/subsystems/sync/sync.tokens.ts +57 -0
  136. package/runtime/types/drizzle.ts +23 -0
@@ -0,0 +1,289 @@
1
+ /**
2
+ * BaseRepository<TEntity>
3
+ *
4
+ * Abstract base class providing standard CRUD operations via Drizzle ORM.
5
+ * Every generated repository extends this class.
6
+ *
7
+ * Family-specific bases (CrmEntityRepository, etc.) extend this in v0.1
8
+ * without any changes to BaseRepository.
9
+ *
10
+ * NOT @Injectable — concrete repositories are @Injectable and inject DRIZZLE.
11
+ */
12
+ import { eq, inArray, isNull, sql } from 'drizzle-orm';
13
+ import type { PgTableWithColumns, PgColumn } from 'drizzle-orm/pg-core';
14
+ import type { SQL } from 'drizzle-orm';
15
+ import type { DrizzleClient, DrizzleTx } from '../types/drizzle';
16
+
17
+ // ============================================================================
18
+ // Interfaces
19
+ // ============================================================================
20
+
21
+ /**
22
+ * Behavior flags for the repository. Controls automatic timestamp injection
23
+ * and soft-delete filtering.
24
+ */
25
+ export interface BehaviorConfig {
26
+ timestamps: boolean;
27
+ softDelete: boolean;
28
+ userTracking: boolean;
29
+ }
30
+
31
+ /**
32
+ * Options for the list() method.
33
+ */
34
+ export interface ListOptions {
35
+ where?: SQL;
36
+ limit?: number;
37
+ offset?: number;
38
+ orderBy?: PgColumn | SQL;
39
+ }
40
+
41
+ // ============================================================================
42
+ // BaseRepository
43
+ // ============================================================================
44
+
45
+ export abstract class BaseRepository<TEntity> {
46
+ /**
47
+ * The Drizzle table schema for this entity.
48
+ * Concrete repositories declare this as a class property.
49
+ */
50
+ protected abstract readonly table: PgTableWithColumns<any>; // eslint-disable-line @typescript-eslint/no-explicit-any
51
+
52
+ /**
53
+ * Behavior flags controlling automatic behavior injection.
54
+ * Override in concrete repositories to enable behaviors.
55
+ */
56
+ protected readonly behaviors: BehaviorConfig = {
57
+ timestamps: false,
58
+ softDelete: false,
59
+ userTracking: false,
60
+ };
61
+
62
+ protected readonly db: DrizzleClient;
63
+
64
+ constructor(db: DrizzleClient) {
65
+ this.db = db;
66
+ }
67
+
68
+ /**
69
+ * Pick the runner for a write: the caller-supplied transaction handle
70
+ * if present, otherwise the repository's own client. Keeps the `tx`
71
+ * parameter purely additive — callers without a transaction call as
72
+ * before. Used by the write methods below + consumer overrides (e.g.
73
+ * the generated `upsertCurrentValues` on EAV value tables).
74
+ */
75
+ protected runner(tx?: DrizzleTx): DrizzleClient {
76
+ return tx ?? this.db;
77
+ }
78
+
79
+ // ============================================================================
80
+ // Read Operations
81
+ // ============================================================================
82
+
83
+ /**
84
+ * Find a single entity by its primary key.
85
+ * Returns null if not found (or soft-deleted when softDelete=true).
86
+ */
87
+ async findById(id: string): Promise<TEntity | null> {
88
+ const rows = await this.baseQuery()
89
+ .where(eq(this.table['id'], id))
90
+ .limit(1);
91
+ return (rows[0] as TEntity) ?? null;
92
+ }
93
+
94
+ /**
95
+ * Find multiple entities by their primary keys.
96
+ * Returns empty array immediately for empty input (avoids DB errors).
97
+ */
98
+ async findByIds(ids: string[]): Promise<TEntity[]> {
99
+ if (ids.length === 0) return [];
100
+ const rows = await this.baseQuery().where(inArray(this.table['id'], ids));
101
+ return rows as TEntity[];
102
+ }
103
+
104
+ /**
105
+ * List entities with optional filtering, pagination, and ordering.
106
+ */
107
+ async list(options?: ListOptions): Promise<TEntity[]> {
108
+ let query = this.baseQuery();
109
+
110
+ if (options?.where) {
111
+ query = query.where(options.where) as typeof query;
112
+ }
113
+ if (options?.orderBy) {
114
+ query = query.orderBy(options.orderBy as SQL) as typeof query;
115
+ }
116
+ if (options?.limit !== undefined) {
117
+ query = query.limit(options.limit) as typeof query;
118
+ }
119
+ if (options?.offset !== undefined) {
120
+ query = query.offset(options.offset) as typeof query;
121
+ }
122
+
123
+ const rows = await query;
124
+ return rows as TEntity[];
125
+ }
126
+
127
+ /**
128
+ * Count entities matching an optional WHERE clause.
129
+ * Soft-deleted rows are always excluded when softDelete=true.
130
+ */
131
+ async count(where?: SQL): Promise<number> {
132
+ let query = this.db
133
+ .select({ count: sql<number>`cast(count(*) as integer)` })
134
+ .from(this.table);
135
+
136
+ const conditions: SQL[] = [];
137
+ if (this.behaviors.softDelete) {
138
+ conditions.push(isNull(this.table['deletedAt']));
139
+ }
140
+ if (where) {
141
+ conditions.push(where);
142
+ }
143
+
144
+ if (conditions.length === 1) {
145
+ query = query.where(conditions[0]) as typeof query;
146
+ } else if (conditions.length > 1) {
147
+ // Combine with AND by building the condition inline
148
+ const { and } = await import('drizzle-orm');
149
+ query = query.where(and(...conditions)) as typeof query;
150
+ }
151
+
152
+ const rows = await query;
153
+ return rows[0]?.count ?? 0;
154
+ }
155
+
156
+ /**
157
+ * Check whether an entity with the given id exists.
158
+ */
159
+ async exists(id: string): Promise<boolean> {
160
+ const result = await this.findById(id);
161
+ return result !== null;
162
+ }
163
+
164
+ // ============================================================================
165
+ // Write Operations
166
+ // ============================================================================
167
+
168
+ /**
169
+ * Insert a new entity. Timestamps are auto-injected when timestamps=true.
170
+ */
171
+ async create(input: Partial<TEntity>, tx?: DrizzleTx): Promise<TEntity> {
172
+ const data = this.withTimestamps(input as Record<string, unknown>, 'create');
173
+ const rows = await this.runner(tx)
174
+ .insert(this.table)
175
+ .values(data as any) // eslint-disable-line @typescript-eslint/no-explicit-any
176
+ .returning();
177
+ return rows[0] as TEntity;
178
+ }
179
+
180
+ /**
181
+ * Update an existing entity by id. updatedAt is auto-injected when timestamps=true.
182
+ * Returns the updated entity.
183
+ */
184
+ async update(id: string, input: Partial<TEntity>, tx?: DrizzleTx): Promise<TEntity> {
185
+ const data = this.withTimestamps(input as Record<string, unknown>, 'update');
186
+ const rows = await this.runner(tx)
187
+ .update(this.table)
188
+ .set(data as any) // eslint-disable-line @typescript-eslint/no-explicit-any
189
+ .where(eq(this.table['id'], id))
190
+ .returning();
191
+ return rows[0] as TEntity;
192
+ }
193
+
194
+ /**
195
+ * Delete an entity by id.
196
+ * - softDelete=true: sets deletedAt to current timestamp
197
+ * - softDelete=false: hard-deletes the row
198
+ */
199
+ async delete(id: string, tx?: DrizzleTx): Promise<void> {
200
+ const runner = this.runner(tx);
201
+ if (this.behaviors.softDelete) {
202
+ await runner
203
+ .update(this.table)
204
+ .set({ deletedAt: new Date() } as any) // eslint-disable-line @typescript-eslint/no-explicit-any
205
+ .where(eq(this.table['id'], id));
206
+ } else {
207
+ await runner
208
+ .delete(this.table)
209
+ .where(eq(this.table['id'], id));
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Insert or update multiple entities.
215
+ * Default naive implementation — family repositories override with
216
+ * proper conflict-target upsert (e.g., CrmEntityRepository).
217
+ */
218
+ async upsertMany(inputs: Array<Partial<TEntity>>, tx?: DrizzleTx): Promise<TEntity[]> {
219
+ return Promise.all(inputs.map((input) => this.create(input, tx)));
220
+ }
221
+
222
+ // ============================================================================
223
+ // Protected Helpers
224
+ // ============================================================================
225
+
226
+ /**
227
+ * Base SELECT query that automatically excludes soft-deleted rows
228
+ * when softDelete behavior is enabled.
229
+ */
230
+ protected baseQuery() {
231
+ const query = this.db.select().from(this.table).$dynamic();
232
+ if (this.behaviors.softDelete) {
233
+ return query.where(isNull(this.table['deletedAt']));
234
+ }
235
+ return query;
236
+ }
237
+
238
+ /**
239
+ * Merge timestamp fields into an input object.
240
+ * - mode='create': adds createdAt and updatedAt
241
+ * - mode='update': adds updatedAt only
242
+ *
243
+ * No-op when timestamps behavior is disabled.
244
+ */
245
+ protected withTimestamps(
246
+ input: Record<string, unknown>,
247
+ mode: 'create' | 'update',
248
+ ): Record<string, unknown> {
249
+ if (!this.behaviors.timestamps) return input;
250
+ const now = new Date();
251
+ if (mode === 'create') {
252
+ return { ...input, createdAt: now, updatedAt: now };
253
+ }
254
+ return { ...input, updatedAt: now };
255
+ }
256
+
257
+ /**
258
+ * Build a WHERE clause fragment that restricts results to rows whose
259
+ * parent (identified by a belongs_to FK) is not soft-deleted.
260
+ *
261
+ * Use this in custom repository methods when you need "rows reachable
262
+ * from an active parent". The default findAll / findById behavior is
263
+ * NOT changed by this helper — opt in explicitly where needed.
264
+ *
265
+ * ADR-021 — Soft-delete cascade: Option A (filter at query time).
266
+ * `on_delete` FK rules do not fire for soft-deletes; use this helper
267
+ * instead of expecting cascade semantics on the DB level.
268
+ *
269
+ * Example:
270
+ * async listActiveMessages(): Promise<Message[]> {
271
+ * return this.list({
272
+ * where: this.activeParentFilter(conversations, this.table['conversationId']),
273
+ * });
274
+ * }
275
+ *
276
+ * @param parentTable The Drizzle table object for the parent entity.
277
+ * @param parentFkColumn The FK column on this (child) table that references parent.id.
278
+ */
279
+ protected activeParentFilter(
280
+ parentTable: PgTableWithColumns<any>, // eslint-disable-line @typescript-eslint/no-explicit-any
281
+ parentFkColumn: PgColumn,
282
+ ): SQL {
283
+ return sql`EXISTS (
284
+ SELECT 1 FROM ${parentTable} p
285
+ WHERE p.id = ${parentFkColumn}
286
+ AND p.deleted_at IS NULL
287
+ )`;
288
+ }
289
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * BaseService<TRepo, TEntity>
3
+ *
4
+ * Abstract base class providing 8 CRUD pass-through methods delegating to
5
+ * an injected repository. Every generated service extends this class.
6
+ *
7
+ * Lifecycle event emission (LIFECYCLE + CHANGE categories) is built into
8
+ * create/update/delete — matching pattern-stack's BaseService. Events are
9
+ * fire-and-forget: emission never fails the CRUD operation. If no IEventBus
10
+ * is injected (eventBus is undefined), emission is silently skipped.
11
+ *
12
+ * Generated services set `entityName` and optionally inject `eventBus` via
13
+ * NestJS property injection (@Inject(EVENT_BUS) @Optional()).
14
+ *
15
+ * Note: @Injectable() is applied on concrete services (not here) so that
16
+ * NestJS DI metadata is emitted at the concrete class level. This matches
17
+ * the pattern established by BaseRepository.
18
+ */
19
+
20
+ import type { IEventBus } from '../subsystems/events/event-bus.protocol';
21
+ import {
22
+ entitySnapshot,
23
+ diffSnapshots,
24
+ buildLifecycleEvent,
25
+ buildChangeEvents,
26
+ emitSafely,
27
+ } from './lifecycle-events';
28
+
29
+ // ============================================================================
30
+ // IBaseRepository interface
31
+ // ============================================================================
32
+
33
+ /**
34
+ * Structural interface that BaseRepository satisfies.
35
+ * Use this as the TRepo constraint so BaseService is not coupled to the
36
+ * concrete Drizzle-backed BaseRepository.
37
+ */
38
+ export interface IBaseRepository<TEntity> {
39
+ findById(id: string): Promise<TEntity | null>;
40
+ findByIds(ids: string[]): Promise<TEntity[]>;
41
+ list(options?: unknown): Promise<TEntity[]>;
42
+ count(where?: unknown): Promise<number>;
43
+ exists(id: string): Promise<boolean>;
44
+ create(input: Partial<TEntity>, tx?: unknown): Promise<TEntity>;
45
+ update(id: string, input: Partial<TEntity>, tx?: unknown): Promise<TEntity>;
46
+ delete(id: string, tx?: unknown): Promise<void>;
47
+ }
48
+
49
+ // ============================================================================
50
+ // BaseService
51
+ // ============================================================================
52
+
53
+ export abstract class BaseService<TRepo extends IBaseRepository<TEntity>, TEntity> {
54
+ /**
55
+ * Entity name for event types (e.g., 'account' → 'account.created').
56
+ * Set by generated services. If empty, lifecycle events are skipped.
57
+ */
58
+ protected entityName?: string;
59
+
60
+ /**
61
+ * Event bus for lifecycle/change event emission.
62
+ * Injected via @Inject(EVENT_BUS) @Optional() on generated services.
63
+ * If undefined (no events subsystem installed), emission is silently skipped.
64
+ */
65
+ protected eventBus?: IEventBus;
66
+
67
+ /**
68
+ * Whether to emit lifecycle events. Default: true.
69
+ * Override to false in entity YAML via behaviors or in the service class.
70
+ */
71
+ protected emitLifecycleEvents = true;
72
+
73
+ constructor(protected readonly repository: TRepo) {}
74
+
75
+ /**
76
+ * Find a single entity by its primary key.
77
+ * Returns null if not found.
78
+ */
79
+ findById(id: string): Promise<TEntity | null> {
80
+ return this.repository.findById(id);
81
+ }
82
+
83
+ /**
84
+ * Find multiple entities by their primary keys.
85
+ */
86
+ findByIds(ids: string[]): Promise<TEntity[]> {
87
+ return this.repository.findByIds(ids);
88
+ }
89
+
90
+ /**
91
+ * List entities with optional filtering/pagination options.
92
+ */
93
+ list(options?: unknown): Promise<TEntity[]> {
94
+ return this.repository.list(options);
95
+ }
96
+
97
+ /**
98
+ * Count entities matching an optional filter.
99
+ */
100
+ count(where?: unknown): Promise<number> {
101
+ return this.repository.count(where);
102
+ }
103
+
104
+ /**
105
+ * Check whether an entity with the given id exists.
106
+ */
107
+ exists(id: string): Promise<boolean> {
108
+ return this.repository.exists(id);
109
+ }
110
+
111
+ /**
112
+ * Insert a new entity.
113
+ * Emits a LIFECYCLE 'created' event with entity snapshot.
114
+ */
115
+ async create(input: Partial<TEntity>, tx?: unknown): Promise<TEntity> {
116
+ const result = await this.repository.create(input, tx);
117
+
118
+ if (this._shouldEmit()) {
119
+ const snap = entitySnapshot(result as Record<string, unknown>);
120
+ const id = (result as Record<string, unknown>).id as string;
121
+ const event = buildLifecycleEvent(this.entityName!, 'created', id, snap);
122
+ void emitSafely(this.eventBus, [event]);
123
+ }
124
+
125
+ return result;
126
+ }
127
+
128
+ /**
129
+ * Update an existing entity by id.
130
+ * Emits a LIFECYCLE 'updated' event + CHANGE events for each modified field.
131
+ */
132
+ async update(id: string, input: Partial<TEntity>, tx?: unknown): Promise<TEntity> {
133
+ // Snapshot before for change diffing
134
+ let before: Record<string, unknown> | undefined;
135
+ if (this._shouldEmit()) {
136
+ const existing = await this.repository.findById(id);
137
+ if (existing) {
138
+ before = entitySnapshot(existing as Record<string, unknown>);
139
+ }
140
+ }
141
+
142
+ const result = await this.repository.update(id, input, tx);
143
+
144
+ if (this._shouldEmit()) {
145
+ const after = entitySnapshot(result as Record<string, unknown>);
146
+ const events = [
147
+ buildLifecycleEvent(this.entityName!, 'updated', id, after),
148
+ ];
149
+ // Append per-field CHANGE events
150
+ if (before) {
151
+ const changes = diffSnapshots(before, after);
152
+ if (changes.length > 0) {
153
+ events.push(...buildChangeEvents(this.entityName!, id, changes));
154
+ }
155
+ }
156
+ void emitSafely(this.eventBus, events);
157
+ }
158
+
159
+ return result;
160
+ }
161
+
162
+ /**
163
+ * Delete an entity by id.
164
+ * Emits a LIFECYCLE 'deleted' event.
165
+ */
166
+ async delete(id: string, tx?: unknown): Promise<void> {
167
+ await this.repository.delete(id, tx);
168
+
169
+ if (this._shouldEmit()) {
170
+ const event = buildLifecycleEvent(this.entityName!, 'deleted', id);
171
+ void emitSafely(this.eventBus, [event]);
172
+ }
173
+ }
174
+
175
+ /** Check whether lifecycle event emission is active. */
176
+ private _shouldEmit(): boolean {
177
+ return Boolean(
178
+ this.emitLifecycleEvents &&
179
+ this.entityName &&
180
+ this.eventBus,
181
+ );
182
+ }
183
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Base classes barrel export
3
+ */
4
+ export { BaseRepository } from './base-repository';
5
+ export type { BehaviorConfig, ListOptions } from './base-repository';
6
+
7
+ export { BaseService } from './base-service';
8
+ export type { IBaseRepository } from './base-service';
9
+
10
+ export {
11
+ entitySnapshot,
12
+ diffSnapshots,
13
+ buildLifecycleEvent,
14
+ buildChangeEvents,
15
+ emitSafely,
16
+ } from './lifecycle-events';
17
+ export type { EventCategory } from './lifecycle-events';
18
+
19
+ export { BaseFindByIdUseCase, BaseListUseCase } from './base-read-use-cases';
20
+ export type { IFindByIdService, IListService } from './base-read-use-cases';
21
+
22
+ // Family-specific repository base classes
23
+ export { SyncedEntityRepository } from './synced-entity-repository';
24
+ export { ActivityEntityRepository } from './activity-entity-repository';
25
+ export { MetadataEntityRepository } from './metadata-entity-repository';
26
+ export { KnowledgeEntityRepository } from './knowledge-entity-repository';
27
+
28
+ // Family-specific service base classes
29
+ export { SyncedEntityService } from './synced-entity-service';
30
+ export type { ISyncedEntityRepository } from './synced-entity-service';
31
+ export { ActivityEntityService } from './activity-entity-service';
32
+ export type { IActivityEntityRepository } from './activity-entity-service';
33
+ export { MetadataEntityService } from './metadata-entity-service';
34
+ export type { IMetadataEntityRepository } from './metadata-entity-service';
35
+ export { KnowledgeEntityService } from './knowledge-entity-service';
36
+
37
+ // Mixins
38
+ export { WithAnalytics } from './with-analytics';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * KnowledgeEntityRepository<TEntity>
3
+ *
4
+ * Stub for the knowledge family (requires pgvector — parked for now).
5
+ * Concrete repos extend this when pgvector is available.
6
+ */
7
+ import { BaseRepository } from './base-repository';
8
+
9
+ export abstract class KnowledgeEntityRepository<TEntity> extends BaseRepository<TEntity> {
10
+ // pgvector-dependent methods will be added when the extension is available:
11
+ // semanticSearch, findPendingByOpportunityId, updateStatus, updateStatusBatch
12
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * KnowledgeEntityService<TRepo, TEntity>
3
+ *
4
+ * Stub for the knowledge family (requires pgvector — parked for now).
5
+ */
6
+ import { BaseService, type IBaseRepository } from './base-service';
7
+
8
+ export abstract class KnowledgeEntityService<
9
+ TRepo extends IBaseRepository<TEntity>,
10
+ TEntity,
11
+ > extends BaseService<TRepo, TEntity> {
12
+ // pgvector-dependent methods will be added when the extension is available:
13
+ // semanticSearch, findPendingByOpportunityId, updateStatus, updateStatusBatch
14
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Lifecycle event emission for BaseService.
3
+ *
4
+ * Ported from pattern-stack/atoms/patterns/services/base.py — the Python
5
+ * BaseService emits LIFECYCLE and CHANGE events on every CRUD operation.
6
+ * This module provides the same capability for the TypeScript codegen stack.
7
+ *
8
+ * Design:
9
+ * - Fire-and-forget: event emission never fails the CRUD operation.
10
+ * - IEventBus is optional: if no EVENT_BUS is injected, emission is silently
11
+ * skipped. This means base classes work in projects that haven't installed
12
+ * the events subsystem.
13
+ * - LIFECYCLE events carry an entity snapshot in payload.
14
+ * - CHANGE events carry per-field old/new diffs.
15
+ * - Controlled per-entity via `emitLifecycleEvents` flag (default: true).
16
+ *
17
+ * @deprecated EVT-7 — Lifecycle events are untyped and emit outside of the
18
+ * CRUD transaction. New work should declare an `emits:` block on the entity
19
+ * and publish typed domain events from use-cases via TYPED_EVENT_BUS inside
20
+ * the same Drizzle transaction. See `docs/specs/EVT-7.md`. This helper is
21
+ * retained for BaseService backward compatibility until all entities have
22
+ * migrated to typed emits.
23
+ */
24
+
25
+ import { randomUUID } from 'crypto';
26
+ import type { IEventBus, DomainEvent } from '../subsystems/events/event-bus.protocol';
27
+
28
+ // ============================================================================
29
+ // Event categories (subset of pattern-stack's EventCategory)
30
+ // ============================================================================
31
+
32
+ export type EventCategory = 'lifecycle' | 'change';
33
+
34
+ // ============================================================================
35
+ // Helpers
36
+ // ============================================================================
37
+
38
+ /** System fields excluded from entity snapshots and change diffs. */
39
+ const SYSTEM_FIELDS = new Set([
40
+ 'id',
41
+ 'createdAt',
42
+ 'updatedAt',
43
+ 'deletedAt',
44
+ ]);
45
+
46
+ /**
47
+ * Snapshot an entity's field values, excluding system fields.
48
+ * Mirrors pattern-stack's `_get_entity_snapshot()`.
49
+ */
50
+ export function entitySnapshot(entity: Record<string, unknown>): Record<string, unknown> {
51
+ const snap: Record<string, unknown> = {};
52
+ for (const [key, value] of Object.entries(entity)) {
53
+ if (!SYSTEM_FIELDS.has(key)) {
54
+ snap[key] = value;
55
+ }
56
+ }
57
+ return snap;
58
+ }
59
+
60
+ /**
61
+ * Diff two entity snapshots, returning per-field old/new pairs.
62
+ * Only includes fields that actually changed.
63
+ */
64
+ export function diffSnapshots(
65
+ before: Record<string, unknown>,
66
+ after: Record<string, unknown>,
67
+ ): Array<{ field: string; oldValue: unknown; newValue: unknown }> {
68
+ const changes: Array<{ field: string; oldValue: unknown; newValue: unknown }> = [];
69
+ const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
70
+
71
+ for (const key of allKeys) {
72
+ if (SYSTEM_FIELDS.has(key)) continue;
73
+ const oldVal = before[key];
74
+ const newVal = after[key];
75
+ // Simple equality — good enough for primitives and nulls.
76
+ // For deep objects, JSON.stringify comparison.
77
+ if (oldVal !== newVal && JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
78
+ changes.push({ field: key, oldValue: oldVal, newValue: newVal });
79
+ }
80
+ }
81
+
82
+ return changes;
83
+ }
84
+
85
+ // ============================================================================
86
+ // Event builders
87
+ // ============================================================================
88
+
89
+ export function buildLifecycleEvent(
90
+ entityName: string,
91
+ action: 'created' | 'updated' | 'deleted',
92
+ entityId: string,
93
+ snapshot?: Record<string, unknown>,
94
+ ): DomainEvent {
95
+ return {
96
+ id: randomUUID(),
97
+ type: `${entityName}.${action}`,
98
+ aggregateId: entityId,
99
+ aggregateType: entityName,
100
+ payload: snapshot ? { snapshot } : {},
101
+ occurredAt: new Date(),
102
+ metadata: { category: 'lifecycle' as EventCategory },
103
+ };
104
+ }
105
+
106
+ export function buildChangeEvents(
107
+ entityName: string,
108
+ entityId: string,
109
+ changes: Array<{ field: string; oldValue: unknown; newValue: unknown }>,
110
+ ): DomainEvent[] {
111
+ return changes.map((c) => ({
112
+ id: randomUUID(),
113
+ type: `${entityName}.field_changed`,
114
+ aggregateId: entityId,
115
+ aggregateType: entityName,
116
+ payload: {
117
+ fieldName: c.field,
118
+ oldValue: c.oldValue,
119
+ newValue: c.newValue,
120
+ },
121
+ occurredAt: new Date(),
122
+ metadata: { category: 'change' as EventCategory },
123
+ }));
124
+ }
125
+
126
+ // ============================================================================
127
+ // Emission helper (fire-and-forget)
128
+ // ============================================================================
129
+
130
+ /**
131
+ * Emit events to the bus, swallowing errors.
132
+ * Mirrors pattern-stack's `_emit_lifecycle_event()` try/except.
133
+ */
134
+ export async function emitSafely(
135
+ eventBus: IEventBus | undefined,
136
+ events: DomainEvent[],
137
+ ): Promise<void> {
138
+ if (!eventBus || events.length === 0) return;
139
+ try {
140
+ if (events.length === 1) {
141
+ const only = events[0];
142
+ if (!only) return;
143
+ await eventBus.publish(only);
144
+ } else {
145
+ await eventBus.publishMany(events);
146
+ }
147
+ } catch {
148
+ // Log but never fail the CRUD operation.
149
+ // In production, this would use a structured logger.
150
+ console.warn(`[lifecycle-events] failed to emit ${events.length} event(s)`);
151
+ }
152
+ }