@pattern-stack/codegen 0.4.1 → 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.
- package/package.json +2 -1
- package/runtime/analytics/index.ts +31 -0
- package/runtime/analytics/metrics.ts +85 -0
- package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
- package/runtime/analytics/packs/index.ts +5 -0
- package/runtime/analytics/packs/monetary-measures.ts +20 -0
- package/runtime/analytics/specs.ts +54 -0
- package/runtime/analytics/types.ts +105 -0
- package/runtime/base-classes/activity-entity-repository.ts +50 -0
- package/runtime/base-classes/activity-entity-service.ts +48 -0
- package/runtime/base-classes/base-read-use-cases.ts +88 -0
- package/runtime/base-classes/base-repository.ts +289 -0
- package/runtime/base-classes/base-service.ts +183 -0
- package/runtime/base-classes/index.ts +38 -0
- package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
- package/runtime/base-classes/knowledge-entity-service.ts +14 -0
- package/runtime/base-classes/lifecycle-events.ts +152 -0
- package/runtime/base-classes/metadata-entity-repository.ts +80 -0
- package/runtime/base-classes/metadata-entity-service.ts +48 -0
- package/runtime/base-classes/synced-entity-repository.ts +57 -0
- package/runtime/base-classes/synced-entity-service.ts +50 -0
- package/runtime/base-classes/with-analytics.ts +22 -0
- package/runtime/constants/tokens.ts +29 -0
- package/runtime/eav-helpers.ts +74 -0
- package/runtime/pipes/zod-validation.pipe.ts +64 -0
- package/runtime/shared/openapi/error-response.dto.ts +24 -0
- package/runtime/shared/openapi/errors.ts +39 -0
- package/runtime/shared/openapi/index.ts +20 -0
- package/runtime/shared/openapi/registry.tokens.ts +13 -0
- package/runtime/shared/openapi/registry.ts +151 -0
- package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
- package/runtime/subsystems/analytics/analytics.module.ts +64 -0
- package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
- package/runtime/subsystems/analytics/cube-backend.ts +75 -0
- package/runtime/subsystems/analytics/index.ts +15 -0
- package/runtime/subsystems/analytics/noop-backend.ts +27 -0
- package/runtime/subsystems/auth/auth.module.ts +91 -0
- package/runtime/subsystems/auth/auth.tokens.ts +27 -0
- package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
- package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
- package/runtime/subsystems/auth/index.ts +77 -0
- package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
- package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
- package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
- package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
- package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
- package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
- package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
- package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
- package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
- package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
- package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
- package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
- package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
- package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
- package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
- package/runtime/subsystems/bridge/bridge.module.ts +160 -0
- package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
- package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
- package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
- package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
- package/runtime/subsystems/bridge/generated/registry.ts +6 -0
- package/runtime/subsystems/bridge/index.ts +84 -0
- package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
- package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
- package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
- package/runtime/subsystems/cache/cache.module.ts +115 -0
- package/runtime/subsystems/cache/cache.protocol.ts +45 -0
- package/runtime/subsystems/cache/cache.schema.ts +27 -0
- package/runtime/subsystems/cache/cache.tokens.ts +17 -0
- package/runtime/subsystems/cache/index.ts +22 -0
- package/runtime/subsystems/events/domain-events.schema.ts +77 -0
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
- package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
- package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
- package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
- package/runtime/subsystems/events/events-errors.ts +30 -0
- package/runtime/subsystems/events/events.module.ts +230 -0
- package/runtime/subsystems/events/events.tokens.ts +62 -0
- package/runtime/subsystems/events/generated/bus.ts +103 -0
- package/runtime/subsystems/events/generated/index.ts +7 -0
- package/runtime/subsystems/events/generated/registry.ts +84 -0
- package/runtime/subsystems/events/generated/schemas.ts +59 -0
- package/runtime/subsystems/events/generated/types.ts +94 -0
- package/runtime/subsystems/events/index.ts +21 -0
- package/runtime/subsystems/index.ts +63 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
- package/runtime/subsystems/jobs/index.ts +120 -0
- package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
- package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
- package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
- package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +850 -0
- package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
- package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
- package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
- package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
- package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
- package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
- package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
- package/runtime/subsystems/jobs/job-worker.module.ts +302 -0
- package/runtime/subsystems/jobs/job-worker.ts +615 -0
- package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
- package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
- package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
- package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
- package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
- package/runtime/subsystems/storage/index.ts +18 -0
- package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
- package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
- package/runtime/subsystems/storage/storage.module.ts +60 -0
- package/runtime/subsystems/storage/storage.protocol.ts +78 -0
- package/runtime/subsystems/storage/storage.tokens.ts +9 -0
- package/runtime/subsystems/storage/storage.utils.ts +20 -0
- package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
- package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
- package/runtime/subsystems/sync/index.ts +98 -0
- package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
- package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
- package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
- package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
- package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
- package/runtime/subsystems/sync/sync-errors.ts +54 -0
- package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
- package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
- package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
- package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
- package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
- package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
- package/runtime/subsystems/sync/sync.module.ts +156 -0
- package/runtime/subsystems/sync/sync.tokens.ts +57 -0
- package/runtime/types/drizzle.ts +23 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CacheModule — DynamicModule factory for the cache subsystem.
|
|
3
|
+
*
|
|
4
|
+
* Usage in AppModule:
|
|
5
|
+
* ```typescript
|
|
6
|
+
* CacheModule.forRoot({ backend: 'drizzle', defaultTtl: 300 })
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* Usage in tests:
|
|
10
|
+
* ```typescript
|
|
11
|
+
* CacheModule.forRoot({ backend: 'memory' })
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* `global: true` means any module that needs ICacheService can inject CACHE
|
|
15
|
+
* directly without importing CacheModule. Register once in AppModule.
|
|
16
|
+
*
|
|
17
|
+
* The drizzle backend requires DRIZZLE to be provided globally (e.g., via DatabaseModule).
|
|
18
|
+
*
|
|
19
|
+
* Async configuration (`forRootAsync`):
|
|
20
|
+
* The async factory returns `CacheModuleOptions`; the CACHE provider then
|
|
21
|
+
* receives DRIZZLE (for the drizzle backend) through Nest DI rather than
|
|
22
|
+
* hand-constructing with `null` — see issue #108 which flagged the same
|
|
23
|
+
* shape in `EventsModule.forRootAsync`. DRIZZLE is injected as optional
|
|
24
|
+
* so memory-backend consumers are not required to wire DatabaseModule.
|
|
25
|
+
*/
|
|
26
|
+
import { Module, type DynamicModule } from '@nestjs/common';
|
|
27
|
+
import { CACHE, CACHE_DEFAULT_TTL } from './cache.tokens';
|
|
28
|
+
import { DRIZZLE } from '../../constants/tokens';
|
|
29
|
+
import type { DrizzleClient } from '../../types/drizzle';
|
|
30
|
+
import { DrizzleCacheService } from './cache.drizzle-backend';
|
|
31
|
+
import { MemoryCacheService } from './cache.memory-backend';
|
|
32
|
+
|
|
33
|
+
export interface CacheModuleOptions {
|
|
34
|
+
backend: 'drizzle' | 'memory';
|
|
35
|
+
/** Default TTL in seconds for entries that don't specify their own TTL. Null = no expiry. */
|
|
36
|
+
defaultTtl?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CacheModuleAsyncOptions {
|
|
40
|
+
useFactory: (...args: unknown[]) => Promise<CacheModuleOptions> | CacheModuleOptions;
|
|
41
|
+
inject?: unknown[];
|
|
42
|
+
imports?: unknown[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** String token for the resolved CacheModuleOptions in the async path. */
|
|
46
|
+
const CACHE_MODULE_OPTIONS = 'CACHE_MODULE_OPTIONS' as const;
|
|
47
|
+
|
|
48
|
+
function buildCacheAsync(
|
|
49
|
+
options: CacheModuleOptions,
|
|
50
|
+
db: DrizzleClient | null,
|
|
51
|
+
): DrizzleCacheService | MemoryCacheService {
|
|
52
|
+
const defaultTtl = options.defaultTtl ?? null;
|
|
53
|
+
if (options.backend === 'drizzle') {
|
|
54
|
+
if (!db) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"CacheModule.forRootAsync: backend: 'drizzle' selected but DRIZZLE provider is not available. " +
|
|
57
|
+
'Ensure DatabaseModule (or another provider exposing DRIZZLE) is imported before CacheModule.forRootAsync.',
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return new DrizzleCacheService(db, defaultTtl);
|
|
61
|
+
}
|
|
62
|
+
return new MemoryCacheService(defaultTtl);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@Module({})
|
|
66
|
+
export class CacheModule {
|
|
67
|
+
static forRootAsync(asyncOptions: CacheModuleAsyncOptions): DynamicModule {
|
|
68
|
+
return {
|
|
69
|
+
module: CacheModule,
|
|
70
|
+
global: true,
|
|
71
|
+
imports: (asyncOptions.imports ?? []) as Parameters<typeof Module>[0]['imports'],
|
|
72
|
+
providers: [
|
|
73
|
+
{
|
|
74
|
+
provide: CACHE_MODULE_OPTIONS,
|
|
75
|
+
useFactory: asyncOptions.useFactory,
|
|
76
|
+
inject: (asyncOptions.inject ?? []) as (string | symbol | Function)[],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
provide: CACHE,
|
|
80
|
+
useFactory: (options: CacheModuleOptions, db: DrizzleClient | null) =>
|
|
81
|
+
buildCacheAsync(options, db),
|
|
82
|
+
inject: [CACHE_MODULE_OPTIONS, { token: DRIZZLE, optional: true }],
|
|
83
|
+
},
|
|
84
|
+
// Alias the concrete classes to CACHE for typed injection.
|
|
85
|
+
{ provide: DrizzleCacheService, useExisting: CACHE },
|
|
86
|
+
{ provide: MemoryCacheService, useExisting: CACHE },
|
|
87
|
+
],
|
|
88
|
+
exports: [CACHE],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static forRoot(options: CacheModuleOptions = { backend: 'drizzle' }): DynamicModule {
|
|
93
|
+
const ConcreteClass = options.backend === 'drizzle' ? DrizzleCacheService : MemoryCacheService;
|
|
94
|
+
|
|
95
|
+
const providers = options.defaultTtl !== undefined
|
|
96
|
+
? [
|
|
97
|
+
// Register the concrete class as the canonical instance
|
|
98
|
+
ConcreteClass,
|
|
99
|
+
{ provide: CACHE_DEFAULT_TTL, useValue: options.defaultTtl },
|
|
100
|
+
// CACHE token points to the same instance — no duplicate
|
|
101
|
+
{ provide: CACHE, useExisting: ConcreteClass },
|
|
102
|
+
]
|
|
103
|
+
: [
|
|
104
|
+
ConcreteClass,
|
|
105
|
+
{ provide: CACHE, useExisting: ConcreteClass },
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
module: CacheModule,
|
|
110
|
+
global: true,
|
|
111
|
+
providers,
|
|
112
|
+
exports: [CACHE],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICacheService — the cache port.
|
|
3
|
+
*
|
|
4
|
+
* Per ADR-003:
|
|
5
|
+
* - Cache reads (get, has) are NOT side effects — services may call them.
|
|
6
|
+
* - Cache writes (set, delete, invalidateByPrefix) ARE side effects — use cases only.
|
|
7
|
+
*
|
|
8
|
+
* Error behavior:
|
|
9
|
+
* - get() returns null on any error (cache miss semantics; never throws for reads).
|
|
10
|
+
* - has() returns false on any error.
|
|
11
|
+
* - set(), delete(), invalidateByPrefix() throw on failure.
|
|
12
|
+
*/
|
|
13
|
+
export interface ICacheService {
|
|
14
|
+
/** Read a cached value. Returns null on miss or error. */
|
|
15
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
16
|
+
|
|
17
|
+
/** Write a value to cache with an optional TTL in seconds. */
|
|
18
|
+
set<T = unknown>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
|
19
|
+
|
|
20
|
+
/** Delete a single cache entry by key. */
|
|
21
|
+
delete(key: string): Promise<void>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Delete all entries whose key starts with the given prefix.
|
|
25
|
+
* Returns the number of entries deleted.
|
|
26
|
+
*
|
|
27
|
+
* Example: invalidateByPrefix('contact:') removes all contact cache entries.
|
|
28
|
+
*/
|
|
29
|
+
invalidateByPrefix(prefix: string): Promise<number>;
|
|
30
|
+
|
|
31
|
+
/** Check whether a non-expired entry exists for the given key. Returns false on error. */
|
|
32
|
+
has(key: string): Promise<boolean>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Return the cached value for `key`, or compute it via `factory` and store it.
|
|
36
|
+
*
|
|
37
|
+
* Stampede protection: concurrent calls for the same key that miss the cache
|
|
38
|
+
* will share the same in-flight promise — the factory is invoked only once.
|
|
39
|
+
*
|
|
40
|
+
* @param key - Cache key
|
|
41
|
+
* @param factory - Async function that computes the value on cache miss
|
|
42
|
+
* @param ttlSeconds - Optional TTL; falls back to the module-configured default
|
|
43
|
+
*/
|
|
44
|
+
getOrSet<T = unknown>(key: string, factory: () => Promise<T>, ttlSeconds?: number): Promise<T>;
|
|
45
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle schema for the cache_entries table.
|
|
3
|
+
*
|
|
4
|
+
* This table backs the DrizzleCacheService. TTL is enforced by filtering
|
|
5
|
+
* on expiresAt at read time; a periodic cleanup job removes stale rows.
|
|
6
|
+
*
|
|
7
|
+
* Indexes:
|
|
8
|
+
* - PRIMARY KEY on key (point-lookup)
|
|
9
|
+
* - (expiresAt) for the cleanup query
|
|
10
|
+
*/
|
|
11
|
+
import { pgTable, text, jsonb, timestamp } from 'drizzle-orm/pg-core';
|
|
12
|
+
import type { InferSelectModel } from 'drizzle-orm';
|
|
13
|
+
|
|
14
|
+
export const cacheEntries = pgTable(
|
|
15
|
+
'cache_entries',
|
|
16
|
+
{
|
|
17
|
+
/** Cache key — primary key, text (not uuid) to support arbitrary key namespacing. */
|
|
18
|
+
key: text('key').primaryKey(),
|
|
19
|
+
/** Cached value serialised as JSONB. */
|
|
20
|
+
value: jsonb('value').notNull(),
|
|
21
|
+
/** NULL means the entry never expires. */
|
|
22
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
|
23
|
+
},
|
|
24
|
+
// Index: add (expires_at) via migration for cleanup queries
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export type CacheEntry = InferSelectModel<typeof cacheEntries>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injection token for the cache service.
|
|
3
|
+
*
|
|
4
|
+
* Usage in use cases:
|
|
5
|
+
* ```typescript
|
|
6
|
+
* constructor(@Inject(CACHE) private readonly cache: ICacheService) {}
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* Services may also inject CACHE for reads (get, has) per ADR-003.
|
|
10
|
+
*/
|
|
11
|
+
export const CACHE = Symbol('CACHE');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Injection token for the default TTL (in seconds) passed from CacheModule.forRoot().
|
|
15
|
+
* Optional — omit for no-expiry behavior.
|
|
16
|
+
*/
|
|
17
|
+
export const CACHE_DEFAULT_TTL = Symbol('CACHE_DEFAULT_TTL');
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache subsystem public API.
|
|
3
|
+
*
|
|
4
|
+
* Import the token and protocol in use cases and services:
|
|
5
|
+
* ```typescript
|
|
6
|
+
* import { CACHE, type ICacheService } from '@shared/subsystems/cache';
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* Import the module in AppModule:
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { CacheModule } from '@shared/subsystems/cache';
|
|
12
|
+
* CacheModule.forRoot({ backend: 'drizzle', defaultTtl: 300 })
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export { CACHE } from './cache.tokens';
|
|
16
|
+
export type { ICacheService } from './cache.protocol';
|
|
17
|
+
export { CacheModule } from './cache.module';
|
|
18
|
+
export type { CacheModuleOptions } from './cache.module';
|
|
19
|
+
export { cacheEntries } from './cache.schema';
|
|
20
|
+
export type { CacheEntry } from './cache.schema';
|
|
21
|
+
export { DrizzleCacheService, CACHE_DEFAULT_TTL } from './cache.drizzle-backend';
|
|
22
|
+
export { MemoryCacheService } from './cache.memory-backend';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle schema for the domain_events outbox table.
|
|
3
|
+
*
|
|
4
|
+
* This table backs the DrizzleEventBus. Events are inserted within the
|
|
5
|
+
* same database transaction as the domain write (outbox pattern). A
|
|
6
|
+
* polling process reads unprocessed rows and dispatches to subscribers.
|
|
7
|
+
*
|
|
8
|
+
* First-class routing columns (EVT-1):
|
|
9
|
+
* - `pool` — populated by DrizzleEventBus.publish() (EVT-4); enables
|
|
10
|
+
* pool-filtered drain queries without unpacking metadata JSON.
|
|
11
|
+
* - `direction` — `inbound` | `change` | `outbound`; mirrors the routing
|
|
12
|
+
* dimension used by jobs' reserved `events_inbound` /
|
|
13
|
+
* `events_change` / `events_outbound` pools.
|
|
14
|
+
* - `tenant_id` — conditional: emitted only when `events.multi_tenant: true`
|
|
15
|
+
* in `codegen.config.yaml`. The runtime source declares it
|
|
16
|
+
* unconditionally; EVT-8's scaffold template handles the
|
|
17
|
+
* config-driven include/exclude.
|
|
18
|
+
*
|
|
19
|
+
* The `metadata` JSON column continues to carry these values for protocol
|
|
20
|
+
* stability; the first-class columns are an optimization for drain filtering.
|
|
21
|
+
*
|
|
22
|
+
* Indexes (declared below in the index callback):
|
|
23
|
+
* - (status, occurred_at) — polling drain filter
|
|
24
|
+
* - (aggregate_id, aggregate_type) — event replay per aggregate
|
|
25
|
+
* - (pool, status, occurred_at) — per-pool drain filter (EVT-1)
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
index,
|
|
29
|
+
jsonb,
|
|
30
|
+
pgTable,
|
|
31
|
+
text,
|
|
32
|
+
timestamp,
|
|
33
|
+
uuid,
|
|
34
|
+
} from 'drizzle-orm/pg-core';
|
|
35
|
+
import type { InferSelectModel } from 'drizzle-orm';
|
|
36
|
+
|
|
37
|
+
export const domainEvents = pgTable(
|
|
38
|
+
'domain_events',
|
|
39
|
+
{
|
|
40
|
+
id: uuid('id').primaryKey(),
|
|
41
|
+
type: text('type').notNull(),
|
|
42
|
+
aggregateId: text('aggregate_id').notNull(),
|
|
43
|
+
aggregateType: text('aggregate_type').notNull(),
|
|
44
|
+
payload: jsonb('payload').notNull().$type<Record<string, unknown>>(),
|
|
45
|
+
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
|
46
|
+
processedAt: timestamp('processed_at', { withTimezone: true }),
|
|
47
|
+
/** Lifecycle status: pending | processed | failed */
|
|
48
|
+
status: text('status').notNull().default('pending'),
|
|
49
|
+
/** Error message from the last failed dispatch attempt. */
|
|
50
|
+
error: text('error'),
|
|
51
|
+
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
|
|
52
|
+
/** Routing pool (e.g. `events_inbound`, `events_change`, `events_outbound`). Populated by DrizzleEventBus.publish() in EVT-4. */
|
|
53
|
+
pool: text('pool'),
|
|
54
|
+
/** Routing direction: `inbound` | `change` | `outbound`. Populated by DrizzleEventBus.publish() in EVT-4. */
|
|
55
|
+
direction: text('direction'),
|
|
56
|
+
// conditional: emitted only when events.multi_tenant: true
|
|
57
|
+
tenantId: text('tenant_id'),
|
|
58
|
+
},
|
|
59
|
+
(t) => ({
|
|
60
|
+
/** Polling drain filter (existing — promoted from comment to declaration in EVT-1). */
|
|
61
|
+
idxDomainEventsStatusOccurredAt: index('idx_domain_events_status_occurred_at').on(
|
|
62
|
+
t.status,
|
|
63
|
+
t.occurredAt,
|
|
64
|
+
),
|
|
65
|
+
/** Event replay per aggregate (existing — promoted from comment to declaration in EVT-1). */
|
|
66
|
+
idxDomainEventsAggregate: index('idx_domain_events_aggregate').on(
|
|
67
|
+
t.aggregateId,
|
|
68
|
+
t.aggregateType,
|
|
69
|
+
),
|
|
70
|
+
/** Per-pool drain filter (EVT-1). Enables DrizzleEventBus to drain a single pool without scanning all events. */
|
|
71
|
+
idxDomainEventsPoolStatusOccurredAt: index(
|
|
72
|
+
'idx_domain_events_pool_status_occurred_at',
|
|
73
|
+
).on(t.pool, t.status, t.occurredAt),
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
export type DomainEventRecord = InferSelectModel<typeof domainEvents>;
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DrizzleEventBus — Postgres-backed event bus using the transactional outbox pattern.
|
|
3
|
+
*
|
|
4
|
+
* Events are inserted into the `domain_events` table within the caller's
|
|
5
|
+
* Drizzle transaction. A background polling loop (started on module init)
|
|
6
|
+
* reads unprocessed events and dispatches them to registered subscribers.
|
|
7
|
+
*
|
|
8
|
+
* When the transaction rolls back, the event is never persisted — no
|
|
9
|
+
* phantom events.
|
|
10
|
+
*
|
|
11
|
+
* Pool awareness (EVT-4):
|
|
12
|
+
* - On `publish`/`publishMany` the backend writes `metadata.pool`,
|
|
13
|
+
* `metadata.direction`, and `metadata.tenantId` into the first-class
|
|
14
|
+
* `pool` / `direction` / `tenant_id` columns (metadata JSON is still
|
|
15
|
+
* written unchanged for protocol stability).
|
|
16
|
+
* - The drain loop filters by `opts.pools` when provided, so separate
|
|
17
|
+
* processes (e.g. one per `events_inbound` / `events_change` /
|
|
18
|
+
* `events_outbound`) can claim only their own lane. `pools: undefined`
|
|
19
|
+
* drains all pending rows (backwards-compatible behaviour).
|
|
20
|
+
*
|
|
21
|
+
* EVT-Q7: No stale-event sweeper. `FOR UPDATE SKIP LOCKED` is
|
|
22
|
+
* self-healing — the row is only locked for the duration of the
|
|
23
|
+
* enclosing polling transaction; the `status='processed'` update happens
|
|
24
|
+
* within that same transaction. There is no `claimed_at` semantic (unlike
|
|
25
|
+
* jobs), so no stale rows can exist.
|
|
26
|
+
*
|
|
27
|
+
* This backend is suitable until you need real-time fan-out or very high
|
|
28
|
+
* throughput. At that point, swap the backend for Redis Streams or similar
|
|
29
|
+
* via EventsModule.forRoot({ backend: '...' }) without touching use cases.
|
|
30
|
+
*/
|
|
31
|
+
import { Injectable, OnModuleDestroy, OnModuleInit, Inject, Logger, Optional } from '@nestjs/common';
|
|
32
|
+
import { eq, and, inArray, asc, type SQL } from 'drizzle-orm';
|
|
33
|
+
import type { DomainEvent, DrizzleTransaction, IEventBus } from './event-bus.protocol';
|
|
34
|
+
import type { DrizzleClient } from '../../types/drizzle';
|
|
35
|
+
import { domainEvents } from './domain-events.schema';
|
|
36
|
+
import { DRIZZLE } from '../../constants/tokens';
|
|
37
|
+
import { EVENTS_MODULE_OPTIONS } from './events.tokens';
|
|
38
|
+
import type { EventsModuleOptions } from './events.module';
|
|
39
|
+
import { BRIDGE_OUTBOX_DRAIN_HOOK } from '../bridge/bridge.tokens';
|
|
40
|
+
import type { IBridgeOutboxDrainHook } from '../bridge/bridge.protocol';
|
|
41
|
+
|
|
42
|
+
/** How long to wait between polling cycles (ms). */
|
|
43
|
+
const POLL_INTERVAL_MS = 1_000;
|
|
44
|
+
/** Max events claimed per polling cycle to bound memory usage. */
|
|
45
|
+
const POLL_BATCH_SIZE = 50;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Row shape built from `metadata` for writing into `domain_events`. Keeps
|
|
49
|
+
* the per-event extraction logic in one place so publish/publishMany stay
|
|
50
|
+
* in sync.
|
|
51
|
+
*/
|
|
52
|
+
function toInsertValues(event: DomainEvent) {
|
|
53
|
+
const metadata = event.metadata ?? undefined;
|
|
54
|
+
const pool = (metadata?.['pool'] as string | undefined) ?? null;
|
|
55
|
+
const direction = (metadata?.['direction'] as string | undefined) ?? null;
|
|
56
|
+
const tenantId = (metadata?.['tenantId'] as string | undefined) ?? null;
|
|
57
|
+
return {
|
|
58
|
+
id: event.id,
|
|
59
|
+
type: event.type,
|
|
60
|
+
aggregateId: event.aggregateId,
|
|
61
|
+
aggregateType: event.aggregateType,
|
|
62
|
+
payload: event.payload,
|
|
63
|
+
occurredAt: event.occurredAt,
|
|
64
|
+
processedAt: null,
|
|
65
|
+
status: 'pending' as const,
|
|
66
|
+
metadata: event.metadata,
|
|
67
|
+
pool,
|
|
68
|
+
direction,
|
|
69
|
+
tenantId,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@Injectable()
|
|
74
|
+
export class DrizzleEventBus implements IEventBus, OnModuleInit, OnModuleDestroy {
|
|
75
|
+
private readonly logger = new Logger(DrizzleEventBus.name);
|
|
76
|
+
private polling = false;
|
|
77
|
+
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
78
|
+
private readonly handlers = new Map<string, Set<(event: DomainEvent) => Promise<void>>>();
|
|
79
|
+
private readonly opts: EventsModuleOptions;
|
|
80
|
+
|
|
81
|
+
constructor(
|
|
82
|
+
@Inject(DRIZZLE) private readonly db: DrizzleClient,
|
|
83
|
+
@Optional() @Inject(EVENTS_MODULE_OPTIONS) opts?: EventsModuleOptions,
|
|
84
|
+
/**
|
|
85
|
+
* Bridge subsystem hook (BRIDGE-4). Optional — when the bridge
|
|
86
|
+
* subsystem is not installed in the consuming app, this token is
|
|
87
|
+
* undefined and the drain skips the bridge block entirely (preserves
|
|
88
|
+
* EVT-4 baseline behaviour).
|
|
89
|
+
*
|
|
90
|
+
* When provided, `processEvent` is invoked once per drained event
|
|
91
|
+
* INSIDE the per-event tx, before `processed_at` is stamped. The
|
|
92
|
+
* hook owns all knowledge of `bridge_delivery + wrapper job_run`
|
|
93
|
+
* shapes; the events subsystem stays unaware of bridge schemas.
|
|
94
|
+
*/
|
|
95
|
+
@Optional()
|
|
96
|
+
@Inject(BRIDGE_OUTBOX_DRAIN_HOOK)
|
|
97
|
+
private readonly bridgeHook: IBridgeOutboxDrainHook | null = null,
|
|
98
|
+
) {
|
|
99
|
+
// Default so direct construction (e.g. integration tests not going
|
|
100
|
+
// through Nest DI) keeps working without an explicit options object.
|
|
101
|
+
this.opts = opts ?? { backend: 'drizzle' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// Lifecycle
|
|
106
|
+
// ============================================================================
|
|
107
|
+
|
|
108
|
+
async onModuleInit(): Promise<void> {
|
|
109
|
+
this.polling = true;
|
|
110
|
+
this.schedulePoll();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async onModuleDestroy(): Promise<void> {
|
|
114
|
+
this.polling = false;
|
|
115
|
+
if (this.pollTimer) {
|
|
116
|
+
clearTimeout(this.pollTimer);
|
|
117
|
+
this.pollTimer = null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ============================================================================
|
|
122
|
+
// IEventBus
|
|
123
|
+
// ============================================================================
|
|
124
|
+
|
|
125
|
+
async publish(event: DomainEvent, tx?: DrizzleTransaction): Promise<void> {
|
|
126
|
+
const client = (tx ?? this.db) as DrizzleClient;
|
|
127
|
+
await client.insert(domainEvents).values(toInsertValues(event));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async publishMany(events: DomainEvent[], tx?: DrizzleTransaction): Promise<void> {
|
|
131
|
+
if (events.length === 0) return;
|
|
132
|
+
const client = (tx ?? this.db) as DrizzleClient;
|
|
133
|
+
await client.insert(domainEvents).values(events.map(toInsertValues));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async findById(eventId: string): Promise<DomainEvent | null> {
|
|
137
|
+
const rows = await this.db
|
|
138
|
+
.select()
|
|
139
|
+
.from(domainEvents)
|
|
140
|
+
.where(eq(domainEvents.id, eventId))
|
|
141
|
+
.limit(1);
|
|
142
|
+
const row = rows[0];
|
|
143
|
+
if (!row) return null;
|
|
144
|
+
return {
|
|
145
|
+
id: row.id,
|
|
146
|
+
type: row.type,
|
|
147
|
+
aggregateId: row.aggregateId,
|
|
148
|
+
aggregateType: row.aggregateType,
|
|
149
|
+
payload: row.payload as Record<string, unknown>,
|
|
150
|
+
occurredAt:
|
|
151
|
+
row.occurredAt instanceof Date
|
|
152
|
+
? row.occurredAt
|
|
153
|
+
: new Date(row.occurredAt as unknown as string),
|
|
154
|
+
metadata: (row.metadata ?? undefined) as
|
|
155
|
+
| Record<string, unknown>
|
|
156
|
+
| undefined,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
subscribe<T extends DomainEvent = DomainEvent>(
|
|
161
|
+
eventType: string,
|
|
162
|
+
handler: (event: T) => Promise<void>,
|
|
163
|
+
): () => void {
|
|
164
|
+
if (!this.handlers.has(eventType)) {
|
|
165
|
+
this.handlers.set(eventType, new Set());
|
|
166
|
+
}
|
|
167
|
+
const set = this.handlers.get(eventType)!;
|
|
168
|
+
const h = handler as (event: DomainEvent) => Promise<void>;
|
|
169
|
+
set.add(h);
|
|
170
|
+
return () => {
|
|
171
|
+
set.delete(h);
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// Polling
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Test-only hook. Runs exactly one drain cycle and returns. Production
|
|
181
|
+
* code goes through `onModuleInit` → `schedulePoll`, which calls the
|
|
182
|
+
* same `processBatch` under a timer.
|
|
183
|
+
*/
|
|
184
|
+
async drainOnce(): Promise<void> {
|
|
185
|
+
await this.processBatch();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private schedulePoll(): void {
|
|
189
|
+
if (!this.polling) return;
|
|
190
|
+
this.pollTimer = setTimeout(async () => {
|
|
191
|
+
try {
|
|
192
|
+
await this.processBatch();
|
|
193
|
+
} catch (err) {
|
|
194
|
+
this.logger.error(`Poll cycle error: ${err}`);
|
|
195
|
+
} finally {
|
|
196
|
+
this.schedulePoll();
|
|
197
|
+
}
|
|
198
|
+
}, POLL_INTERVAL_MS);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Drain one batch (BRIDGE-4 restructure of EVT-4).
|
|
203
|
+
*
|
|
204
|
+
* Two-phase per drained event:
|
|
205
|
+
*
|
|
206
|
+
* 1. **Per-event transaction** — bridge fanout (`bridgeHook.processEvent`)
|
|
207
|
+
* + `processed_at` stamp. Both write through the same `tx`. A throw
|
|
208
|
+
* inside the tx (only infra-level failures should reach here, since
|
|
209
|
+
* the hook tolerates null direction and registry misses inline)
|
|
210
|
+
* rolls back the bridge inserts AND the `processed_at` stamp; the
|
|
211
|
+
* event re-claims on the next drain cycle. Bridge `UNIQUE
|
|
212
|
+
* (event_id, trigger_id)` makes the retry idempotent.
|
|
213
|
+
*
|
|
214
|
+
* 2. **After commit** — dispatch in-process subscribers (`IEventBus.subscribe`
|
|
215
|
+
* handlers). This deliberately runs OUTSIDE the per-event tx (lead
|
|
216
|
+
* decision 2026-04-22): subscribers are best-effort and must not
|
|
217
|
+
* gate forward progress or roll back bridge fanout. Subscriber
|
|
218
|
+
* errors are caught + logged; `processed_at` is already committed.
|
|
219
|
+
* The old `MAX_RETRIES=3` in-process retry loop and the
|
|
220
|
+
* `failed`-stamping path were removed in BRIDGE-4 along with their
|
|
221
|
+
* coupling.
|
|
222
|
+
*
|
|
223
|
+
* The `processed_at` UPDATE carries `AND status='pending'` (BRIDGE-4
|
|
224
|
+
* tightening — without it, a hypothetical double-claim could double-stamp
|
|
225
|
+
* the timestamp). The per-event tx + `FOR UPDATE SKIP LOCKED` claim
|
|
226
|
+
* make this defensive belt-and-suspenders.
|
|
227
|
+
*/
|
|
228
|
+
private async processBatch(): Promise<void> {
|
|
229
|
+
const pools = this.opts.pools;
|
|
230
|
+
|
|
231
|
+
// Build WHERE: status='pending' [AND pool IN (...)]
|
|
232
|
+
const whereClause: SQL<unknown> = pools && pools.length > 0
|
|
233
|
+
? (and(eq(domainEvents.status, 'pending'), inArray(domainEvents.pool, pools)) as SQL<unknown>)
|
|
234
|
+
: eq(domainEvents.status, 'pending');
|
|
235
|
+
|
|
236
|
+
// Claim a batch with FOR UPDATE SKIP LOCKED so multiple pollers don't
|
|
237
|
+
// double-dispatch. The lock is released when the outer transaction
|
|
238
|
+
// commits — which is fine because the immediately-following per-event
|
|
239
|
+
// tx flips status='processed' under its own `AND status='pending'`
|
|
240
|
+
// guard, so a re-claim of the same row in a subsequent batch is a
|
|
241
|
+
// no-op UPDATE.
|
|
242
|
+
const rows = await this.db.transaction(async (tx) => {
|
|
243
|
+
return tx
|
|
244
|
+
.select()
|
|
245
|
+
.from(domainEvents)
|
|
246
|
+
.where(whereClause)
|
|
247
|
+
.orderBy(asc(domainEvents.occurredAt))
|
|
248
|
+
.limit(POLL_BATCH_SIZE)
|
|
249
|
+
.for('update', { skipLocked: true });
|
|
250
|
+
}) as Array<typeof domainEvents.$inferSelect>;
|
|
251
|
+
|
|
252
|
+
for (const row of rows) {
|
|
253
|
+
const event: DomainEvent = {
|
|
254
|
+
id: row.id,
|
|
255
|
+
type: row.type,
|
|
256
|
+
aggregateId: row.aggregateId,
|
|
257
|
+
aggregateType: row.aggregateType,
|
|
258
|
+
payload: row.payload as Record<string, unknown>,
|
|
259
|
+
occurredAt: row.occurredAt instanceof Date ? row.occurredAt : new Date(row.occurredAt as unknown as string),
|
|
260
|
+
metadata: (row.metadata ?? undefined) as Record<string, unknown> | undefined,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Phase 1 — per-event tx: bridge fanout + processed_at stamp.
|
|
264
|
+
try {
|
|
265
|
+
await this.db.transaction(async (tx) => {
|
|
266
|
+
if (this.bridgeHook) {
|
|
267
|
+
await this.bridgeHook.processEvent(event, tx);
|
|
268
|
+
}
|
|
269
|
+
await tx
|
|
270
|
+
.update(domainEvents)
|
|
271
|
+
.set({ status: 'processed', processedAt: new Date() })
|
|
272
|
+
.where(
|
|
273
|
+
and(
|
|
274
|
+
eq(domainEvents.id, event.id),
|
|
275
|
+
eq(domainEvents.status, 'pending'),
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
} catch (err) {
|
|
280
|
+
// Infra-level failure inside the per-event tx — bridge inserts
|
|
281
|
+
// and processed_at both rolled back. Log and move on; the next
|
|
282
|
+
// drain cycle re-claims the row. UNIQUE on bridge_delivery makes
|
|
283
|
+
// the retry idempotent.
|
|
284
|
+
this.logger.error(
|
|
285
|
+
`Per-event tx failed for event id=${event.id} type=${event.type}: ${err}`,
|
|
286
|
+
);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Phase 2 — best-effort subscriber dispatch. Errors are logged
|
|
291
|
+
// and discarded; processed_at is already committed. Subscribers
|
|
292
|
+
// are observability + cache-busts + small ancillary work; they
|
|
293
|
+
// must not gate forward progress.
|
|
294
|
+
try {
|
|
295
|
+
await this.dispatch(event);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
this.logger.error(
|
|
298
|
+
`Subscriber dispatch failed for event id=${event.id} type=${event.type} ` +
|
|
299
|
+
`(processed_at already committed; failure does not retry): ${err}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private async dispatch(event: DomainEvent): Promise<void> {
|
|
306
|
+
const set = this.handlers.get(event.type);
|
|
307
|
+
if (!set) return;
|
|
308
|
+
|
|
309
|
+
let firstError: unknown;
|
|
310
|
+
for (const handler of set) {
|
|
311
|
+
try {
|
|
312
|
+
await handler(event);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
this.logger.error(
|
|
315
|
+
`Handler error for event type "${event.type}" (id: ${event.id}): ${err}`,
|
|
316
|
+
);
|
|
317
|
+
if (firstError === undefined) {
|
|
318
|
+
firstError = err;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (firstError !== undefined) {
|
|
324
|
+
throw firstError;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|