@pattern-stack/codegen 0.4.1 → 0.4.3

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 (158) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/runtime/subsystems/bridge/bridge.module.d.ts +1 -0
  3. package/dist/runtime/subsystems/bridge/bridge.module.js +38 -21
  4. package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
  5. package/dist/runtime/subsystems/bridge/index.d.ts +1 -0
  6. package/dist/runtime/subsystems/bridge/index.js +29 -12
  7. package/dist/runtime/subsystems/bridge/index.js.map +1 -1
  8. package/dist/runtime/subsystems/index.js +31 -14
  9. package/dist/runtime/subsystems/index.js.map +1 -1
  10. package/dist/runtime/subsystems/jobs/index.d.ts +1 -0
  11. package/dist/runtime/subsystems/jobs/index.js +27 -10
  12. package/dist/runtime/subsystems/jobs/index.js.map +1 -1
  13. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.d.ts +3 -1
  14. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +9 -4
  15. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js.map +1 -1
  16. package/dist/runtime/subsystems/jobs/job-worker.d.ts +3 -1
  17. package/dist/runtime/subsystems/jobs/job-worker.js +6 -2
  18. package/dist/runtime/subsystems/jobs/job-worker.js.map +1 -1
  19. package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +3 -1
  20. package/dist/runtime/subsystems/jobs/job-worker.module.js +27 -10
  21. package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
  22. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +9 -4
  23. package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
  24. package/dist/src/cli/index.js +29 -2
  25. package/dist/src/cli/index.js.map +1 -1
  26. package/package.json +2 -1
  27. package/runtime/analytics/index.ts +31 -0
  28. package/runtime/analytics/metrics.ts +85 -0
  29. package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
  30. package/runtime/analytics/packs/index.ts +5 -0
  31. package/runtime/analytics/packs/monetary-measures.ts +20 -0
  32. package/runtime/analytics/specs.ts +54 -0
  33. package/runtime/analytics/types.ts +105 -0
  34. package/runtime/base-classes/activity-entity-repository.ts +50 -0
  35. package/runtime/base-classes/activity-entity-service.ts +48 -0
  36. package/runtime/base-classes/base-read-use-cases.ts +88 -0
  37. package/runtime/base-classes/base-repository.ts +289 -0
  38. package/runtime/base-classes/base-service.ts +183 -0
  39. package/runtime/base-classes/index.ts +38 -0
  40. package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
  41. package/runtime/base-classes/knowledge-entity-service.ts +14 -0
  42. package/runtime/base-classes/lifecycle-events.ts +152 -0
  43. package/runtime/base-classes/metadata-entity-repository.ts +80 -0
  44. package/runtime/base-classes/metadata-entity-service.ts +48 -0
  45. package/runtime/base-classes/synced-entity-repository.ts +57 -0
  46. package/runtime/base-classes/synced-entity-service.ts +50 -0
  47. package/runtime/base-classes/with-analytics.ts +22 -0
  48. package/runtime/constants/tokens.ts +29 -0
  49. package/runtime/eav-helpers.ts +74 -0
  50. package/runtime/pipes/zod-validation.pipe.ts +64 -0
  51. package/runtime/shared/openapi/error-response.dto.ts +24 -0
  52. package/runtime/shared/openapi/errors.ts +39 -0
  53. package/runtime/shared/openapi/index.ts +20 -0
  54. package/runtime/shared/openapi/registry.tokens.ts +13 -0
  55. package/runtime/shared/openapi/registry.ts +151 -0
  56. package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
  57. package/runtime/subsystems/analytics/analytics.module.ts +64 -0
  58. package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
  59. package/runtime/subsystems/analytics/cube-backend.ts +75 -0
  60. package/runtime/subsystems/analytics/index.ts +15 -0
  61. package/runtime/subsystems/analytics/noop-backend.ts +27 -0
  62. package/runtime/subsystems/auth/auth.module.ts +91 -0
  63. package/runtime/subsystems/auth/auth.tokens.ts +27 -0
  64. package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
  65. package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
  66. package/runtime/subsystems/auth/index.ts +77 -0
  67. package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
  68. package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
  69. package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
  70. package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
  71. package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
  72. package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
  73. package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
  74. package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
  75. package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
  76. package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
  77. package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
  78. package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
  79. package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
  80. package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
  81. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
  82. package/runtime/subsystems/bridge/bridge.module.ts +160 -0
  83. package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
  84. package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
  85. package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
  86. package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
  87. package/runtime/subsystems/bridge/generated/registry.ts +6 -0
  88. package/runtime/subsystems/bridge/index.ts +84 -0
  89. package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
  90. package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
  91. package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
  92. package/runtime/subsystems/cache/cache.module.ts +115 -0
  93. package/runtime/subsystems/cache/cache.protocol.ts +45 -0
  94. package/runtime/subsystems/cache/cache.schema.ts +27 -0
  95. package/runtime/subsystems/cache/cache.tokens.ts +17 -0
  96. package/runtime/subsystems/cache/index.ts +22 -0
  97. package/runtime/subsystems/events/domain-events.schema.ts +77 -0
  98. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
  99. package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
  100. package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
  101. package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
  102. package/runtime/subsystems/events/events-errors.ts +30 -0
  103. package/runtime/subsystems/events/events.module.ts +230 -0
  104. package/runtime/subsystems/events/events.tokens.ts +62 -0
  105. package/runtime/subsystems/events/generated/bus.ts +103 -0
  106. package/runtime/subsystems/events/generated/index.ts +7 -0
  107. package/runtime/subsystems/events/generated/registry.ts +84 -0
  108. package/runtime/subsystems/events/generated/schemas.ts +59 -0
  109. package/runtime/subsystems/events/generated/types.ts +94 -0
  110. package/runtime/subsystems/events/index.ts +21 -0
  111. package/runtime/subsystems/index.ts +63 -0
  112. package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
  113. package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
  114. package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
  115. package/runtime/subsystems/jobs/index.ts +120 -0
  116. package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
  117. package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
  118. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
  119. package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +860 -0
  120. package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
  121. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
  122. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
  123. package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
  124. package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
  125. package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
  126. package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
  127. package/runtime/subsystems/jobs/job-worker.module.ts +312 -0
  128. package/runtime/subsystems/jobs/job-worker.ts +624 -0
  129. package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
  130. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
  131. package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
  132. package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
  133. package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
  134. package/runtime/subsystems/storage/index.ts +18 -0
  135. package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
  136. package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
  137. package/runtime/subsystems/storage/storage.module.ts +60 -0
  138. package/runtime/subsystems/storage/storage.protocol.ts +78 -0
  139. package/runtime/subsystems/storage/storage.tokens.ts +9 -0
  140. package/runtime/subsystems/storage/storage.utils.ts +20 -0
  141. package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
  142. package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
  143. package/runtime/subsystems/sync/index.ts +98 -0
  144. package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
  145. package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
  146. package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
  147. package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
  148. package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
  149. package/runtime/subsystems/sync/sync-errors.ts +54 -0
  150. package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
  151. package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
  152. package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
  153. package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
  154. package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
  155. package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
  156. package/runtime/subsystems/sync/sync.module.ts +156 -0
  157. package/runtime/subsystems/sync/sync.tokens.ts +57 -0
  158. package/runtime/types/drizzle.ts +23 -0
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Storage subsystem — in-memory backend
3
+ *
4
+ * Stores files as Buffers in a Map. Intended for unit tests only.
5
+ * All state is lost when the process exits.
6
+ *
7
+ * - getUrl returns `memory://{key}` (not a real URL, useful for assertions)
8
+ * - All methods throw on failure (missing keys, etc.)
9
+ */
10
+ import type { IStorageService } from './storage.protocol';
11
+ import { toBuffer } from './storage.utils';
12
+
13
+ interface MemoryEntry {
14
+ data: Buffer;
15
+ contentType?: string;
16
+ }
17
+
18
+ export class MemoryStorageBackend implements IStorageService {
19
+ private readonly store = new Map<string, MemoryEntry>();
20
+
21
+ async upload(key: string, data: Buffer | ReadableStream, contentType?: string): Promise<string> {
22
+ const buffer = await toBuffer(data);
23
+ this.store.set(key, { data: buffer, contentType });
24
+ return key;
25
+ }
26
+
27
+ async download(key: string): Promise<Buffer> {
28
+ const entry = this.store.get(key);
29
+ if (!entry) {
30
+ throw new Error(`Storage: file not found: ${key}`);
31
+ }
32
+ return entry.data;
33
+ }
34
+
35
+ async delete(key: string): Promise<void> {
36
+ if (!this.store.has(key)) {
37
+ throw new Error(`Storage: file not found: ${key}`);
38
+ }
39
+ this.store.delete(key);
40
+ }
41
+
42
+ async getUrl(key: string, _expiresInSeconds?: number): Promise<string> {
43
+ if (!this.store.has(key)) {
44
+ throw new Error(`Storage: file not found: ${key}`);
45
+ }
46
+ return `memory://${key}`;
47
+ }
48
+
49
+ async exists(key: string): Promise<boolean> {
50
+ return this.store.has(key);
51
+ }
52
+
53
+ async list(prefix?: string): Promise<string[]> {
54
+ const keys = Array.from(this.store.keys());
55
+ if (prefix === undefined) return keys;
56
+ return keys.filter((k) => k.startsWith(prefix));
57
+ }
58
+
59
+ async downloadStream(key: string): Promise<ReadableStream> {
60
+ const buffer = await this.download(key);
61
+ return new ReadableStream<Uint8Array>({
62
+ start(controller) {
63
+ controller.enqueue(new Uint8Array(buffer));
64
+ controller.close();
65
+ },
66
+ });
67
+ }
68
+
69
+ /** Clear all stored files. Useful for test teardown. */
70
+ clear(): void {
71
+ this.store.clear();
72
+ }
73
+
74
+ /** Return number of stored files. Useful for test assertions. */
75
+ size(): number {
76
+ return this.store.size;
77
+ }
78
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Storage subsystem — NestJS module factory
3
+ *
4
+ * Register once in AppModule (global: true means all other modules can inject
5
+ * STORAGE without importing StorageModule themselves):
6
+ *
7
+ * ```typescript
8
+ * // app.module.ts
9
+ * @Module({
10
+ * imports: [
11
+ * StorageModule.forRoot({ backend: 'local', basePath: './uploads' }),
12
+ * ],
13
+ * })
14
+ * export class AppModule {}
15
+ * ```
16
+ *
17
+ * Swap to memory backend in tests:
18
+ * ```typescript
19
+ * Test.createTestingModule({
20
+ * imports: [StorageModule.forRoot({ backend: 'memory' })],
21
+ * });
22
+ * ```
23
+ */
24
+ import { type DynamicModule, Module } from '@nestjs/common';
25
+ import { LocalStorageBackend } from './storage.local-backend';
26
+ import { MemoryStorageBackend } from './storage.memory-backend';
27
+ import { STORAGE } from './storage.tokens';
28
+
29
+ export interface StorageModuleOptions {
30
+ /** Which backend to activate. */
31
+ backend: 'local' | 'memory';
32
+ /**
33
+ * Base path for the local backend (resolved to an absolute path).
34
+ * Ignored when backend is 'memory'. Defaults to `./storage`.
35
+ */
36
+ basePath?: string;
37
+ }
38
+
39
+ @Module({})
40
+ export class StorageModule {
41
+ static forRoot(options: StorageModuleOptions = { backend: 'local' }): DynamicModule {
42
+ const provider =
43
+ options.backend === 'local'
44
+ ? {
45
+ provide: STORAGE,
46
+ useFactory: () => new LocalStorageBackend(options.basePath ?? './storage'),
47
+ }
48
+ : {
49
+ provide: STORAGE,
50
+ useClass: MemoryStorageBackend,
51
+ };
52
+
53
+ return {
54
+ module: StorageModule,
55
+ global: true,
56
+ providers: [provider],
57
+ exports: [STORAGE],
58
+ };
59
+ }
60
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Storage subsystem — protocol (port)
3
+ *
4
+ * IStorageService is the hexagonal port. Use cases inject this interface via
5
+ * STORAGE token. They never depend on a specific backend implementation.
6
+ *
7
+ * All methods throw on failure — callers must handle upload/download errors
8
+ * explicitly. There is no null-return behavior like CacheService.
9
+ *
10
+ * Users who need cloud storage (S3, GCS, R2) implement this interface
11
+ * directly. No Drizzle backend exists — files in Postgres is an antipattern.
12
+ */
13
+
14
+ // ============================================================================
15
+ // IStorageService
16
+ // ============================================================================
17
+
18
+ export interface IStorageService {
19
+ /**
20
+ * Upload a file and return the stored key (same as the input key).
21
+ *
22
+ * @param key - Storage key / path (e.g. 'avatars/user-123.png')
23
+ * @param data - File contents as a Buffer or a ReadableStream
24
+ * @param contentType - Optional MIME type (e.g. 'image/png')
25
+ * @returns The stored key
26
+ * @throws On any write failure
27
+ */
28
+ upload(key: string, data: Buffer | ReadableStream, contentType?: string): Promise<string>;
29
+
30
+ /**
31
+ * Download a file by key and return its contents as a Buffer.
32
+ *
33
+ * @throws If the file does not exist or cannot be read
34
+ */
35
+ download(key: string): Promise<Buffer>;
36
+
37
+ /**
38
+ * Delete a file by key.
39
+ *
40
+ * @throws If the file does not exist or cannot be deleted
41
+ */
42
+ delete(key: string): Promise<void>;
43
+
44
+ /**
45
+ * Return a URL for accessing the file.
46
+ *
47
+ * For local backend: returns a `file://` URI.
48
+ * For cloud backends: returns a presigned URL that expires after `expiresInSeconds`.
49
+ *
50
+ * @param key - Storage key
51
+ * @param expiresInSeconds - URL expiry (ignored by local/memory backends)
52
+ * @throws If the file does not exist
53
+ */
54
+ getUrl(key: string, expiresInSeconds?: number): Promise<string>;
55
+
56
+ /**
57
+ * Check whether a file exists at the given key.
58
+ *
59
+ * @returns `true` if the file exists, `false` otherwise
60
+ * @throws Only on unexpected I/O errors (not on simple absence)
61
+ */
62
+ exists(key: string): Promise<boolean>;
63
+
64
+ /**
65
+ * List all stored keys, optionally filtered by prefix.
66
+ *
67
+ * @param prefix - Optional prefix filter (e.g. 'avatars/')
68
+ * @returns Array of keys matching the prefix (or all keys if omitted)
69
+ */
70
+ list(prefix?: string): Promise<string[]>;
71
+
72
+ /**
73
+ * Download a file by key and return its contents as a Web ReadableStream.
74
+ *
75
+ * @throws If the file does not exist or cannot be read
76
+ */
77
+ downloadStream(key: string): Promise<ReadableStream>;
78
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Injection token for the storage service.
3
+ *
4
+ * Usage in use cases:
5
+ * ```typescript
6
+ * constructor(@Inject(STORAGE) private readonly storage: IStorageService) {}
7
+ * ```
8
+ */
9
+ export const STORAGE = Symbol('STORAGE');
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Storage subsystem — shared utilities
3
+ */
4
+
5
+ /**
6
+ * Convert a Buffer or Web ReadableStream to a Node.js Buffer.
7
+ */
8
+ export async function toBuffer(data: Buffer | ReadableStream): Promise<Buffer> {
9
+ if (Buffer.isBuffer(data)) {
10
+ return data;
11
+ }
12
+ const reader = (data as ReadableStream<Uint8Array>).getReader();
13
+ const chunks: Uint8Array[] = [];
14
+ while (true) {
15
+ const { done, value } = await reader.read();
16
+ if (done) break;
17
+ if (value) chunks.push(value);
18
+ }
19
+ return Buffer.concat(chunks);
20
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * DeepEqualDiffer — default `IFieldDiffer<T>` for the sync subsystem (SYNC-5).
3
+ *
4
+ * Walks every field of `incoming` against `existing`, emitting a structured
5
+ * per-field diff (`{ from, to }`) for every field whose value changed.
6
+ * Returns `'noop'` when the record is unchanged.
7
+ *
8
+ * Design decisions (extracted from dealbrain-v2 + HS-9 findings):
9
+ *
10
+ * 1. **Ignore list** — row metadata that sinks/services stamp unconditionally
11
+ * so upstream cannot reasonably disagree:
12
+ * `id`, `createdAt`, `updatedAt`, `deletedAt`, `type`,
13
+ * `lastModifiedAt`, `fields`, `providerMetadata`
14
+ * (`fields` is the EAV bag — it's diffed by the sink's EAV dual-write
15
+ * path, not at the canonical-record layer.)
16
+ *
17
+ * 2. **`providerChangedFields` hint (CDC)** — when present, restricts the
18
+ * comparison to the hinted field set. The hint is advisory; fields in
19
+ * the ignore list are still filtered out even when hinted. Provider
20
+ * hints are field-NAME-level; they don't override the ignore rules.
21
+ *
22
+ * 3. **Date → ISO string** — `Date` instances are normalized to
23
+ * `toISOString()` before comparison. Sinks return `Date` from the DB
24
+ * driver; adapters typically deliver strings. Direct `===` would
25
+ * always say "changed."
26
+ *
27
+ * 4. **Decimal-string vs number** — Postgres `numeric` columns return as
28
+ * strings through Drizzle; adapters deliver numbers. When one side is a
29
+ * number and the other is a numeric string that parses to the same
30
+ * number, they're equal. The normalizer does NOT coerce non-numeric
31
+ * strings, and it preserves zero-vs-null distinction.
32
+ *
33
+ * 5. **null-existing path** — `diff(null, incoming)` produces a full
34
+ * created-shape diff (`{from: null, to: <value>}` for every non-ignored
35
+ * field). Orchestrator sees this and records `operation: 'created'`.
36
+ */
37
+ import { Injectable } from '@nestjs/common';
38
+ import type {
39
+ DiffResult,
40
+ FieldDiff,
41
+ IFieldDiffer,
42
+ } from './sync-field-diff.protocol';
43
+
44
+ /**
45
+ * Default ignore list. Keep in sync with consumer canonical-record shapes —
46
+ * adding a row-metadata field here means no sync will ever mark it changed.
47
+ */
48
+ const DEFAULT_IGNORE_FIELDS: ReadonlySet<string> = new Set([
49
+ 'id',
50
+ 'createdAt',
51
+ 'updatedAt',
52
+ 'deletedAt',
53
+ 'type',
54
+ 'lastModifiedAt',
55
+ 'fields',
56
+ 'providerMetadata',
57
+ ]);
58
+
59
+ export interface DeepEqualDifferOptions {
60
+ /**
61
+ * Extra field names to ignore in addition to the defaults. Consumers can
62
+ * pass `['sync_version']` etc. to augment the base list; values here are
63
+ * merged (not replaced) with `DEFAULT_IGNORE_FIELDS`.
64
+ */
65
+ readonly ignore?: readonly string[];
66
+ }
67
+
68
+ @Injectable()
69
+ export class DeepEqualDiffer<T extends Record<string, unknown>>
70
+ implements IFieldDiffer<T>
71
+ {
72
+ private readonly ignore: ReadonlySet<string>;
73
+
74
+ constructor(opts: DeepEqualDifferOptions = {}) {
75
+ if (opts.ignore && opts.ignore.length > 0) {
76
+ this.ignore = new Set([...DEFAULT_IGNORE_FIELDS, ...opts.ignore]);
77
+ } else {
78
+ this.ignore = DEFAULT_IGNORE_FIELDS;
79
+ }
80
+ }
81
+
82
+ diff(
83
+ existing: T | null,
84
+ incoming: T,
85
+ providerChangedFields?: string[],
86
+ ): DiffResult {
87
+ // Created-shape: every non-ignored field becomes `{from: null, to}`.
88
+ if (existing === null) {
89
+ const out: FieldDiff = {};
90
+ for (const key of Object.keys(incoming)) {
91
+ if (this.ignore.has(key)) continue;
92
+ const value = (incoming as Record<string, unknown>)[key];
93
+ // Skip fields that are themselves null/undefined — a created record
94
+ // doesn't need to declare "this field is null now" for every
95
+ // untouched column.
96
+ if (value === null || value === undefined) continue;
97
+ out[key] = { from: null, to: value };
98
+ }
99
+ return Object.keys(out).length === 0 ? 'noop' : out;
100
+ }
101
+
102
+ // Field set to compare. `providerChangedFields` narrows to a hint set;
103
+ // ignored fields are filtered out regardless of hint.
104
+ const candidates = new Set<string>();
105
+ if (providerChangedFields && providerChangedFields.length > 0) {
106
+ for (const key of providerChangedFields) {
107
+ if (!this.ignore.has(key)) candidates.add(key);
108
+ }
109
+ } else {
110
+ for (const key of Object.keys(incoming)) {
111
+ if (!this.ignore.has(key)) candidates.add(key);
112
+ }
113
+ // Also include keys that exist on existing but not on incoming —
114
+ // e.g. a field that was cleared. This would otherwise be missed when
115
+ // incoming carries an undefined column we drop from the iteration.
116
+ for (const key of Object.keys(existing)) {
117
+ if (this.ignore.has(key)) continue;
118
+ if (!(key in (incoming as Record<string, unknown>))) continue;
119
+ candidates.add(key);
120
+ }
121
+ }
122
+
123
+ const out: FieldDiff = {};
124
+ for (const key of candidates) {
125
+ const before = (existing as Record<string, unknown>)[key];
126
+ const after = (incoming as Record<string, unknown>)[key];
127
+ if (!isEqual(before, after)) {
128
+ out[key] = { from: before ?? null, to: after ?? null };
129
+ }
130
+ }
131
+
132
+ return Object.keys(out).length === 0 ? 'noop' : out;
133
+ }
134
+ }
135
+
136
+ // ─── equality helpers ───────────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Field-level equality with the canonical-sync normalizations:
140
+ * - Date → toISOString (adapters deliver strings)
141
+ * - numeric-string vs number → numeric equality when both parse
142
+ * - deep equality for plain objects/arrays (single-level is enough for
143
+ * canonical records; nested records travel as jsonb columns where the
144
+ * sink already owns the comparison)
145
+ */
146
+ function isEqual(a: unknown, b: unknown): boolean {
147
+ if (a === b) return true;
148
+
149
+ const na = normalize(a);
150
+ const nb = normalize(b);
151
+ if (na === nb) return true;
152
+
153
+ // After normalization: both may still be non-primitive objects.
154
+ if (
155
+ typeof na === 'object' &&
156
+ typeof nb === 'object' &&
157
+ na !== null &&
158
+ nb !== null
159
+ ) {
160
+ return deepEqualObject(na as Record<string, unknown>, nb as Record<string, unknown>);
161
+ }
162
+
163
+ // Numeric string ↔ number: when one side is a number and the other is a
164
+ // string that parses to the same finite number.
165
+ const numericEqual = maybeNumericEqual(na, nb) || maybeNumericEqual(nb, na);
166
+ return numericEqual;
167
+ }
168
+
169
+ function normalize(value: unknown): unknown {
170
+ if (value instanceof Date) return value.toISOString();
171
+ return value;
172
+ }
173
+
174
+ function maybeNumericEqual(a: unknown, b: unknown): boolean {
175
+ // a is string-shape, b is number — parse a and compare. Only when the
176
+ // string looks numeric AND the parse round-trips (no silent NaN pass-
177
+ // through on non-numeric strings).
178
+ if (typeof a !== 'string' || typeof b !== 'number') return false;
179
+ if (a.trim() === '') return false;
180
+ const parsed = Number(a);
181
+ if (!Number.isFinite(parsed)) return false;
182
+ return parsed === b;
183
+ }
184
+
185
+ function deepEqualObject(
186
+ a: Record<string, unknown>,
187
+ b: Record<string, unknown>,
188
+ ): boolean {
189
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
190
+ const aKeys = Object.keys(a);
191
+ const bKeys = Object.keys(b);
192
+ if (aKeys.length !== bKeys.length) return false;
193
+ for (const key of aKeys) {
194
+ if (!(key in b)) return false;
195
+ if (!isEqual(a[key], b[key])) return false;
196
+ }
197
+ return true;
198
+ }