@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,80 @@
1
+ /**
2
+ * MetadataEntityRepository<TEntity>
3
+ *
4
+ * Family-specific base for metadata entities (field values, field history, tags).
5
+ * Adds entity-scoped lookups, type filtering, history ordering, and bulk upsert.
6
+ *
7
+ * Concrete repos extend this and declare their table + behaviors.
8
+ */
9
+ import { eq, and, desc } from 'drizzle-orm';
10
+ import type { PgTableWithColumns } from 'drizzle-orm/pg-core';
11
+ import { BaseRepository } from './base-repository';
12
+ import type { DrizzleTx } from '../types/drizzle';
13
+
14
+ export abstract class MetadataEntityRepository<TEntity> extends BaseRepository<TEntity> {
15
+ /**
16
+ * Bulk upsert with a caller-specified conflict target.
17
+ * Uses Drizzle's onConflictDoUpdate to merge records.
18
+ */
19
+ override async upsertMany(
20
+ inputs: Array<Partial<TEntity>>,
21
+ tx?: DrizzleTx,
22
+ options?: { conflictTarget?: keyof PgTableWithColumns<any>['_']['columns'] }, // eslint-disable-line @typescript-eslint/no-explicit-any
23
+ ): Promise<TEntity[]> {
24
+ if (inputs.length === 0) return [];
25
+ const conflictTarget = options?.conflictTarget;
26
+
27
+ // Fall back to base class naive upsert when no conflict target provided.
28
+ if (!conflictTarget) {
29
+ return super.upsertMany(inputs, tx);
30
+ }
31
+
32
+ const data = inputs.map((input) =>
33
+ this.withTimestamps(input as Record<string, unknown>, 'create'),
34
+ );
35
+
36
+ const rows = await this.runner(tx)
37
+ .insert(this.table)
38
+ .values(data as any) // eslint-disable-line @typescript-eslint/no-explicit-any
39
+ .onConflictDoUpdate({
40
+ target: this.table[conflictTarget as string],
41
+ set: data[0] as any, // eslint-disable-line @typescript-eslint/no-explicit-any
42
+ })
43
+ .returning();
44
+
45
+ return rows as TEntity[];
46
+ }
47
+
48
+ /**
49
+ * Find metadata by entity ID and entity type (compound lookup).
50
+ */
51
+ async findByEntityIdAndType(entityId: string, entityType: string): Promise<TEntity[]> {
52
+ const rows = await this.baseQuery()
53
+ .where(
54
+ and(
55
+ eq(this.table['entityId'], entityId),
56
+ eq(this.table['entityType'], entityType),
57
+ ),
58
+ );
59
+ return rows as TEntity[];
60
+ }
61
+
62
+ /**
63
+ * List all metadata records for an entity.
64
+ */
65
+ async listByEntityId(entityId: string): Promise<TEntity[]> {
66
+ const rows = await this.baseQuery()
67
+ .where(eq(this.table['entityId'], entityId));
68
+ return rows as TEntity[];
69
+ }
70
+
71
+ /**
72
+ * List metadata history for an entity, ordered by validFrom descending.
73
+ */
74
+ async listHistoryByEntityId(entityId: string): Promise<TEntity[]> {
75
+ const rows = await this.baseQuery()
76
+ .where(eq(this.table['entityId'], entityId))
77
+ .orderBy(desc(this.table['validFrom']));
78
+ return rows as TEntity[];
79
+ }
80
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * MetadataEntityService<TRepo, TEntity>
3
+ *
4
+ * Family-specific base service for metadata entities.
5
+ * Delegates to a metadata repository that provides entity-scoped
6
+ * lookups, history, and bulk upsert.
7
+ */
8
+ import { BaseService, type IBaseRepository } from './base-service';
9
+
10
+ export interface IMetadataEntityRepository<TEntity> extends IBaseRepository<TEntity> {
11
+ findByEntityIdAndType(entityId: string, entityType: string): Promise<TEntity[]>;
12
+ listByEntityId(entityId: string): Promise<TEntity[]>;
13
+ listHistoryByEntityId(entityId: string): Promise<TEntity[]>;
14
+ upsertMany(inputs: Array<Partial<TEntity>>, tx?: unknown, options?: { conflictTarget?: string }): Promise<TEntity[]>;
15
+ }
16
+
17
+ export abstract class MetadataEntityService<
18
+ TRepo extends IMetadataEntityRepository<TEntity>,
19
+ TEntity,
20
+ > extends BaseService<TRepo, TEntity> {
21
+ /**
22
+ * Find metadata records by entity ID and entity type (EAV polymorphic lookup).
23
+ */
24
+ findByEntityIdAndType(entityId: string, entityType: string): Promise<TEntity[]> {
25
+ return this.repository.findByEntityIdAndType(entityId, entityType);
26
+ }
27
+
28
+ /**
29
+ * List all metadata records for an entity.
30
+ */
31
+ listByEntity(entityId: string): Promise<TEntity[]> {
32
+ return this.repository.listByEntityId(entityId);
33
+ }
34
+
35
+ /**
36
+ * List metadata history for an entity, ordered by validFrom desc.
37
+ */
38
+ listHistory(entityId: string): Promise<TEntity[]> {
39
+ return this.repository.listHistoryByEntityId(entityId);
40
+ }
41
+
42
+ /**
43
+ * Bulk upsert metadata values.
44
+ */
45
+ upsertValues(inputs: Array<Partial<TEntity>>, conflictTarget: string, tx?: unknown): Promise<TEntity[]> {
46
+ return this.repository.upsertMany(inputs, tx, { conflictTarget });
47
+ }
48
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * SyncedEntityRepository<TEntity>
3
+ *
4
+ * Family-specific base for Synced entities (contacts, accounts, opportunities).
5
+ * Adds external ID lookups, user-scoped queries, and sync stubs.
6
+ *
7
+ * Concrete repos extend this and declare their table + behaviors.
8
+ */
9
+ import { eq, inArray } from 'drizzle-orm';
10
+ import { BaseRepository } from './base-repository';
11
+
12
+ export abstract class SyncedEntityRepository<TEntity> extends BaseRepository<TEntity> {
13
+ /**
14
+ * Find a single entity by its external CRM identifier.
15
+ */
16
+ async findByExternalId(externalId: string): Promise<TEntity | null> {
17
+ const rows = await this.baseQuery()
18
+ .where(eq(this.table['externalId'], externalId))
19
+ .limit(1);
20
+ return (rows[0] as TEntity) ?? null;
21
+ }
22
+
23
+ /**
24
+ * Find multiple entities by external CRM identifiers.
25
+ */
26
+ async findManyByExternalIds(externalIds: string[]): Promise<TEntity[]> {
27
+ if (externalIds.length === 0) return [];
28
+ const rows = await this.baseQuery()
29
+ .where(inArray(this.table['externalId'], externalIds));
30
+ return rows as TEntity[];
31
+ }
32
+
33
+ /**
34
+ * Find all entities owned by a specific user.
35
+ */
36
+ async findAllByUserId(userId: string): Promise<TEntity[]> {
37
+ const rows = await this.baseQuery()
38
+ .where(eq(this.table['userId'], userId));
39
+ return rows as TEntity[];
40
+ }
41
+
42
+ /**
43
+ * Sync upsert — bulk insert-or-update from external CRM data.
44
+ * Concrete repositories must implement with the appropriate conflict target.
45
+ */
46
+ async syncUpsert(_inputs: Array<Partial<TEntity>>): Promise<TEntity[]> {
47
+ throw new Error('syncUpsert not implemented — override in concrete repository');
48
+ }
49
+
50
+ /**
51
+ * Find entities visible to a user (ownership + sharing rules).
52
+ * Concrete repositories must implement with visibility logic.
53
+ */
54
+ async findVisibleByUserId(_userId: string): Promise<TEntity[]> {
55
+ throw new Error('findVisibleByUserId not implemented — override in concrete repository');
56
+ }
57
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * SyncedEntityService<TRepo, TEntity>
3
+ *
4
+ * Family-specific base service for Synced entities.
5
+ * Delegates to a CRM repository that provides external ID lookups
6
+ * and user-scoped queries.
7
+ */
8
+ import { BaseService, type IBaseRepository } from './base-service';
9
+
10
+ export interface ISyncedEntityRepository<TEntity> extends IBaseRepository<TEntity> {
11
+ findByExternalId(externalId: string): Promise<TEntity | null>;
12
+ findManyByExternalIds(externalIds: string[]): Promise<TEntity[]>;
13
+ findAllByUserId(userId: string): Promise<TEntity[]>;
14
+ findVisibleByUserId(userId: string): Promise<TEntity[]>;
15
+ syncUpsert(inputs: Array<Partial<TEntity>>): Promise<TEntity[]>;
16
+ }
17
+
18
+ export abstract class SyncedEntityService<
19
+ TRepo extends ISyncedEntityRepository<TEntity>,
20
+ TEntity,
21
+ > extends BaseService<TRepo, TEntity> {
22
+ /**
23
+ * Find a single entity by its external CRM identifier.
24
+ */
25
+ findByExternalId(externalId: string): Promise<TEntity | null> {
26
+ return this.repository.findByExternalId(externalId);
27
+ }
28
+
29
+ /**
30
+ * Find multiple entities by external CRM identifiers.
31
+ */
32
+ findManyByExternalIds(externalIds: string[]): Promise<TEntity[]> {
33
+ return this.repository.findManyByExternalIds(externalIds);
34
+ }
35
+
36
+ /**
37
+ * Find all entities owned by a specific user.
38
+ */
39
+ findAllByUser(userId: string): Promise<TEntity[]> {
40
+ return this.repository.findAllByUserId(userId);
41
+ }
42
+
43
+ /**
44
+ * Find entities visible to a user (ownership + sharing rules).
45
+ * Concrete services may override with domain-specific visibility logic.
46
+ */
47
+ findVisibleByUser(userId: string): Promise<TEntity[]> {
48
+ return this.repository.findVisibleByUserId(userId);
49
+ }
50
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * WithAnalytics mixin
3
+ *
4
+ * Adds an optional `.analytics` property to the service class.
5
+ * The analytics provider is a per-entity @Injectable (e.g., AccountAnalytics)
6
+ * injected via @Optional() in the generated service constructor.
7
+ *
8
+ * Usage: class MyService extends WithAnalytics(BaseService<...>) { ... }
9
+ *
10
+ * The generated service adds:
11
+ * @Optional() @Inject(AccountAnalytics) override analytics?: AccountAnalytics
12
+ */
13
+
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ type Constructor<T = {}> = abstract new (...args: any[]) => T;
16
+
17
+ export function WithAnalytics<TBase extends Constructor>(Base: TBase) {
18
+ abstract class WithAnalyticsMixin extends Base {
19
+ analytics?: any;
20
+ }
21
+ return WithAnalyticsMixin as TBase & typeof WithAnalyticsMixin;
22
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * NestJS injection tokens
3
+ *
4
+ * Used with @Inject() decorator in concrete repository constructors.
5
+ */
6
+
7
+ /**
8
+ * Injection token for the Drizzle ORM database client.
9
+ *
10
+ * Usage in concrete repositories:
11
+ * ```typescript
12
+ * constructor(@Inject(DRIZZLE) db: DrizzleClient) { super(db); }
13
+ * ```
14
+ */
15
+ export const DRIZZLE = 'DRIZZLE' as const;
16
+
17
+ /**
18
+ * Injection token for the event bus (IEventBus).
19
+ *
20
+ * Optional — only resolved when EventsModule.forRoot() is registered.
21
+ * BaseService uses this with @Optional() to emit lifecycle events
22
+ * without requiring the events subsystem to be installed.
23
+ *
24
+ * Usage in services/use cases:
25
+ * ```typescript
26
+ * @Optional() @Inject(EVENT_BUS) eventBus?: IEventBus
27
+ * ```
28
+ */
29
+ export const EVENT_BUS = 'EVENT_BUS' as const;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * EAV helpers
3
+ *
4
+ * Small, pure utilities used by services that dual-write to the EAV value
5
+ * table alongside their own table.
6
+ *
7
+ * - `toEavRows` builds value-table insert rows from a flat `{ key: value }`
8
+ * bag, resolving the `fieldDefinitionId` via a caller-supplied map.
9
+ * Callers own the field-definitions lookup / cache.
10
+ * - `mergeEavRows` inverts: given value-table rows + a definition `id -> key`
11
+ * map, collapses them into a flat `{ key: value }` bag.
12
+ *
13
+ * Both are sync + allocation-light. They do not touch the DB.
14
+ */
15
+
16
+ /**
17
+ * Map of field key -> field_definitions.id, scoped to a single entityType.
18
+ */
19
+ export type FieldDefinitionIdMap = ReadonlyMap<string, string>;
20
+
21
+ /**
22
+ * Minimal shape of a value-table row accepted by toEavRows output. Consumers
23
+ * pass their entity's `Insert` type; these are the columns the helper sets.
24
+ */
25
+ export interface EavInsertShape {
26
+ entityId: string;
27
+ entityType: string;
28
+ userId: string;
29
+ fieldDefinitionId: string;
30
+ value: unknown;
31
+ }
32
+
33
+ /**
34
+ * Build value-table insert rows from a flat field bag. Keys present in
35
+ * `fields` but missing from `fieldDefIds` are skipped — caller is expected
36
+ * to ensure the definitions exist (first-cut; auto-create is a later step).
37
+ */
38
+ export function toEavRows(
39
+ entityId: string,
40
+ entityType: string,
41
+ userId: string,
42
+ fields: Record<string, unknown>,
43
+ fieldDefIds: FieldDefinitionIdMap,
44
+ ): EavInsertShape[] {
45
+ const rows: EavInsertShape[] = [];
46
+ for (const [key, value] of Object.entries(fields)) {
47
+ const fieldDefinitionId = fieldDefIds.get(key);
48
+ if (!fieldDefinitionId) continue;
49
+ rows.push({ entityId, entityType, userId, fieldDefinitionId, value });
50
+ }
51
+ return rows;
52
+ }
53
+
54
+ /**
55
+ * Collapse EAV rows back into a flat `{ key: value }` bag.
56
+ *
57
+ * Accepts bare value-table rows plus a separate `id -> { key }` map. Later
58
+ * rows win if the same key appears more than once. Call with temporal
59
+ * filtering already applied (e.g. validTo IS NULL) — this function does not
60
+ * interpret validFrom / validTo.
61
+ */
62
+ export function mergeEavRows(
63
+ rows: Array<{ fieldDefinitionId: string | null; value: unknown }>,
64
+ defsById: ReadonlyMap<string, { key: string }>,
65
+ ): Record<string, unknown> {
66
+ const out: Record<string, unknown> = {};
67
+ for (const row of rows) {
68
+ if (!row.fieldDefinitionId) continue;
69
+ const def = defsById.get(row.fieldDefinitionId);
70
+ if (!def) continue;
71
+ out[def.key] = row.value;
72
+ }
73
+ return out;
74
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * ZodValidationPipe
3
+ *
4
+ * Validates an incoming request body (or any decorated value) against a
5
+ * Zod schema at the controller boundary. Intended for use with Nest's
6
+ * `@Body(new ZodValidationPipe(MySchema))` pattern so generated
7
+ * controllers get runtime validation without relying on a global pipe
8
+ * that every consumer would have to opt into.
9
+ *
10
+ * Why a pipe (vs. validating inside the use case): validation is a
11
+ * presentation-layer concern (ADR-003). The use case should receive a
12
+ * type-safe, already-validated DTO; surfacing a `ZodError` at the pipe
13
+ * stage produces the standard 400 BadRequest response shape that HTTP
14
+ * clients expect.
15
+ *
16
+ * On success: returns parsed, coerced data.
17
+ * On failure: throws BadRequestException with a structured `issues` array
18
+ * (path / code / message) — richer than `.flatten()` for API consumers.
19
+ *
20
+ * One pipe instance per route (cheap — instantiated at module load). Keeps
21
+ * validation explicit in the generated code; no metadata/reflection magic.
22
+ *
23
+ * Vendored into consumer projects at `src/shared/pipes/zod-validation.pipe.ts`
24
+ * via `codegen project init` (see init-scaffold's VENDORED_RUNTIME_FILES).
25
+ */
26
+ import {
27
+ BadRequestException,
28
+ Injectable,
29
+ PipeTransform,
30
+ type ArgumentMetadata,
31
+ } from '@nestjs/common';
32
+ import type { ZodIssue, ZodSchema } from 'zod';
33
+
34
+ @Injectable()
35
+ export class ZodValidationPipe<TSchema extends ZodSchema = ZodSchema>
36
+ implements PipeTransform
37
+ {
38
+ constructor(private readonly schema: TSchema) {}
39
+
40
+ transform(value: unknown, _metadata: ArgumentMetadata): unknown {
41
+ const result = this.schema.safeParse(value);
42
+ if (result.success) {
43
+ return result.data;
44
+ }
45
+ throw new BadRequestException({
46
+ statusCode: 400,
47
+ error: 'Bad Request',
48
+ message: 'Validation failed',
49
+ issues: formatIssues(result.error.issues),
50
+ });
51
+ }
52
+ }
53
+
54
+ function formatIssues(issues: readonly ZodIssue[]): Array<{
55
+ path: string;
56
+ code: string;
57
+ message: string;
58
+ }> {
59
+ return issues.map((issue) => ({
60
+ path: issue.path.join('.'),
61
+ code: issue.code,
62
+ message: issue.message,
63
+ }));
64
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared error response schema (OPENAPI-3).
3
+ *
4
+ * Generated controllers `@ApiResponse(...)` decorators reference this
5
+ * schema by `$ref` (name `ErrorResponseDto`) for non-success status codes
6
+ * (400, 401, 404, etc.). Shape matches NestJS's default `HttpException`
7
+ * JSON body — see `packages/common/src/exceptions/http.exception.ts`.
8
+ *
9
+ * The registry auto-registers this schema on construction so every
10
+ * consumer project exposes `components.schemas.ErrorResponseDto` on
11
+ * `/docs-json` without per-entity duplication.
12
+ */
13
+ import { z } from 'zod';
14
+
15
+ export const errorResponseSchema = z.object({
16
+ statusCode: z.number().int(),
17
+ message: z.union([z.string(), z.array(z.string())]),
18
+ error: z.string().optional(),
19
+ });
20
+
21
+ export type ErrorResponseDto = z.infer<typeof errorResponseSchema>;
22
+
23
+ /** Canonical name used across `$ref` URIs in generated controllers. */
24
+ export const ERROR_RESPONSE_SCHEMA_NAME = 'ErrorResponseDto';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Typed errors for the OpenAPI registry (OPENAPI-1).
3
+ *
4
+ * Same shape as `runtime/subsystems/bridge/bridge-errors.ts` so consumers
5
+ * can catch them with the same exception-filter pattern used elsewhere.
6
+ */
7
+
8
+ /**
9
+ * Thrown by `OpenApiRegistry.build()` when `@anatine/zod-openapi` is not
10
+ * resolvable. The peer is declared optional (`peerDependenciesMeta`) so
11
+ * consumer apps that don't care about OpenAPI still boot; the cost is a
12
+ * deferred failure here on first `build()`.
13
+ */
14
+ export class OpenApiPeerDepMissingError extends Error {
15
+ override readonly name = 'OpenApiPeerDepMissingError';
16
+ constructor(message?: string) {
17
+ super(
18
+ message ??
19
+ 'OpenApiRegistry requires @anatine/zod-openapi. Install it: bun add @anatine/zod-openapi',
20
+ );
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Thrown by `OpenApiRegistry.registerSchema(name, ...)` when `name` is
26
+ * already registered. Silent overwrite would make debugging
27
+ * double-registration bugs (e.g. two entity pipelines both emitting a
28
+ * `User` DTO) painful; loud failure lets the mismatch surface at module
29
+ * init where the stack trace is clear.
30
+ */
31
+ export class DuplicateSchemaError extends Error {
32
+ override readonly name = 'DuplicateSchemaError';
33
+ constructor(public readonly schemaName: string) {
34
+ super(
35
+ `DuplicateSchemaError: schema '${schemaName}' is already registered. ` +
36
+ `Each schema name must be unique within the OpenApiRegistry.`,
37
+ );
38
+ }
39
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * OpenAPI shared subsystem — public API (OPENAPI-1).
3
+ *
4
+ * Consumed by generated DTO providers (OPENAPI-2), controller decorators
5
+ * (OPENAPI-3), and the Swagger UI bootstrap (OPENAPI-4).
6
+ */
7
+ export { OpenApiRegistry } from './registry';
8
+ export type {
9
+ HttpMethod,
10
+ PathSpec,
11
+ OpenAPIInfo,
12
+ OpenAPIObject,
13
+ } from './registry';
14
+ export { OPENAPI_REGISTRY } from './registry.tokens';
15
+ export { OpenApiPeerDepMissingError, DuplicateSchemaError } from './errors';
16
+ export {
17
+ ERROR_RESPONSE_SCHEMA_NAME,
18
+ errorResponseSchema,
19
+ } from './error-response.dto';
20
+ export type { ErrorResponseDto } from './error-response.dto';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Injection token for the OpenAPI registry (OPENAPI-1).
3
+ *
4
+ * String constant (not a Symbol) so it matches by value across import
5
+ * boundaries — same convention as `ANALYTICS_QUERY` in analytics and
6
+ * `EVENT_BUS` / `BRIDGE_DELIVERY_REPO` in events / bridge. The OPENAPI-1
7
+ * spec sketched a Symbol, but the repo-wide convention wins — codebase
8
+ * consistency matters more than the spec's initial guess.
9
+ *
10
+ * Consumed by generated DTO providers (OPENAPI-2), controllers
11
+ * (OPENAPI-3), and the Swagger bootstrap (OPENAPI-4).
12
+ */
13
+ export const OPENAPI_REGISTRY = 'OPENAPI_REGISTRY' as const;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * OpenApiRegistry — collects Zod schemas and path specs, emits a
3
+ * complete `OpenAPIObject` on `build()` (OPENAPI-1).
4
+ *
5
+ * Wraps `@anatine/zod-openapi` as an **optional peer dependency** using
6
+ * the lazy-import pattern from `runtime/subsystems/analytics/cube-backend.ts`
7
+ * — consumer apps that never call `build()` still boot even if
8
+ * `@anatine/zod-openapi` isn't installed.
9
+ *
10
+ * The registry is the single source of truth consumed by OPENAPI-2
11
+ * (generated DTOs register their Zod schemas at module init), OPENAPI-3
12
+ * (controller decorators reference those schemas), and OPENAPI-4
13
+ * (Swagger UI bootstrap calls `build()` once at startup).
14
+ */
15
+ import type { z } from 'zod';
16
+
17
+ import { ERROR_RESPONSE_SCHEMA_NAME, errorResponseSchema } from './error-response.dto';
18
+ import { OpenApiPeerDepMissingError, DuplicateSchemaError } from './errors';
19
+
20
+ export type HttpMethod = 'get' | 'post' | 'patch' | 'delete' | 'put';
21
+
22
+ /**
23
+ * OpenAPI path spec. Structurally compatible with `openapi3-ts`'s
24
+ * `OperationObject` but typed loosely here because the peer type package
25
+ * isn't installed as a direct dep — consumers supply whatever their
26
+ * codegen emits.
27
+ */
28
+ export interface PathSpec {
29
+ summary?: string;
30
+ description?: string;
31
+ operationId?: string;
32
+ tags?: string[];
33
+ parameters?: unknown[];
34
+ requestBody?: unknown;
35
+ responses?: Record<string, unknown>;
36
+ security?: unknown[];
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ export interface OpenAPIInfo {
41
+ title: string;
42
+ version: string;
43
+ description?: string;
44
+ }
45
+
46
+ /**
47
+ * Minimal OpenAPIObject shape. We redeclare rather than pull
48
+ * `openapi3-ts` types through the peer — the peer's `generateSchema`
49
+ * returns a `SchemaObject`, but the final document assembly is ours.
50
+ */
51
+ export interface OpenAPIObject {
52
+ openapi: string;
53
+ info: OpenAPIInfo;
54
+ paths: Record<string, Record<string, PathSpec>>;
55
+ components: {
56
+ schemas: Record<string, unknown>;
57
+ };
58
+ }
59
+
60
+ interface PeerModule {
61
+ generateSchema: (zodRef: unknown, useOutput?: boolean, version?: '3.0' | '3.1') => unknown;
62
+ }
63
+
64
+ export class OpenApiRegistry {
65
+ private zodSchemas = new Map<string, z.ZodType>();
66
+ private pathEntries = new Map<string, Map<HttpMethod, PathSpec>>();
67
+ private peer: PeerModule | null = null;
68
+
69
+ constructor() {
70
+ // Auto-register the shared error response schema so controllers that
71
+ // reference `#/components/schemas/ErrorResponseDto` always resolve
72
+ // (OPENAPI-3). Consumers can `reset()` + re-register in tests.
73
+ this.zodSchemas.set(ERROR_RESPONSE_SCHEMA_NAME, errorResponseSchema);
74
+ }
75
+
76
+ registerSchema(name: string, schema: z.ZodType): void {
77
+ if (this.zodSchemas.has(name)) {
78
+ throw new DuplicateSchemaError(name);
79
+ }
80
+ this.zodSchemas.set(name, schema);
81
+ }
82
+
83
+ registerPath(path: string, method: HttpMethod, spec: PathSpec): void {
84
+ let methods = this.pathEntries.get(path);
85
+ if (!methods) {
86
+ methods = new Map();
87
+ this.pathEntries.set(path, methods);
88
+ }
89
+ methods.set(method, spec);
90
+ }
91
+
92
+ /**
93
+ * Emit the full OpenAPI document. Lazy-imports `@anatine/zod-openapi`
94
+ * on first call; failure to resolve raises `OpenApiPeerDepMissingError`
95
+ * (matches the `CubeAnalyticsBackend.onModuleInit` precedent).
96
+ *
97
+ * OpenAPI version is pinned to `3.0.3` — Swagger UI tooling is most
98
+ * stable on 3.0.x (see OPENAPI-PHASE-1-PLAN §Four locked decisions).
99
+ */
100
+ async build(info: OpenAPIInfo): Promise<OpenAPIObject> {
101
+ const peer = await this.loadPeer();
102
+
103
+ const schemas: Record<string, unknown> = {};
104
+ for (const [name, zodSchema] of this.zodSchemas) {
105
+ schemas[name] = peer.generateSchema(zodSchema, false, '3.0');
106
+ }
107
+
108
+ const paths: Record<string, Record<string, PathSpec>> = {};
109
+ for (const [path, methods] of this.pathEntries) {
110
+ const methodMap: Record<string, PathSpec> = {};
111
+ for (const [method, spec] of methods) {
112
+ methodMap[method] = spec;
113
+ }
114
+ paths[path] = methodMap;
115
+ }
116
+
117
+ return {
118
+ openapi: '3.0.3',
119
+ info,
120
+ paths,
121
+ components: { schemas },
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Test helper — clears registered schemas and paths, then re-seeds the
127
+ * core `ErrorResponseDto` entry so post-reset state matches the
128
+ * invariant established in the constructor.
129
+ */
130
+ reset(): void {
131
+ this.zodSchemas.clear();
132
+ this.pathEntries.clear();
133
+ this.peer = null;
134
+ this.zodSchemas.set(ERROR_RESPONSE_SCHEMA_NAME, errorResponseSchema);
135
+ }
136
+
137
+ protected async loadPeer(): Promise<PeerModule> {
138
+ if (this.peer) return this.peer;
139
+ try {
140
+ // Computed specifier: prevents tsc from resolving this import at
141
+ // typecheck time. Consumers vendor this file but may not install
142
+ // @anatine/zod-openapi (optional peer).
143
+ const specifier: string = '@anatine/zod-openapi';
144
+ const mod = (await import(specifier)) as PeerModule;
145
+ this.peer = mod;
146
+ return mod;
147
+ } catch {
148
+ throw new OpenApiPeerDepMissingError();
149
+ }
150
+ }
151
+ }