@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,61 @@
1
+ /**
2
+ * Sync subsystem — field-diff protocol (port)
3
+ *
4
+ * `IFieldDiffer<T>` is the pluggable differ seam. The default implementation
5
+ * (`DeepEqualDiffer`, ships in SYNC-5) walks every field except an ignore
6
+ * list; CDC-aware differs can skip comparison for fields the provider didn't
7
+ * flag as changed.
8
+ *
9
+ * `FieldDiffSchema` is the structural enforcement of the `changed_fields`
10
+ * column per ADR-0003 — enforced at write time by the recorder service so
11
+ * consumers can rely on the shape in downstream queries.
12
+ */
13
+ import { z } from 'zod';
14
+
15
+ // ============================================================================
16
+ // FieldDiff shape — the ADR-0003 contract
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Structured per-field change. Enforced shape for `sync_run_items.changed_fields`.
21
+ *
22
+ * `created` items set `from: null, to: <value>` for every non-null field.
23
+ * `deleted` items set `from: <value>, to: null`.
24
+ * `noop` items carry `{}`.
25
+ */
26
+ export const FieldDiffValueSchema = z.object({
27
+ from: z.unknown(),
28
+ to: z.unknown(),
29
+ });
30
+
31
+ export const FieldDiffSchema = z.record(z.string(), FieldDiffValueSchema);
32
+
33
+ export type FieldDiffValue = z.infer<typeof FieldDiffValueSchema>;
34
+ export type FieldDiff = z.infer<typeof FieldDiffSchema>;
35
+
36
+ /** Result of comparing a new record against its existing local state. */
37
+ export type DiffResult = FieldDiff | 'noop';
38
+
39
+ // ============================================================================
40
+ // IFieldDiffer
41
+ // ============================================================================
42
+
43
+ /**
44
+ * Pluggable differ. Default ships in SYNC-5 as `DeepEqualDiffer<T>` —
45
+ * deep-equal over every field except an ignore list (`updated_at` and other
46
+ * row metadata). CDC-aware differs restrict comparison to
47
+ * `providerChangedFields` when supplied.
48
+ */
49
+ export interface IFieldDiffer<T> {
50
+ /**
51
+ * @param existing — current local state, or `null` when the record is new
52
+ * @param incoming — the canonical record coming from the adapter
53
+ * @param providerChangedFields — optional hint from CDC-capable sources;
54
+ * when present, differ may restrict the comparison to these fields
55
+ */
56
+ diff(
57
+ existing: T | null,
58
+ incoming: T,
59
+ providerChangedFields?: string[],
60
+ ): DiffResult;
61
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Sync subsystem — loopback-fingerprint protocol (port)
3
+ *
4
+ * Optional port. When the local system writes to an upstream provider via an
5
+ * outbound path, the same change typically echoes back on the next inbound
6
+ * poll/CDC/webhook. A fingerprint store lets `ExecuteSyncUseCase` skip
7
+ * records it already wrote, avoiding a diff-noop round trip and a spurious
8
+ * audit row.
9
+ *
10
+ * The contract is deliberately narrow: one method, one decision
11
+ * ("is this change an echo of our own recent write?"). Provider-specific
12
+ * fingerprinting (hash a canonical payload, TTL shorter than the poll
13
+ * interval, etc.) lives in the concrete backend. The subsystem does not ship
14
+ * a backend in Phase 1; consumers that need loopback suppression provide
15
+ * their own (redis-hashed, memory TTL, etc.).
16
+ *
17
+ * `entityType` is `string` (not a union) — per dealbrain-v2's HS-9 findings
18
+ * the CRM-specific narrowing `'opportunity' | 'account' | 'contact'` bled
19
+ * into the port and had to be removed. Consumers narrow internally if they
20
+ * want.
21
+ */
22
+
23
+ export interface ILoopbackFingerprintStore<T = unknown> {
24
+ /**
25
+ * @returns `true` when the record matches a recent local write (skip it)
26
+ * `false` when the record is external-originated (process it)
27
+ */
28
+ isEchoOfOwnWrite(
29
+ entityType: string,
30
+ externalId: string,
31
+ record: T,
32
+ ): Promise<boolean>;
33
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * DrizzleSyncRunRecorder — Drizzle-backed `ISyncRunRecorder` (SYNC-4).
3
+ *
4
+ * Generic write path only — extracted from dealbrain-v2's
5
+ * `SyncRunRecorderService`, minus CRM-specific convenience methods. Those
6
+ * stay consumer-owned; the subsystem ships the substrate.
7
+ *
8
+ * ## Responsibilities
9
+ *
10
+ * - `startRun` — INSERT sync_runs row in status='running', returns id.
11
+ * - `recordItem` — validates `changedFields` via `FieldDiffSchema.parse`
12
+ * BEFORE the INSERT; a malformed shape throws before
13
+ * any DB call fires. Enforces the ADR-0003 contract at
14
+ * the write boundary.
15
+ * - `completeRun` — UPDATE sync_runs with terminal status, counts,
16
+ * cursor_after, duration_ms, completed_at.
17
+ *
18
+ * ## Multi-tenancy
19
+ *
20
+ * When `SYNC_MULTI_TENANT` is true (SYNC-6):
21
+ * - `startRun` and `recordItem` require non-null `tenantId` on input.
22
+ * Enforcement goes through the shared `assertTenantId` helper so the
23
+ * error message shape matches the orchestrator entry point + the
24
+ * cursor-store backends.
25
+ * - `completeRun` does NOT re-check tenancy — the run id was returned
26
+ * by `startRun` which already enforced it, and run ids are uuids that
27
+ * aren't guessable cross-tenant. Matches JOB-3's pattern of trusting
28
+ * the run-id for downstream mutations.
29
+ */
30
+ import { Inject, Injectable, Optional } from '@nestjs/common';
31
+ import { eq } from 'drizzle-orm';
32
+ import type { DrizzleClient } from '../../types/drizzle';
33
+ import { DRIZZLE } from '../../constants/tokens';
34
+ import type {
35
+ CompleteRunInput,
36
+ ISyncRunRecorder,
37
+ RecordItemInput,
38
+ StartRunInput,
39
+ } from './sync-run-recorder.protocol';
40
+ import { syncRuns, syncRunItems } from './sync-audit.schema';
41
+ import { FieldDiffSchema } from './sync-field-diff.protocol';
42
+ import { SYNC_MULTI_TENANT } from './sync.tokens';
43
+ import { assertTenantId } from './sync-errors';
44
+
45
+ @Injectable()
46
+ export class DrizzleSyncRunRecorder implements ISyncRunRecorder {
47
+ private readonly multiTenant: boolean;
48
+
49
+ constructor(
50
+ @Inject(DRIZZLE) private readonly db: DrizzleClient,
51
+ @Optional() @Inject(SYNC_MULTI_TENANT) multiTenant?: boolean,
52
+ ) {
53
+ this.multiTenant = multiTenant ?? false;
54
+ }
55
+
56
+ async startRun(input: StartRunInput): Promise<{ id: string }> {
57
+ assertTenantId(input.tenantId, {
58
+ multiTenant: this.multiTenant,
59
+ operation: 'startRun',
60
+ });
61
+
62
+ const rows = await this.db
63
+ .insert(syncRuns)
64
+ .values({
65
+ subscriptionId: input.subscriptionId,
66
+ direction: input.direction,
67
+ action: input.action,
68
+ status: 'running',
69
+ cursorBefore: input.cursorBefore ?? null,
70
+ tenantId: input.tenantId ?? null,
71
+ })
72
+ .returning({ id: syncRuns.id });
73
+
74
+ const id = rows[0]?.id;
75
+ if (!id) {
76
+ // Drizzle's insert().returning() contract: at least one row is
77
+ // returned for every successful INSERT. A missing id would indicate
78
+ // a driver misbehavior; throw loudly rather than return bogus data.
79
+ throw new Error('DrizzleSyncRunRecorder: INSERT RETURNING produced no id');
80
+ }
81
+ return { id };
82
+ }
83
+
84
+ async recordItem(input: RecordItemInput): Promise<void> {
85
+ assertTenantId(input.tenantId, {
86
+ multiTenant: this.multiTenant,
87
+ operation: 'recordItem',
88
+ });
89
+
90
+ // ADR-0003 contract enforcement — reject malformed changedFields
91
+ // before the DB call fires. `parse` throws a ZodError; callers see
92
+ // the validation failure, not a DB constraint error.
93
+ FieldDiffSchema.parse(input.changedFields);
94
+
95
+ await this.db.insert(syncRunItems).values({
96
+ syncRunId: input.syncRunId,
97
+ entityType: input.entityType,
98
+ externalId: input.externalId,
99
+ localId: input.localId ?? null,
100
+ operation: input.operation,
101
+ status: input.status,
102
+ changedFields: input.changedFields,
103
+ title: input.title ?? null,
104
+ error: input.error ?? null,
105
+ tenantId: input.tenantId ?? null,
106
+ });
107
+ }
108
+
109
+ async completeRun(runId: string, input: CompleteRunInput): Promise<void> {
110
+ await this.db
111
+ .update(syncRuns)
112
+ .set({
113
+ status: input.status,
114
+ recordsFound: input.recordsFound,
115
+ recordsProcessed: input.recordsProcessed,
116
+ cursorAfter: input.cursorAfter ?? null,
117
+ durationMs: input.durationMs,
118
+ error: input.error ?? null,
119
+ completedAt: new Date(),
120
+ })
121
+ .where(eq(syncRuns.id, runId));
122
+ }
123
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * MemoryRunRecorder — in-memory backend for `ISyncRunRecorder` (SYNC-6).
3
+ *
4
+ * Test double so `SyncModule.forRoot({ backend: 'memory' })` is genuinely
5
+ * end-to-end runnable without Postgres. Mirrors the role of
6
+ * `MemoryCursorStore`: plain keyed state, `clear()` helper for
7
+ * `beforeEach` resets, public inspection surface so tests can assert on
8
+ * the recorded run + item timeline without scraping logs.
9
+ *
10
+ * Validates `changedFields` through `FieldDiffSchema.parse` on every
11
+ * `recordItem` call — same ADR-0003 contract as the Drizzle backend. An
12
+ * in-memory recorder that skipped the validation would be a silently
13
+ * weaker contract than production.
14
+ *
15
+ * `startRun` generates a uuid via `crypto.randomUUID()` (Node 19+ / Bun).
16
+ * We don't import `uuid` because the subsystem has no other use for it.
17
+ *
18
+ * ## Multi-tenancy
19
+ *
20
+ * `tenantId` is accepted (and recorded on the in-memory row so tests can
21
+ * assert it) but enforcement lives at the module boundary. The memory
22
+ * backend intentionally does not throw on missing `tenantId` — that's
23
+ * the orchestrator's job when `multiTenant=true` (SYNC-6). A permissive
24
+ * memory recorder lets tests exercise error paths where the orchestrator
25
+ * short-circuits before ever reaching the recorder.
26
+ */
27
+ import { Injectable } from '@nestjs/common';
28
+ import type {
29
+ CompleteRunInput,
30
+ ISyncRunRecorder,
31
+ RecordItemInput,
32
+ StartRunInput,
33
+ } from './sync-run-recorder.protocol';
34
+ import { FieldDiffSchema } from './sync-field-diff.protocol';
35
+
36
+ /**
37
+ * Concrete run row as held in memory. Shape mirrors the interesting
38
+ * columns on `sync_runs` so assertions read like DB queries.
39
+ */
40
+ export interface MemoryRunRecord {
41
+ id: string;
42
+ subscriptionId: string;
43
+ direction: 'inbound' | 'outbound';
44
+ action: 'poll' | 'cdc' | 'webhook' | 'manual' | 'writeback';
45
+ status: 'running' | 'success' | 'no_changes' | 'failed';
46
+ cursorBefore: unknown | null;
47
+ cursorAfter: unknown | null;
48
+ recordsFound: number;
49
+ recordsProcessed: number;
50
+ durationMs: number | null;
51
+ error: string | null;
52
+ tenantId: string | null;
53
+ startedAt: Date;
54
+ completedAt: Date | null;
55
+ }
56
+
57
+ @Injectable()
58
+ export class MemoryRunRecorder implements ISyncRunRecorder {
59
+ /**
60
+ * All started runs keyed by id. Public so tests can inspect lifecycle
61
+ * transitions without poking through recording methods.
62
+ */
63
+ readonly runs: Map<string, MemoryRunRecord> = new Map();
64
+
65
+ /**
66
+ * Items keyed by `sync_run_id`, array order matches insertion order —
67
+ * mirrors the timeline the `(sync_run_id, created_at)` index produces
68
+ * in Postgres.
69
+ */
70
+ readonly items: Map<string, RecordItemInput[]> = new Map();
71
+
72
+ async startRun(input: StartRunInput): Promise<{ id: string }> {
73
+ const id = crypto.randomUUID();
74
+ this.runs.set(id, {
75
+ id,
76
+ subscriptionId: input.subscriptionId,
77
+ direction: input.direction,
78
+ action: input.action,
79
+ status: 'running',
80
+ cursorBefore: input.cursorBefore ?? null,
81
+ cursorAfter: null,
82
+ recordsFound: 0,
83
+ recordsProcessed: 0,
84
+ durationMs: null,
85
+ error: null,
86
+ tenantId: input.tenantId ?? null,
87
+ startedAt: new Date(),
88
+ completedAt: null,
89
+ });
90
+ this.items.set(id, []);
91
+ return { id };
92
+ }
93
+
94
+ async recordItem(input: RecordItemInput): Promise<void> {
95
+ // Same ADR-0003 contract as the Drizzle backend.
96
+ FieldDiffSchema.parse(input.changedFields);
97
+
98
+ const bucket = this.items.get(input.syncRunId);
99
+ if (!bucket) {
100
+ throw new Error(
101
+ `MemoryRunRecorder.recordItem: no run started for id '${input.syncRunId}'. ` +
102
+ `Call startRun(...) first.`,
103
+ );
104
+ }
105
+ bucket.push(input);
106
+ }
107
+
108
+ async completeRun(runId: string, input: CompleteRunInput): Promise<void> {
109
+ const run = this.runs.get(runId);
110
+ if (!run) {
111
+ throw new Error(
112
+ `MemoryRunRecorder.completeRun: no run started for id '${runId}'.`,
113
+ );
114
+ }
115
+ run.status = input.status;
116
+ run.recordsFound = input.recordsFound;
117
+ run.recordsProcessed = input.recordsProcessed;
118
+ run.cursorAfter = input.cursorAfter ?? null;
119
+ run.durationMs = input.durationMs;
120
+ run.error = input.error ?? null;
121
+ run.completedAt = new Date();
122
+ }
123
+
124
+ /** Reset state. Tests call this in `beforeEach`. */
125
+ clear(): void {
126
+ this.runs.clear();
127
+ this.items.clear();
128
+ }
129
+
130
+ // ─── test ergonomics ─────────────────────────────────────────────────
131
+
132
+ /** All runs for a subscription, newest first. Timeline reads. */
133
+ getRunsForSubscription(subscriptionId: string): MemoryRunRecord[] {
134
+ return Array.from(this.runs.values())
135
+ .filter((r) => r.subscriptionId === subscriptionId)
136
+ .sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime());
137
+ }
138
+
139
+ /** All item rows for a run, insertion-ordered. */
140
+ getItemsForRun(runId: string): RecordItemInput[] {
141
+ return this.items.get(runId) ?? [];
142
+ }
143
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Sync subsystem — run-recorder protocol (port)
3
+ *
4
+ * `ISyncRunRecorder` is the write side of the audit log. `ExecuteSyncUseCase`
5
+ * (SYNC-5) calls `startRun` at the top of the loop, `recordItem` for each
6
+ * processed change, and `completeRun` in a `finally` block so a run always
7
+ * reaches a terminal status.
8
+ *
9
+ * The Drizzle backend (SYNC-4) persists against `sync_runs` / `sync_run_items`
10
+ * from the SYNC-1 schema. Tests use lightweight in-memory fakes — no
11
+ * dedicated memory backend ships; the surface is small enough that inline
12
+ * fakes keep the intent local to each spec.
13
+ *
14
+ * `changed_fields` on `recordItem` is validated by the implementation via
15
+ * `FieldDiffSchema.parse` (ADR-0003 contract). Orchestrator callers pass the
16
+ * `DiffResult` the differ returned — `'noop'` is translated to `{}` by the
17
+ * orchestrator before reaching the recorder, so the recorder's input is
18
+ * always a `FieldDiff`.
19
+ */
20
+ import type { FieldDiff } from './sync-field-diff.protocol';
21
+
22
+ // ============================================================================
23
+ // Lifecycle — inputs
24
+ // ============================================================================
25
+
26
+ /** Args for `startRun`. Mirrors the non-nullable columns on `sync_runs`. */
27
+ export interface StartRunInput {
28
+ readonly subscriptionId: string;
29
+ readonly direction: 'inbound' | 'outbound';
30
+ readonly action: 'poll' | 'cdc' | 'webhook' | 'manual' | 'writeback';
31
+ /** Cursor snapshot at run start, or `null` if this is the first run. */
32
+ readonly cursorBefore: unknown | null;
33
+ /**
34
+ * Tenant id when `SYNC_MULTI_TENANT` is enabled. The recorder's own
35
+ * boundary rule (SYNC-6) enforces non-null when the flag is on;
36
+ * orchestrator passes it through from `ExecuteSyncInput.tenantId`.
37
+ */
38
+ readonly tenantId?: string | null;
39
+ }
40
+
41
+ /** Args for `recordItem`. Mirrors the non-nullable columns on `sync_run_items`. */
42
+ export interface RecordItemInput {
43
+ readonly syncRunId: string;
44
+ readonly entityType: string;
45
+ readonly externalId: string;
46
+ readonly localId?: string | null;
47
+ readonly operation: 'created' | 'updated' | 'deleted' | 'noop';
48
+ readonly status: 'success' | 'failed' | 'skipped';
49
+ /**
50
+ * Structured per-field diff — ADR-0003. `{}` for noop / skipped items.
51
+ * The recorder validates this against `FieldDiffSchema` on every write.
52
+ */
53
+ readonly changedFields: FieldDiff;
54
+ readonly title?: string | null;
55
+ readonly error?: string | null;
56
+ readonly tenantId?: string | null;
57
+ }
58
+
59
+ /** Args for `completeRun`. */
60
+ export interface CompleteRunInput {
61
+ readonly status: 'success' | 'no_changes' | 'failed';
62
+ readonly recordsFound: number;
63
+ readonly recordsProcessed: number;
64
+ readonly cursorAfter: unknown | null;
65
+ readonly durationMs: number;
66
+ readonly error?: string | null;
67
+ }
68
+
69
+ // ============================================================================
70
+ // ISyncRunRecorder
71
+ // ============================================================================
72
+
73
+ export interface ISyncRunRecorder {
74
+ /** Opens a new `sync_runs` row in `status = 'running'`. Returns the run id. */
75
+ startRun(input: StartRunInput): Promise<{ id: string }>;
76
+
77
+ /** Appends one `sync_run_items` row. Throws if `changedFields` is malformed. */
78
+ recordItem(input: RecordItemInput): Promise<void>;
79
+
80
+ /**
81
+ * Finalizes the run. Must be called from a `finally` block so an in-flight
82
+ * run never gets stuck in `'running'` state; the orchestrator passes
83
+ * `'failed'` when the iteration body threw.
84
+ */
85
+ completeRun(runId: string, input: CompleteRunInput): Promise<void>;
86
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Sync subsystem — sync-sink protocol (port)
3
+ *
4
+ * Write surface for the generic orchestrator. One per canonical entity type.
5
+ *
6
+ * **Shape contract:** the sink speaks the *canonical* `TCanonical` externally
7
+ * — `findByExternalId` returns a canonical-shaped view of local state
8
+ * (columns projected to canonical field names) so the differ compares
9
+ * like-for-like against `Change.record` from the adapter. Internal DB
10
+ * mapping (canonical → local write, EAV dual-write, FK resolution) stays
11
+ * inside the sink implementation.
12
+ *
13
+ * Implementations compose the entity's service + (when the entity has EAV)
14
+ * `FieldValueService` inside a single transaction. ADR-13-revised.
15
+ */
16
+ export interface ISyncSink<TCanonical> {
17
+ /**
18
+ * Canonical-shaped view of local state, or `null` when no local row exists.
19
+ * Called once per change to source the diff's "before" side.
20
+ */
21
+ findByExternalId(
22
+ userId: string,
23
+ externalId: string,
24
+ ): Promise<TCanonical | null>;
25
+
26
+ /**
27
+ * Insert-or-update by `external_id`. Must:
28
+ * - run EAV dual-write in a single transaction when the entity has `fields`
29
+ * - resolve FK references (e.g. `account_id` from `accountExternalId`)
30
+ * via a repository lookup
31
+ * - stamp `user_id` and `provider` from caller / context
32
+ * - tolerate re-entry (same record twice in a window = no-op)
33
+ *
34
+ * Returns the local row id and the canonical projection of the saved row
35
+ * (so the orchestrator can record it on `sync_run_items.local_id`).
36
+ *
37
+ * `provider` is the adapter domain string (e.g. `'salesforce-crm'`,
38
+ * `'hubspot-crm'`) persisted on the DB row. Passed from
39
+ * `ExecuteSyncInput.provider`.
40
+ */
41
+ upsertByExternalId(
42
+ userId: string,
43
+ record: TCanonical,
44
+ provider: string,
45
+ ): Promise<{ id: string; saved: TCanonical }>;
46
+
47
+ /**
48
+ * Soft-delete by `external_id`. Called when `Change.operation === 'deleted'`.
49
+ * Returns `null` when no local row exists (orchestrator records a no-op).
50
+ */
51
+ softDeleteByExternalId(
52
+ userId: string,
53
+ externalId: string,
54
+ ): Promise<{ id: string } | null>;
55
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * SyncModule — `DynamicModule.forRoot({ backend, multiTenant? })` factory
3
+ * wiring the sync subsystem's substrate (SYNC-6, ADR-008 subsystem pattern).
4
+ *
5
+ * ## What this module provides
6
+ *
7
+ * - `SYNC_CURSOR_STORE` — Drizzle or Memory cursor store
8
+ * - `SYNC_RUN_RECORDER` — Drizzle or Memory run recorder
9
+ * - `SYNC_FIELD_DIFFER` — default `DeepEqualDiffer`
10
+ * - `SYNC_MULTI_TENANT` — resolved boolean flag (defaults to false)
11
+ * - `SYNC_MODULE_OPTIONS` — the options object itself, for backends
12
+ * that need to inspect config at construction time
13
+ *
14
+ * ## What this module does NOT provide
15
+ *
16
+ * - `SYNC_CHANGE_SOURCE` — per-provider per-entity; consumer binds in
17
+ * their feature module (e.g. `OpportunitySyncModule` provides a
18
+ * `SalesforceOpportunityChangeSource`).
19
+ * - `SYNC_SINK` — per canonical entity; consumer binds in their feature
20
+ * module.
21
+ * - `SYNC_LOOPBACK_FINGERPRINT_STORE` — optional; consumer provides
22
+ * only when they have outbound writeback paths. `@Optional()` at the
23
+ * orchestrator seam means absence is fine.
24
+ * - `ExecuteSyncUseCase` — registered by the feature module alongside
25
+ * its source + sink bindings. Providing the orchestrator here would
26
+ * force Nest to resolve SYNC_CHANGE_SOURCE + SYNC_SINK at module
27
+ * compile time, which fails when the feature module hasn't been
28
+ * imported yet. Consumers register `ExecuteSyncUseCase` in the same
29
+ * `providers` array as their source + sink so resolution is local
30
+ * to where all three are bound.
31
+ *
32
+ * Same shape as `EventsModule.forRoot` — the module wires the bus; you
33
+ * bring your own handlers. Here: the module wires the substrate; you
34
+ * bring your own source + sink.
35
+ *
36
+ * ## Usage
37
+ *
38
+ * ```ts
39
+ * // AppModule — single source of truth for backend + multi-tenancy.
40
+ * @Module({
41
+ * imports: [SyncModule.forRoot({ backend: 'drizzle' })],
42
+ * })
43
+ * export class AppModule {}
44
+ *
45
+ * // Per-entity feature module — binds source + sink, gets the
46
+ * // orchestrator for free.
47
+ * @Module({
48
+ * providers: [
49
+ * { provide: SYNC_CHANGE_SOURCE, useClass: SalesforceOpportunitySource },
50
+ * { provide: SYNC_SINK, useClass: OpportunitySyncSink },
51
+ * ExecuteSyncUseCase,
52
+ * ],
53
+ * })
54
+ * export class OpportunitySyncModule {
55
+ * constructor(
56
+ * private readonly execute: ExecuteSyncUseCase<CanonicalOpportunity>,
57
+ * ) {}
58
+ * }
59
+ * ```
60
+ *
61
+ * `global: true` means feature modules do not need to re-import
62
+ * `SyncModule` — the substrate tokens are available project-wide.
63
+ */
64
+ import { Module, type DynamicModule, type Provider } from '@nestjs/common';
65
+ import {
66
+ SYNC_CURSOR_STORE,
67
+ SYNC_FIELD_DIFFER,
68
+ SYNC_MODULE_OPTIONS,
69
+ SYNC_MULTI_TENANT,
70
+ SYNC_RUN_RECORDER,
71
+ } from './sync.tokens';
72
+ import { MemoryCursorStore } from './sync-cursor-store.memory-backend';
73
+ import { MemoryRunRecorder } from './sync-run-recorder.memory-backend';
74
+ import { PostgresCursorStore } from './sync-cursor-store.drizzle-backend';
75
+ import { DrizzleSyncRunRecorder } from './sync-run-recorder.drizzle-backend';
76
+ import { DeepEqualDiffer } from './deep-equal.differ';
77
+
78
+ export interface SyncModuleOptions {
79
+ /**
80
+ * Backend selection. `drizzle` wires the Postgres cursor store +
81
+ * run-log recorder; `memory` wires in-memory doubles suitable for
82
+ * tests + local dev.
83
+ */
84
+ backend: 'drizzle' | 'memory';
85
+
86
+ /**
87
+ * Multi-tenancy opt-in (SYNC-6).
88
+ *
89
+ * When `true`, every call to the orchestrator + both Drizzle backends
90
+ * must supply a non-null `tenantId`; missing values throw
91
+ * `MissingTenantIdError`. Defense-in-depth: the orchestrator rejects
92
+ * at entry (no dangling `status=running` rows) AND the Drizzle
93
+ * backends reject at their write boundary (belt-and-braces for any
94
+ * path that bypasses the orchestrator). Both sites use the shared
95
+ * `assertTenantId` helper so error messages match.
96
+ *
97
+ * Memory backends accept `tenantId` unconditionally — their state is
98
+ * process-local; cross-tenant isolation there is not meaningful.
99
+ *
100
+ * Defaults to `false`.
101
+ */
102
+ multiTenant?: boolean;
103
+ }
104
+
105
+ @Module({})
106
+ export class SyncModule {
107
+ static forRoot(options: SyncModuleOptions): DynamicModule {
108
+ const multiTenant = options.multiTenant ?? false;
109
+
110
+ const sharedProviders: Provider[] = [
111
+ { provide: SYNC_MODULE_OPTIONS, useValue: options },
112
+ { provide: SYNC_MULTI_TENANT, useValue: multiTenant },
113
+ // Default differ — consumers can override by binding a different
114
+ // `IFieldDiffer<T>` to `SYNC_FIELD_DIFFER` in their feature module.
115
+ { provide: SYNC_FIELD_DIFFER, useValue: new DeepEqualDiffer() },
116
+ ];
117
+
118
+ const backendProviders: Provider[] =
119
+ options.backend === 'memory'
120
+ ? [
121
+ // Wired as singletons via `useValue` so tests can pull
122
+ // them out via `moduleRef.get(MemoryCursorStore)` for
123
+ // direct assertions. Matches JOB-4 / MemoryJobStore shape.
124
+ { provide: MemoryCursorStore, useValue: new MemoryCursorStore() },
125
+ {
126
+ provide: SYNC_CURSOR_STORE,
127
+ useExisting: MemoryCursorStore,
128
+ },
129
+ { provide: MemoryRunRecorder, useValue: new MemoryRunRecorder() },
130
+ {
131
+ provide: SYNC_RUN_RECORDER,
132
+ useExisting: MemoryRunRecorder,
133
+ },
134
+ ]
135
+ : [
136
+ // Drizzle backends — injected with DRIZZLE (provided by the
137
+ // consumer's DrizzleModule) + the SYNC_MULTI_TENANT flag
138
+ // we bound above.
139
+ { provide: SYNC_CURSOR_STORE, useClass: PostgresCursorStore },
140
+ { provide: SYNC_RUN_RECORDER, useClass: DrizzleSyncRunRecorder },
141
+ ];
142
+
143
+ return {
144
+ module: SyncModule,
145
+ global: true,
146
+ providers: [...sharedProviders, ...backendProviders],
147
+ exports: [
148
+ SYNC_MODULE_OPTIONS,
149
+ SYNC_MULTI_TENANT,
150
+ SYNC_FIELD_DIFFER,
151
+ SYNC_CURSOR_STORE,
152
+ SYNC_RUN_RECORDER,
153
+ ],
154
+ };
155
+ }
156
+ }