@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.
Files changed (133) hide show
  1. package/package.json +2 -1
  2. package/runtime/analytics/index.ts +31 -0
  3. package/runtime/analytics/metrics.ts +85 -0
  4. package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
  5. package/runtime/analytics/packs/index.ts +5 -0
  6. package/runtime/analytics/packs/monetary-measures.ts +20 -0
  7. package/runtime/analytics/specs.ts +54 -0
  8. package/runtime/analytics/types.ts +105 -0
  9. package/runtime/base-classes/activity-entity-repository.ts +50 -0
  10. package/runtime/base-classes/activity-entity-service.ts +48 -0
  11. package/runtime/base-classes/base-read-use-cases.ts +88 -0
  12. package/runtime/base-classes/base-repository.ts +289 -0
  13. package/runtime/base-classes/base-service.ts +183 -0
  14. package/runtime/base-classes/index.ts +38 -0
  15. package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
  16. package/runtime/base-classes/knowledge-entity-service.ts +14 -0
  17. package/runtime/base-classes/lifecycle-events.ts +152 -0
  18. package/runtime/base-classes/metadata-entity-repository.ts +80 -0
  19. package/runtime/base-classes/metadata-entity-service.ts +48 -0
  20. package/runtime/base-classes/synced-entity-repository.ts +57 -0
  21. package/runtime/base-classes/synced-entity-service.ts +50 -0
  22. package/runtime/base-classes/with-analytics.ts +22 -0
  23. package/runtime/constants/tokens.ts +29 -0
  24. package/runtime/eav-helpers.ts +74 -0
  25. package/runtime/pipes/zod-validation.pipe.ts +64 -0
  26. package/runtime/shared/openapi/error-response.dto.ts +24 -0
  27. package/runtime/shared/openapi/errors.ts +39 -0
  28. package/runtime/shared/openapi/index.ts +20 -0
  29. package/runtime/shared/openapi/registry.tokens.ts +13 -0
  30. package/runtime/shared/openapi/registry.ts +151 -0
  31. package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
  32. package/runtime/subsystems/analytics/analytics.module.ts +64 -0
  33. package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
  34. package/runtime/subsystems/analytics/cube-backend.ts +75 -0
  35. package/runtime/subsystems/analytics/index.ts +15 -0
  36. package/runtime/subsystems/analytics/noop-backend.ts +27 -0
  37. package/runtime/subsystems/auth/auth.module.ts +91 -0
  38. package/runtime/subsystems/auth/auth.tokens.ts +27 -0
  39. package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
  40. package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
  41. package/runtime/subsystems/auth/index.ts +77 -0
  42. package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
  43. package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
  44. package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
  45. package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
  46. package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
  47. package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
  48. package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
  49. package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
  50. package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
  51. package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
  52. package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
  53. package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
  54. package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
  55. package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
  56. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
  57. package/runtime/subsystems/bridge/bridge.module.ts +160 -0
  58. package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
  59. package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
  60. package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
  61. package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
  62. package/runtime/subsystems/bridge/generated/registry.ts +6 -0
  63. package/runtime/subsystems/bridge/index.ts +84 -0
  64. package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
  65. package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
  66. package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
  67. package/runtime/subsystems/cache/cache.module.ts +115 -0
  68. package/runtime/subsystems/cache/cache.protocol.ts +45 -0
  69. package/runtime/subsystems/cache/cache.schema.ts +27 -0
  70. package/runtime/subsystems/cache/cache.tokens.ts +17 -0
  71. package/runtime/subsystems/cache/index.ts +22 -0
  72. package/runtime/subsystems/events/domain-events.schema.ts +77 -0
  73. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
  74. package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
  75. package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
  76. package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
  77. package/runtime/subsystems/events/events-errors.ts +30 -0
  78. package/runtime/subsystems/events/events.module.ts +230 -0
  79. package/runtime/subsystems/events/events.tokens.ts +62 -0
  80. package/runtime/subsystems/events/generated/bus.ts +103 -0
  81. package/runtime/subsystems/events/generated/index.ts +7 -0
  82. package/runtime/subsystems/events/generated/registry.ts +84 -0
  83. package/runtime/subsystems/events/generated/schemas.ts +59 -0
  84. package/runtime/subsystems/events/generated/types.ts +94 -0
  85. package/runtime/subsystems/events/index.ts +21 -0
  86. package/runtime/subsystems/index.ts +63 -0
  87. package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
  88. package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
  89. package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
  90. package/runtime/subsystems/jobs/index.ts +120 -0
  91. package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
  92. package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
  93. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
  94. package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +850 -0
  95. package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
  96. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
  97. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
  98. package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
  99. package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
  100. package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
  101. package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
  102. package/runtime/subsystems/jobs/job-worker.module.ts +302 -0
  103. package/runtime/subsystems/jobs/job-worker.ts +615 -0
  104. package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
  105. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
  106. package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
  107. package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
  108. package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
  109. package/runtime/subsystems/storage/index.ts +18 -0
  110. package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
  111. package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
  112. package/runtime/subsystems/storage/storage.module.ts +60 -0
  113. package/runtime/subsystems/storage/storage.protocol.ts +78 -0
  114. package/runtime/subsystems/storage/storage.tokens.ts +9 -0
  115. package/runtime/subsystems/storage/storage.utils.ts +20 -0
  116. package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
  117. package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
  118. package/runtime/subsystems/sync/index.ts +98 -0
  119. package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
  120. package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
  121. package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
  122. package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
  123. package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
  124. package/runtime/subsystems/sync/sync-errors.ts +54 -0
  125. package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
  126. package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
  127. package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
  128. package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
  129. package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
  130. package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
  131. package/runtime/subsystems/sync/sync.module.ts +156 -0
  132. package/runtime/subsystems/sync/sync.tokens.ts +57 -0
  133. package/runtime/types/drizzle.ts +23 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * JobsDomainModule — `DynamicModule.forRoot({ backend })` factory wiring
3
+ * the three jobs-domain protocol tokens to a backend implementation
4
+ * (ADR-022, JOB-5).
5
+ *
6
+ * Mirrors `EventsModule.forRoot()` exactly:
7
+ * - `global: true` so consumer entity modules don't have to import this
8
+ * individually — `JOB_ORCHESTRATOR` / `JOB_RUN_SERVICE` /
9
+ * `JOB_STEP_SERVICE` are available project-wide.
10
+ * - One backend at a time (Drizzle for production, Memory for tests).
11
+ *
12
+ * Backend swappability follows the core/extension protocol from CLAUDE.md:
13
+ * the three tokens are the **core contract**; backend-specific tunables
14
+ * live under `extensions.<backend>` so opting into a feature is explicit
15
+ * and the type system reserves the slot.
16
+ */
17
+ import { Module, type DynamicModule, type Provider } from '@nestjs/common';
18
+ import {
19
+ JOB_ORCHESTRATOR,
20
+ JOB_RUN_SERVICE,
21
+ JOB_STEP_SERVICE,
22
+ JOBS_MULTI_TENANT,
23
+ } from './jobs-domain.tokens';
24
+ import { DrizzleJobOrchestrator } from './job-orchestrator.drizzle-backend';
25
+ import { DrizzleJobRunService } from './job-run-service.drizzle-backend';
26
+ import { DrizzleJobStepService } from './job-step-service.drizzle-backend';
27
+ import { MemoryJobOrchestrator } from './job-orchestrator.memory-backend';
28
+ import { MemoryJobRunService } from './job-run-service.memory-backend';
29
+ import { MemoryJobStepService } from './job-step-service.memory-backend';
30
+ import { MemoryJobStore } from './memory-job-store';
31
+
32
+ /**
33
+ * Drizzle backend extensions surface. None are wired into the Drizzle
34
+ * orchestrator yet — this is the **typed reservation** for the LISTEN/NOTIFY
35
+ * + tunable poll-interval extensions called out in ADR-022. App code
36
+ * passing these today is parsed but not yet dispatched; when the
37
+ * Drizzle orchestrator grows the consumer hooks, opt-in code paths will
38
+ * read directly from these fields.
39
+ */
40
+ export interface DrizzleBackendExtensions {
41
+ /** Use Postgres LISTEN/NOTIFY to wake the polling loop. Default false. */
42
+ listenNotify?: boolean;
43
+ /** Polling interval when LISTEN/NOTIFY is off (ms). Default 1000. */
44
+ pollIntervalMs?: number;
45
+ }
46
+
47
+ // Phase 6+ — typed-but-unimplemented BullMQ extension slot. Kept as a
48
+ // commented-out interface to make the future shape discoverable without
49
+ // shipping dead runtime code. Per CLAUDE.md "no feature-flag-guarded dead
50
+ // code" we don't ship the option in `JobsDomainModuleOptions.extensions`
51
+ // either; flip it on when JOB-Phase-6 lands the BullMQ orchestrator.
52
+ //
53
+ // export interface BullMqBackendExtensions {
54
+ // bullBoard?: { enabled: boolean; mountPath?: string };
55
+ // redisUrl?: string;
56
+ // }
57
+
58
+ export interface JobsDomainModuleOptions {
59
+ backend: 'drizzle' | 'memory';
60
+ /**
61
+ * Backend-specific extensions. Only the matching backend's extensions
62
+ * are read at boot; non-matching keys are ignored. This is the
63
+ * core/extension protocol surface — see CLAUDE.md.
64
+ */
65
+ extensions?: {
66
+ drizzle?: DrizzleBackendExtensions;
67
+ // bullmq?: BullMqBackendExtensions; // Phase 6+
68
+ };
69
+ /** Multi-tenancy opt-in. Wired by JOB-8; module signature stays stable. */
70
+ multiTenant?: boolean;
71
+ }
72
+
73
+ @Module({})
74
+ export class JobsDomainModule {
75
+ static forRoot(opts: JobsDomainModuleOptions): DynamicModule {
76
+ void opts.extensions; // typed reservation; consumed by Phase 6+ wiring
77
+
78
+ const multiTenant = opts.multiTenant ?? false;
79
+
80
+ const providers: Provider[] = [
81
+ // JOB-8 — boolean provider consumed by the four service-layer backends.
82
+ // Always provided (even when `multiTenant === false`) so `@Inject`
83
+ // always resolves; backends short-circuit the enforcement path when
84
+ // the value is `false`. See `jobs-domain.tokens.ts` for the claim-loop
85
+ // cross-tenant-by-design decision.
86
+ { provide: JOBS_MULTI_TENANT, useValue: multiTenant },
87
+ ];
88
+
89
+ if (opts.backend === 'memory') {
90
+ // The store is a plain class — wired as a singleton `useValue` so
91
+ // unit tests can pull it out via `.get(MemoryJobStore)` for direct
92
+ // assertions (matches the access pattern in JOB-4 specs).
93
+ const store = new MemoryJobStore();
94
+ providers.push({ provide: MemoryJobStore, useValue: store });
95
+ providers.push(MemoryJobStepService);
96
+ providers.push({ provide: JOB_STEP_SERVICE, useExisting: MemoryJobStepService });
97
+ providers.push(MemoryJobOrchestrator);
98
+ providers.push({ provide: JOB_ORCHESTRATOR, useExisting: MemoryJobOrchestrator });
99
+ providers.push(MemoryJobRunService);
100
+ providers.push({ provide: JOB_RUN_SERVICE, useExisting: MemoryJobRunService });
101
+ } else {
102
+ providers.push({ provide: JOB_ORCHESTRATOR, useClass: DrizzleJobOrchestrator });
103
+ providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });
104
+ providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });
105
+ }
106
+
107
+ return {
108
+ module: JobsDomainModule,
109
+ global: true,
110
+ providers,
111
+ exports: [
112
+ JOB_ORCHESTRATOR,
113
+ JOB_RUN_SERVICE,
114
+ JOB_STEP_SERVICE,
115
+ JOBS_MULTI_TENANT,
116
+ ],
117
+ };
118
+ }
119
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Injection tokens for the job orchestration domain layer (ADR-022, JOB-2).
3
+ *
4
+ * Consumer code injects these symbols via `@Inject(JOB_ORCHESTRATOR)` etc.;
5
+ * concrete backends (JOB-3 Drizzle, JOB-4 Memory) provide the implementations
6
+ * through `JobsDomainModule.forRoot({ backend })` in JOB-5.
7
+ *
8
+ * Each token is a unique `Symbol` — guaranteed distinct from every other
9
+ * Symbol at runtime, which is exactly the uniqueness guarantee Nest's DI
10
+ * container relies on for token-based lookup.
11
+ */
12
+ export const JOB_ORCHESTRATOR = Symbol('JOB_ORCHESTRATOR');
13
+ export const JOB_RUN_SERVICE = Symbol('JOB_RUN_SERVICE');
14
+ export const JOB_STEP_SERVICE = Symbol('JOB_STEP_SERVICE');
15
+
16
+ /**
17
+ * Multi-tenancy opt-in flag (JOB-8). Bound to the boolean passed in via
18
+ * `JobsDomainModule.forRoot({ multiTenant })`, defaulting to `false`.
19
+ *
20
+ * When `true`, the four service-layer backends (Drizzle + Memory orchestrator
21
+ * and run-service) enforce `tenantId` on every mutating / targeted-read call:
22
+ * `start`, `cancel`, `listForScope`, `cancelForScope`, `rescheduleForScope`.
23
+ * Missing (`undefined`) `tenantId` throws `MissingTenantIdError`; explicit
24
+ * `null` opts into cross-tenant background work and passes through.
25
+ *
26
+ * The JobWorker claim loop is **cross-tenant by design** — the worker has no
27
+ * tenant context; `tenantId` is populated at write time and enforced on
28
+ * targeted reads. See docs/specs/JOB-8.md.
29
+ */
30
+ export const JOBS_MULTI_TENANT = Symbol('JOBS_MULTI_TENANT');
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Typed errors for the job orchestration domain (ADR-022, JOB-3).
3
+ *
4
+ * All thrown by the Drizzle orchestrator (and mirrored by the Memory
5
+ * backend in JOB-4). They exist as classes so consumers can `instanceof`
6
+ * them in catch blocks and exception filters can map them to HTTP codes.
7
+ */
8
+ import type { JobRun } from './job-orchestrator.protocol';
9
+
10
+ /**
11
+ * `start(type, …)` was called for a job type that has no row in the `job`
12
+ * table. At runtime this usually means the handler was not decorated or the
13
+ * boot validator (JOB-5) has not registered it yet.
14
+ */
15
+ export class JobTypeNotFoundError extends Error {
16
+ override readonly name = 'JobTypeNotFoundError';
17
+ constructor(public readonly jobType: string) {
18
+ super(`No job definition registered for type '${jobType}'.`);
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Thrown by `start` when `collision_mode === 'reject'` and a non-terminal
24
+ * run with the same `concurrency_key` already exists. Carries the incumbent
25
+ * so callers can surface its id or subscribe to its completion event.
26
+ */
27
+ export class JobCollisionError extends Error {
28
+ override readonly name = 'JobCollisionError';
29
+ constructor(
30
+ public readonly jobType: string,
31
+ public readonly concurrencyKey: string,
32
+ public readonly incumbent: JobRun,
33
+ ) {
34
+ super(
35
+ `Job type '${jobType}' has an in-flight run with concurrency_key ` +
36
+ `'${concurrencyKey}' (incumbent ${incumbent.id}); collision_mode=reject.`,
37
+ );
38
+ }
39
+ }
40
+
41
+ /**
42
+ * `replay` was called on a run that is not in a replayable terminal state
43
+ * (i.e. still `pending` / `running` / `waiting`). Replay always spawns
44
+ * fresh execution and therefore requires the source run to be settled.
45
+ */
46
+ export class JobNotReplayableError extends Error {
47
+ override readonly name = 'JobNotReplayableError';
48
+ constructor(
49
+ public readonly runId: string,
50
+ public readonly currentStatus: string,
51
+ ) {
52
+ super(
53
+ `Run ${runId} is not replayable from status '${currentStatus}'. ` +
54
+ `Only 'completed', 'failed', 'timed_out', and 'canceled' are eligible.`,
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * A `concurrency_key_template` or `dedupe_key_template` referenced a field
61
+ * that is not present on the input payload. Caught at `start` time so the
62
+ * caller sees the misconfiguration synchronously rather than at claim time.
63
+ */
64
+ export class JobTemplateFieldMissingError extends Error {
65
+ override readonly name = 'JobTemplateFieldMissingError';
66
+ constructor(
67
+ public readonly template: string,
68
+ public readonly field: string,
69
+ ) {
70
+ super(
71
+ `Template '${template}' references input field '${field}' which is ` +
72
+ `missing or undefined on the payload.`,
73
+ );
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Thrown by the four multi-tenant-aware service-layer backends (JOB-8)
79
+ * when `JobsDomainModule` was configured with `multiTenant: true` but the
80
+ * caller did not pass a `tenantId` in the relevant options object.
81
+ *
82
+ * **Strict enforcement rationale (resolved 2026-04-18).** Cross-tenant data
83
+ * leakage is the worst class of bug a multi-tenant system can ship; surfacing
84
+ * the misuse loudly at the call site (rather than silently defaulting to
85
+ * `null` or to the "last tenant seen") prevents both accidental global
86
+ * writes and sneaky reads that return a union of tenants.
87
+ *
88
+ * - `undefined` `tenantId` → throw this error.
89
+ * - Explicit `null` `tenantId` → passes; opts the call into cross-tenant
90
+ * background work (e.g. a nightly housekeeping job that must scan all
91
+ * tenants). The row is persisted with `tenant_id = NULL`.
92
+ */
93
+ export class MissingTenantIdError extends Error {
94
+ override readonly name = 'MissingTenantIdError';
95
+ constructor(public readonly method: string) {
96
+ super(
97
+ `MissingTenantIdError: JobsDomainModule was configured with ` +
98
+ `multiTenant=true but ${method} was called without tenantId ` +
99
+ `(undefined). Pass an explicit tenantId, or pass null for ` +
100
+ `cross-tenant work.`,
101
+ );
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Thrown by `JobWorkerModule.onModuleInit` (Drizzle backend only) when the
107
+ * `job` table contains type rows for which no `@JobHandler` is registered
108
+ * in the running process. Surfaces every orphaned type at once so a single
109
+ * boot tells the operator everything to clean up.
110
+ *
111
+ * Skipped entirely in memory mode (Q4 resolution 2026-04-19) — the memory
112
+ * backend has no DB rows to validate; `MemoryJobOrchestrator.start()`
113
+ * throws `JobTypeNotFoundError` synchronously for unknown types instead.
114
+ */
115
+ export class BootValidationError extends Error {
116
+ override readonly name = 'BootValidationError';
117
+ constructor(public readonly missingHandlers: string[]) {
118
+ super(
119
+ `BootValidationError: ${missingHandlers.length} orphaned job type(s) ` +
120
+ `in 'job' table with no matching @JobHandler in the running process: ` +
121
+ `[${missingHandlers.join(', ')}]. Either register the handler(s) or ` +
122
+ `remove the rows.`,
123
+ );
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Thrown by `JobWorkerModule.onModuleInit` when one or more `@JobHandler`
129
+ * classes target a `reserved: true` pool from the resolved pool config
130
+ * (the three `events_*` pools are reserved for the events subsystem
131
+ * outbox drain). Listing every offender on a single boot avoids the
132
+ * fix-one-restart-fix-next loop.
133
+ */
134
+ export class ReservedPoolViolationError extends Error {
135
+ override readonly name = 'ReservedPoolViolationError';
136
+ constructor(
137
+ public readonly offenders: ReadonlyArray<{
138
+ handlerClass: string;
139
+ pool: string;
140
+ }>,
141
+ ) {
142
+ super(
143
+ `ReservedPoolViolationError: ${offenders.length} @JobHandler(s) target ` +
144
+ `reserved pools — reserved pools are framework-only:\n` +
145
+ offenders
146
+ .map((o) => ` - ${o.handlerClass} → pool='${o.pool}'`)
147
+ .join('\n'),
148
+ );
149
+ }
150
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * MemoryJobStore — the shared in-memory backing for the three memory-backend
3
+ * services (ADR-022, JOB-4).
4
+ *
5
+ * Plain class, not `@Injectable()`. Wired as a `useValue` provider in
6
+ * JOB-5's `JobsDomainModule.forRoot({ backend: 'memory' })` so unit tests
7
+ * can keep a direct reference for `beforeEach` resets.
8
+ *
9
+ * All three memory services receive the same `MemoryJobStore` instance via
10
+ * constructor injection; the store owns mutable state, the services are
11
+ * stateless mutators.
12
+ */
13
+ import type {
14
+ JobDefinitionRow,
15
+ JobRunRow,
16
+ JobStepRow,
17
+ } from './job-orchestration.schema';
18
+
19
+ export class MemoryJobStore {
20
+ /** Runs keyed by `id` (single source of truth for status/scope/lineage). */
21
+ readonly runs: Map<string, JobRunRow> = new Map();
22
+
23
+ /** Steps keyed by `job_run_id`; array order matches insertion order. */
24
+ readonly steps: Map<string, JobStepRow[]> = new Map();
25
+
26
+ /** Job definitions keyed by `type` — memory mirror of the `job` table. */
27
+ readonly jobs: Map<string, JobDefinitionRow> = new Map();
28
+
29
+ /** Reset everything. Tests call this in `beforeEach`. */
30
+ clear(): void {
31
+ this.runs.clear();
32
+ this.steps.clear();
33
+ this.jobs.clear();
34
+ }
35
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Pool config loader for the job orchestration domain (ADR-022, JOB-5).
3
+ *
4
+ * Reads `codegen.config.yaml: jobs.pools` from `process.cwd()` (or an
5
+ * explicit `configPath` for tests), merges user-defined pools onto the five
6
+ * framework defaults, and returns the resolved `Map<string, PoolDefinition>`
7
+ * consumed by `JobWorkerModule.onModuleInit` and `JobsDomainModule`'s
8
+ * config-validator surface.
9
+ *
10
+ * Invariants:
11
+ * - User cannot flip `reserved: true` on a framework pool — silently
12
+ * preserved. The three `events_*` pools are reserved infrastructure
13
+ * for the events outbox drain.
14
+ * - User-defined pools cannot set `reserved: true` — `reserved` is
15
+ * framework-only metadata.
16
+ * - Missing `codegen.config.yaml` is not an error; loader returns the
17
+ * framework defaults verbatim.
18
+ *
19
+ * Result is cached at module scope after first call so repeated reads (e.g.
20
+ * a worker module + a one-off scaffold validator in the same process) hit
21
+ * the same parse. Tests that pass `configPath` skip the cache and isolate.
22
+ */
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { resolve } from 'node:path';
25
+ import { parse as parseYaml } from 'yaml';
26
+
27
+ export interface PoolDefinition {
28
+ /** Routing identifier — reused as the per-pool worker queue name. */
29
+ queue: string;
30
+ /** Max parallel in-flight `processRun` calls for this pool's worker. */
31
+ concurrency: number;
32
+ /** `true` ⇒ user `@JobHandler` may not target it. Framework-only. */
33
+ reserved: boolean;
34
+ /** Free-text annotation surfaced in admin UIs / logs. */
35
+ description?: string;
36
+ }
37
+
38
+ export type PoolConfig = Map<string, PoolDefinition>;
39
+
40
+ /**
41
+ * Five framework defaults. Three reserved `events_*` pools drain the
42
+ * `IEventBus` outbox (one per `DomainEvent.direction`); `interactive` and
43
+ * `batch` are user-default pools (`batch` is the `@JobHandler` default
44
+ * when no `pool` is specified).
45
+ */
46
+ export const FRAMEWORK_POOLS: Readonly<Record<string, PoolDefinition>> = Object.freeze({
47
+ events_inbound: Object.freeze({
48
+ queue: 'jobs-events-inbound',
49
+ concurrency: 20,
50
+ reserved: true,
51
+ description: 'Inbound events drain (events subsystem outbox).',
52
+ }),
53
+ events_change: Object.freeze({
54
+ queue: 'jobs-events-change',
55
+ concurrency: 30,
56
+ reserved: true,
57
+ description: 'Change events drain (events subsystem outbox).',
58
+ }),
59
+ events_outbound: Object.freeze({
60
+ queue: 'jobs-events-outbound',
61
+ concurrency: 10,
62
+ reserved: true,
63
+ description: 'Outbound events drain (events subsystem outbox).',
64
+ }),
65
+ interactive: Object.freeze({
66
+ queue: 'jobs-interactive',
67
+ concurrency: 20,
68
+ reserved: false,
69
+ description: 'User-facing latency-sensitive jobs.',
70
+ }),
71
+ batch: Object.freeze({
72
+ queue: 'jobs-batch',
73
+ concurrency: 5,
74
+ reserved: false,
75
+ description: 'Default pool for background jobs.',
76
+ }),
77
+ });
78
+
79
+ /** Names of the framework reserved pools. Cheap inline lookup for the worker. */
80
+ export const RESERVED_POOL_NAMES: ReadonlySet<string> = new Set(
81
+ Object.entries(FRAMEWORK_POOLS)
82
+ .filter(([, def]) => def.reserved)
83
+ .map(([name]) => name),
84
+ );
85
+
86
+ /**
87
+ * Cache by absolute config path. The `cwd` default is normalised before
88
+ * lookup so two callers passing the same path share the cache; explicit
89
+ * test-only paths cache separately.
90
+ */
91
+ const cache = new Map<string, PoolConfig>();
92
+
93
+ /**
94
+ * Reset the loader cache. Test-only — not exported from the package
95
+ * `index.ts`. Useful for tests that mutate `process.cwd()` between cases.
96
+ */
97
+ export function _resetPoolConfigCacheForTests(): void {
98
+ cache.clear();
99
+ }
100
+
101
+ /**
102
+ * Resolve the merged pool config.
103
+ *
104
+ * @param configPath optional absolute or cwd-relative path; defaults to
105
+ * `${process.cwd()}/codegen.config.yaml`.
106
+ */
107
+ export function loadPoolConfig(configPath?: string): PoolConfig {
108
+ const resolved = resolve(configPath ?? `${process.cwd()}/codegen.config.yaml`);
109
+ const cached = cache.get(resolved);
110
+ if (cached) return cached;
111
+
112
+ const merged = new Map<string, PoolDefinition>();
113
+ // Seed with framework defaults first — they always take precedence on
114
+ // `reserved` and provide defaults for `queue` / `concurrency` if user
115
+ // overrides only some fields.
116
+ for (const [name, def] of Object.entries(FRAMEWORK_POOLS)) {
117
+ merged.set(name, { ...def });
118
+ }
119
+
120
+ if (!existsSync(resolved)) {
121
+ cache.set(resolved, merged);
122
+ return merged;
123
+ }
124
+
125
+ let raw: unknown;
126
+ try {
127
+ raw = parseYaml(readFileSync(resolved, 'utf8'));
128
+ } catch (err) {
129
+ throw new Error(
130
+ `pool-config.loader: failed to parse YAML at ${resolved}: ${(err as Error).message}`,
131
+ );
132
+ }
133
+
134
+ const userPools = extractUserPools(raw);
135
+ for (const [name, userDef] of Object.entries(userPools)) {
136
+ const existing = merged.get(name);
137
+ if (existing) {
138
+ // Framework pool — user may tweak concurrency + description but
139
+ // cannot flip `reserved`. `queue` is frozen too (reserved framework
140
+ // pools' queue identifiers are part of the cross-subsystem contract
141
+ // with the events outbox drain).
142
+ const next: PoolDefinition = {
143
+ queue: existing.queue,
144
+ concurrency:
145
+ typeof userDef.concurrency === 'number'
146
+ ? userDef.concurrency
147
+ : existing.concurrency,
148
+ reserved: existing.reserved,
149
+ description: userDef.description ?? existing.description,
150
+ };
151
+ merged.set(name, next);
152
+ continue;
153
+ }
154
+ // User-defined pool. Validate required fields; reject reserved.
155
+ if (typeof userDef.queue !== 'string' || userDef.queue.length === 0) {
156
+ throw new Error(
157
+ `pool-config.loader: pool '${name}' must declare a non-empty 'queue'.`,
158
+ );
159
+ }
160
+ if (typeof userDef.concurrency !== 'number' || userDef.concurrency <= 0) {
161
+ throw new Error(
162
+ `pool-config.loader: pool '${name}' must declare a positive 'concurrency'.`,
163
+ );
164
+ }
165
+ if (userDef.reserved === true) {
166
+ throw new Error(
167
+ `pool-config.loader: user-defined pool '${name}' cannot set ` +
168
+ `'reserved: true' — reserved is framework-only.`,
169
+ );
170
+ }
171
+ merged.set(name, {
172
+ queue: userDef.queue,
173
+ concurrency: userDef.concurrency,
174
+ reserved: false,
175
+ description: userDef.description,
176
+ });
177
+ }
178
+
179
+ cache.set(resolved, merged);
180
+ return merged;
181
+ }
182
+
183
+ /**
184
+ * Names of every non-reserved pool in the resolved config. The default
185
+ * worker activation set when `JobWorkerModuleOptions.pools` is omitted —
186
+ * the worker process never claims the reserved `events_*` pools by
187
+ * default; those are bound by the events subsystem's outbox bridge.
188
+ */
189
+ export function allNonReservedPoolNames(config: PoolConfig): string[] {
190
+ const out: string[] = [];
191
+ for (const [name, def] of config) {
192
+ if (!def.reserved) out.push(name);
193
+ }
194
+ return out;
195
+ }
196
+
197
+ // ─── internals ──────────────────────────────────────────────────────────────
198
+
199
+ interface UserPoolShape {
200
+ queue?: string;
201
+ concurrency?: number;
202
+ reserved?: boolean;
203
+ description?: string;
204
+ }
205
+
206
+ function extractUserPools(raw: unknown): Record<string, UserPoolShape> {
207
+ if (!raw || typeof raw !== 'object') return {};
208
+ const jobs = (raw as { jobs?: unknown }).jobs;
209
+ if (!jobs || typeof jobs !== 'object') return {};
210
+ const pools = (jobs as { pools?: unknown }).pools;
211
+ if (!pools || typeof pools !== 'object') return {};
212
+ const out: Record<string, UserPoolShape> = {};
213
+ for (const [name, def] of Object.entries(pools as Record<string, unknown>)) {
214
+ if (!def || typeof def !== 'object') continue;
215
+ out[name] = def as UserPoolShape;
216
+ }
217
+ return out;
218
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Storage subsystem — public API
3
+ *
4
+ * Import the protocol and token in use cases:
5
+ * ```typescript
6
+ * import { STORAGE, type IStorageService } from '@shared/subsystems/storage';
7
+ * ```
8
+ *
9
+ * Import the module in AppModule:
10
+ * ```typescript
11
+ * import { StorageModule } from '@shared/subsystems/storage';
12
+ * ```
13
+ */
14
+ export type { IStorageService } from './storage.protocol';
15
+ export { LocalStorageBackend } from './storage.local-backend';
16
+ export { MemoryStorageBackend } from './storage.memory-backend';
17
+ export { StorageModule, type StorageModuleOptions } from './storage.module';
18
+ export { STORAGE } from './storage.tokens';
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Storage subsystem — local filesystem backend
3
+ *
4
+ * Writes files to `{basePath}/{key}` on the local filesystem.
5
+ * Suitable for development only — use an S3/GCS backend in production.
6
+ *
7
+ * - Creates intermediate directories automatically (mkdirSync recursive)
8
+ * - getUrl returns a `file://` URI pointing to the absolute path
9
+ * - All methods throw on failure
10
+ * - resolvePath validates against path traversal attacks
11
+ */
12
+ import {
13
+ createReadStream,
14
+ existsSync,
15
+ mkdirSync,
16
+ readdirSync,
17
+ readFileSync,
18
+ statSync,
19
+ unlinkSync,
20
+ writeFileSync,
21
+ } from 'fs';
22
+ import { dirname, join, relative, resolve, sep } from 'path';
23
+ import { Readable } from 'stream';
24
+ import type { IStorageService } from './storage.protocol';
25
+ import { toBuffer } from './storage.utils';
26
+
27
+ export class LocalStorageBackend implements IStorageService {
28
+ private readonly basePath: string;
29
+
30
+ constructor(basePath: string = './storage') {
31
+ this.basePath = resolve(basePath);
32
+ }
33
+
34
+ async upload(key: string, data: Buffer | ReadableStream, contentType?: string): Promise<string> {
35
+ const filePath = this.resolvePath(key);
36
+ mkdirSync(dirname(filePath), { recursive: true });
37
+
38
+ const buffer = await toBuffer(data);
39
+ writeFileSync(filePath, buffer);
40
+ return key;
41
+ }
42
+
43
+ async download(key: string): Promise<Buffer> {
44
+ const filePath = this.resolvePath(key);
45
+ if (!existsSync(filePath)) {
46
+ throw new Error(`Storage: file not found: ${key}`);
47
+ }
48
+ return readFileSync(filePath);
49
+ }
50
+
51
+ async delete(key: string): Promise<void> {
52
+ const filePath = this.resolvePath(key);
53
+ if (!existsSync(filePath)) {
54
+ throw new Error(`Storage: file not found: ${key}`);
55
+ }
56
+ unlinkSync(filePath);
57
+ }
58
+
59
+ async getUrl(key: string, _expiresInSeconds?: number): Promise<string> {
60
+ const filePath = this.resolvePath(key);
61
+ if (!existsSync(filePath)) {
62
+ throw new Error(`Storage: file not found: ${key}`);
63
+ }
64
+ return `file://${filePath}`;
65
+ }
66
+
67
+ async exists(key: string): Promise<boolean> {
68
+ try {
69
+ return existsSync(this.resolvePath(key));
70
+ } catch {
71
+ // resolvePath throws on traversal attempt — treat as non-existent
72
+ return false;
73
+ }
74
+ }
75
+
76
+ async list(prefix?: string): Promise<string[]> {
77
+ const keys = this.listRecursive(this.basePath);
78
+ if (prefix === undefined) return keys;
79
+ return keys.filter((k) => k.startsWith(prefix));
80
+ }
81
+
82
+ async downloadStream(key: string): Promise<ReadableStream> {
83
+ const filePath = this.resolvePath(key);
84
+ if (!existsSync(filePath)) {
85
+ throw new Error(`Storage: file not found: ${key}`);
86
+ }
87
+ const nodeStream = createReadStream(filePath);
88
+ return Readable.toWeb(nodeStream) as ReadableStream;
89
+ }
90
+
91
+ private resolvePath(key: string): string {
92
+ const resolved = resolve(this.basePath, key);
93
+ if (!resolved.startsWith(this.basePath + sep)) {
94
+ throw new Error(`Invalid storage key (path traversal attempt): ${key}`);
95
+ }
96
+ return resolved;
97
+ }
98
+
99
+ /** Recursively list all files under dir, returning keys relative to basePath. */
100
+ private listRecursive(dir: string): string[] {
101
+ if (!existsSync(dir)) return [];
102
+ const keys: string[] = [];
103
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
104
+ const full = join(dir, entry.name);
105
+ if (entry.isDirectory()) {
106
+ keys.push(...this.listRecursive(full));
107
+ } else {
108
+ keys.push(relative(this.basePath, full));
109
+ }
110
+ }
111
+ return keys;
112
+ }
113
+ }