@pattern-stack/codegen 0.16.1 → 0.17.1

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 (127) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/consumer-skills/entities/families-and-queries.md +5 -3
  3. package/consumer-skills/integration/audit-and-detection.md +29 -4
  4. package/dist/{chunk-H6FO2ZDJ.js → chunk-4PFF3ED4.js} +4 -4
  5. package/dist/{chunk-CO6LUM72.js → chunk-7P5ODGLA.js} +34 -2
  6. package/dist/chunk-7P5ODGLA.js.map +1 -0
  7. package/dist/{chunk-QSJ3J4HE.js → chunk-BHZP6LOV.js} +7 -7
  8. package/dist/{chunk-RUSUZZAF.js → chunk-BK5ICA2F.js} +4 -4
  9. package/dist/{chunk-T4YJRD22.js → chunk-DUMI2J5M.js} +45 -14
  10. package/dist/chunk-DUMI2J5M.js.map +1 -0
  11. package/dist/{chunk-TKVTEUBD.js → chunk-EJBK7I4F.js} +2 -2
  12. package/dist/{chunk-IT6FRTEW.js → chunk-FVNAU7VO.js} +39 -18
  13. package/dist/chunk-FVNAU7VO.js.map +1 -0
  14. package/dist/{chunk-JM3T27ZW.js → chunk-FWRL7BZ5.js} +7 -7
  15. package/dist/{chunk-DGYTSCKN.js → chunk-HOIRY5XP.js} +14 -14
  16. package/dist/{chunk-AYC2HEAL.js → chunk-HPS554L4.js} +9 -9
  17. package/dist/{chunk-2WDX6I7T.js → chunk-IOQMMH6C.js} +16 -6
  18. package/dist/{chunk-2WDX6I7T.js.map → chunk-IOQMMH6C.js.map} +1 -1
  19. package/dist/{chunk-24WXSC3C.js → chunk-JA7GJDNI.js} +15 -9
  20. package/dist/chunk-JA7GJDNI.js.map +1 -0
  21. package/dist/{chunk-36U5UGIO.js → chunk-JEINYUJH.js} +8 -5
  22. package/dist/chunk-JEINYUJH.js.map +1 -0
  23. package/dist/{chunk-BOPZWRJK.js → chunk-JYBFPNBJ.js} +8 -8
  24. package/dist/chunk-JYBFPNBJ.js.map +1 -0
  25. package/dist/{chunk-K2I6XIK5.js → chunk-KSTZIULO.js} +4 -4
  26. package/dist/chunk-MKWQKKK7.js +72 -0
  27. package/dist/chunk-MKWQKKK7.js.map +1 -0
  28. package/dist/{chunk-CRBVI4GE.js → chunk-PSDVGPQR.js} +5 -5
  29. package/dist/{chunk-DLG62MQY.js → chunk-SFQRETXJ.js} +7 -7
  30. package/dist/{chunk-NXNVTXKG.js → chunk-SGSWVNNB.js} +5 -5
  31. package/dist/{chunk-5LXOJGO2.js → chunk-VNBC3VXM.js} +6 -6
  32. package/dist/{job-orchestrator.protocol-DubMVbm9.d.ts → job-orchestrator.protocol-ZuJ3ow-O.d.ts} +77 -3
  33. package/dist/runtime/base-classes/activity-entity-repository.d.ts +39 -7
  34. package/dist/runtime/base-classes/activity-entity-repository.js +1 -1
  35. package/dist/runtime/base-classes/activity-entity-service.d.ts +12 -10
  36. package/dist/runtime/base-classes/activity-entity-service.js +1 -1
  37. package/dist/runtime/base-classes/index.js +18 -18
  38. package/dist/runtime/shared/openapi/index.js +3 -3
  39. package/dist/runtime/subsystems/auth/index.js +3 -3
  40. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.d.ts +1 -1
  41. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +2 -2
  42. package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +2 -2
  43. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +6 -6
  44. package/dist/runtime/subsystems/bridge/bridge.module.d.ts +1 -1
  45. package/dist/runtime/subsystems/bridge/bridge.module.js +19 -19
  46. package/dist/runtime/subsystems/bridge/event-flow.service.d.ts +1 -1
  47. package/dist/runtime/subsystems/bridge/index.d.ts +1 -1
  48. package/dist/runtime/subsystems/bridge/index.js +21 -21
  49. package/dist/runtime/subsystems/cache/cache.module.js +1 -1
  50. package/dist/runtime/subsystems/cache/index.js +3 -3
  51. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +2 -2
  52. package/dist/runtime/subsystems/events/events.module.js +3 -3
  53. package/dist/runtime/subsystems/events/index.js +3 -3
  54. package/dist/runtime/subsystems/index.d.ts +1 -1
  55. package/dist/runtime/subsystems/index.js +50 -50
  56. package/dist/runtime/subsystems/integration/deep-equal.differ.d.ts +19 -0
  57. package/dist/runtime/subsystems/integration/deep-equal.differ.js +1 -1
  58. package/dist/runtime/subsystems/integration/index.js +22 -22
  59. package/dist/runtime/subsystems/integration/integration.module.d.ts +20 -0
  60. package/dist/runtime/subsystems/integration/integration.module.js +4 -4
  61. package/dist/runtime/subsystems/jobs/index.d.ts +1 -1
  62. package/dist/runtime/subsystems/jobs/index.js +43 -43
  63. package/dist/runtime/subsystems/jobs/job-handler.base.d.ts +1 -1
  64. package/dist/runtime/subsystems/jobs/job-handler.base.js +11 -3
  65. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.d.ts +1 -1
  66. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +7 -6
  67. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -1
  68. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.d.ts +1 -1
  69. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +4 -3
  70. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.d.ts +11 -1
  71. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +3 -3
  72. package/dist/runtime/subsystems/jobs/job-orchestrator.protocol.d.ts +1 -1
  73. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.d.ts +1 -1
  74. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.d.ts +1 -1
  75. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +3 -3
  76. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.d.ts +1 -1
  77. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +3 -3
  78. package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +1 -1
  79. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.d.ts +1 -1
  80. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +3 -3
  81. package/dist/runtime/subsystems/jobs/job-worker.d.ts +1 -1
  82. package/dist/runtime/subsystems/jobs/job-worker.js +3 -3
  83. package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +1 -1
  84. package/dist/runtime/subsystems/jobs/job-worker.module.js +13 -13
  85. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +11 -11
  86. package/dist/runtime/subsystems/jobs/jobs-errors.d.ts +1 -1
  87. package/dist/runtime/subsystems/observability/index.d.ts +1 -1
  88. package/dist/runtime/subsystems/observability/observability.protocol.d.ts +1 -1
  89. package/dist/runtime/subsystems/observability/observability.service.d.ts +1 -1
  90. package/dist/runtime/subsystems/observability/reporters/bridge-metrics.reporter.d.ts +1 -1
  91. package/dist/runtime/subsystems/observability/reporters/index.d.ts +1 -1
  92. package/dist/runtime/subsystems/storage/index.js +4 -4
  93. package/dist/runtime/subsystems/storage/storage.module.js +2 -2
  94. package/dist/src/cli/index.js +34 -12
  95. package/dist/src/cli/index.js.map +1 -1
  96. package/dist/src/index.d.ts +23 -8
  97. package/dist/src/index.js +7 -7
  98. package/package.json +2 -1
  99. package/runtime/base-classes/activity-entity-repository.ts +72 -13
  100. package/runtime/base-classes/activity-entity-service.ts +14 -12
  101. package/runtime/subsystems/integration/deep-equal.differ.ts +34 -5
  102. package/runtime/subsystems/integration/integration.module.ts +26 -2
  103. package/runtime/subsystems/jobs/job-handler.base.ts +115 -2
  104. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +43 -16
  105. package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +58 -18
  106. package/src/patterns/library/activity.pattern.ts +40 -10
  107. package/templates/subsystem/integration-config/codegen-config-integration-block.ejs.t +17 -0
  108. package/dist/chunk-24WXSC3C.js.map +0 -1
  109. package/dist/chunk-36U5UGIO.js.map +0 -1
  110. package/dist/chunk-BOPZWRJK.js.map +0 -1
  111. package/dist/chunk-CO6LUM72.js.map +0 -1
  112. package/dist/chunk-IT6FRTEW.js.map +0 -1
  113. package/dist/chunk-T4YJRD22.js.map +0 -1
  114. package/dist/chunk-XCEI7NUH.js +0 -41
  115. package/dist/chunk-XCEI7NUH.js.map +0 -1
  116. /package/dist/{chunk-H6FO2ZDJ.js.map → chunk-4PFF3ED4.js.map} +0 -0
  117. /package/dist/{chunk-QSJ3J4HE.js.map → chunk-BHZP6LOV.js.map} +0 -0
  118. /package/dist/{chunk-RUSUZZAF.js.map → chunk-BK5ICA2F.js.map} +0 -0
  119. /package/dist/{chunk-TKVTEUBD.js.map → chunk-EJBK7I4F.js.map} +0 -0
  120. /package/dist/{chunk-JM3T27ZW.js.map → chunk-FWRL7BZ5.js.map} +0 -0
  121. /package/dist/{chunk-DGYTSCKN.js.map → chunk-HOIRY5XP.js.map} +0 -0
  122. /package/dist/{chunk-AYC2HEAL.js.map → chunk-HPS554L4.js.map} +0 -0
  123. /package/dist/{chunk-K2I6XIK5.js.map → chunk-KSTZIULO.js.map} +0 -0
  124. /package/dist/{chunk-CRBVI4GE.js.map → chunk-PSDVGPQR.js.map} +0 -0
  125. /package/dist/{chunk-DLG62MQY.js.map → chunk-SFQRETXJ.js.map} +0 -0
  126. /package/dist/{chunk-NXNVTXKG.js.map → chunk-SGSWVNNB.js.map} +0 -0
  127. /package/dist/{chunk-5LXOJGO2.js.map → chunk-VNBC3VXM.js.map} +0 -0
@@ -4135,17 +4135,32 @@ declare function isDomainPattern(def: AnyPatternDefinition): def is PatternDefin
4135
4135
  declare function defineOrchestrationPattern(def: OrchestrationPatternDefinition): OrchestrationPatternDefinition;
4136
4136
 
4137
4137
  /**
4138
- * ActivityPattern — replaces `family: activity`.
4138
+ * ActivityPattern — config-driven subject-scoped interaction base.
4139
4139
  *
4140
- * Activity entities represent time-bounded interactions (calls, meetings,
4141
- * emails). The base repository/service expose date-range + opportunity +
4142
- * user-scoped lookups on top of the standard CRUD methods.
4140
+ * Activity entities represent interactions (calls, meetings, emails, messages,
4141
+ * transcripts) that reference a *subject* the thing the interaction is about.
4142
+ * Which subject is a per-entity fact, not a library constant: a CRM activity is
4143
+ * scoped to an `opportunity`, a swe-brain interaction to a `person` (later
4144
+ * `repo`/`team`, ADR-0006's Salesforce Activities-vs-Records shape). The base
4145
+ * repository/service therefore expose **generic** subject-scoped finders
4146
+ * (`findBySubjectId` / `findRecentBySubjectId`) that read the subject FK column
4147
+ * from the entity's `config:` block, on top of the standard CRUD methods plus
4148
+ * date-range and actor (`user_id`) scoping.
4143
4149
  *
4144
- * Class names, import paths, and inherited-method strings match the
4145
- * legacy `FAMILY_MAP` entry verbatim so PATTERN-5's template swap produces
4146
- * byte-identical output.
4150
+ * The subject FK column resolves from `config: { Activity: { ... } }`:
4151
+ * - `subjectColumn` explicit snake_case column, OR
4152
+ * - `<subject>_id` — derived from the `subject` entity name.
4153
+ * The recency-ordering column is `occurredAt` (snake_case in config), default
4154
+ * `occurred_at`. The base reads these via `this.patternConfig` — the same
4155
+ * ADR-031 §4 hand-off `IntegratedEntityRepository` uses for `integrationConfig`.
4156
+ *
4157
+ * See `docs/specs/ACTIVITY-SUBJECT-1.md`.
4147
4158
  */
4148
- declare const ActivityPattern: PatternDefinition<unknown>;
4159
+ declare const ActivityPattern: PatternDefinition<{
4160
+ subject?: string | undefined;
4161
+ subjectColumn?: string | undefined;
4162
+ occurredAt?: string | undefined;
4163
+ }>;
4149
4164
 
4150
4165
  /**
4151
4166
  * BasePattern — identity pattern for the `extends` chain.
package/dist/src/index.js CHANGED
@@ -44,27 +44,27 @@ import {
44
44
  validateOrchestrationProject,
45
45
  validatePatternComposition,
46
46
  validatePatternProject
47
- } from "../chunk-2WDX6I7T.js";
47
+ } from "../chunk-IOQMMH6C.js";
48
48
  import "../chunk-KVOWSC5S.js";
49
- import "../chunk-24WXSC3C.js";
50
- import "../chunk-EO2QPOKH.js";
49
+ import "../chunk-JA7GJDNI.js";
51
50
  import "../chunk-PRWIX6UW.js";
52
- import "../chunk-XWBK3XJK.js";
53
51
  import "../chunk-AHV4GDYM.js";
54
52
  import "../chunk-YK5JEVLX.js";
53
+ import "../chunk-EO2QPOKH.js";
55
54
  import "../chunk-SQDOBLBP.js";
56
- import "../chunk-5TK7MEN4.js";
57
55
  import "../chunk-4KNXX6TI.js";
58
56
  import "../chunk-3CJFPU6Q.js";
59
57
  import "../chunk-TDEHU73T.js";
58
+ import "../chunk-LG57S2SC.js";
59
+ import "../chunk-XWBK3XJK.js";
60
60
  import "../chunk-S7C6TIIF.js";
61
61
  import "../chunk-MZ6GV4YF.js";
62
- import "../chunk-LG57S2SC.js";
63
62
  import "../chunk-HNWZFNKP.js";
64
63
  import "../chunk-43SBT72G.js";
65
64
  import "../chunk-4MF3HKJA.js";
66
65
  import "../chunk-TIZXQU26.js";
67
- import "../chunk-36U5UGIO.js";
66
+ import "../chunk-JEINYUJH.js";
67
+ import "../chunk-5TK7MEN4.js";
68
68
  import "../chunk-U64T4YZE.js";
69
69
  import "../chunk-2E224ZSN.js";
70
70
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.16.1",
3
+ "version": "0.17.1",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -138,6 +138,7 @@
138
138
  "@pattern-stack/codegen-calendar": "workspace:*",
139
139
  "@pattern-stack/codegen-crm": "workspace:*",
140
140
  "@pattern-stack/codegen-mail": "workspace:*",
141
+ "@pattern-stack/codegen-messaging": "workspace:*",
141
142
  "@pattern-stack/codegen-transcript": "workspace:*",
142
143
  "@cubejs-client/core": "^1.0.0",
143
144
  "@nestjs/common": "10",
@@ -1,26 +1,84 @@
1
1
  /**
2
2
  * ActivityEntityRepository<TEntity>
3
3
  *
4
- * Family-specific base for activity entities (emails, calls, meetings, notes).
5
- * Adds date-range queries, user/opportunity scoping, and recency ordering.
4
+ * Family-specific base for activity / interaction entities (emails, calls,
5
+ * meetings, messages, transcripts). Adds date-range queries, actor (`user_id`)
6
+ * scoping, recency ordering, and **config-driven subject scoping** — the
7
+ * subject FK column is resolved from the concrete repo's `patternConfig`
8
+ * (ADR-031 §4) rather than hardcoded, so the same base serves a CRM
9
+ * `opportunity`-scoped activity and a swe-brain `person`-scoped interaction.
6
10
  *
7
- * Concrete repos extend this and declare their table + behaviors.
11
+ * Concrete repos extend this and declare their table + behaviors, and (when
12
+ * they use the subject finders) a `patternConfig` carrying `subject` /
13
+ * `subjectColumn` / `occurredAt`. The template emits that property from the
14
+ * entity's `config: { Activity: {...} }` block. See ACTIVITY-SUBJECT-1.
8
15
  */
9
16
  import { eq, between, desc } from 'drizzle-orm';
10
17
  import { BaseRepository } from './base-repository';
11
18
 
19
+ /**
20
+ * Per-entity Activity config (matches `ActivityPatternConfigSchema` in
21
+ * `src/patterns/library/activity.pattern.ts`). Carried on the concrete repo as
22
+ * `patternConfig` and read here to resolve column names at runtime.
23
+ */
24
+ export interface ActivityPatternConfig {
25
+ /** Subject entity name → derives the FK column `<subject>_id`. */
26
+ subject?: string;
27
+ /** Explicit snake_case FK column, when it does not follow `<subject>_id`. */
28
+ subjectColumn?: string;
29
+ /** snake_case recency-ordering column; defaults to `occurred_at`. */
30
+ occurredAt?: string;
31
+ }
32
+
33
+ const toCamel = (snake: string): string =>
34
+ snake.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
35
+
12
36
  export abstract class ActivityEntityRepository<TEntity> extends BaseRepository<TEntity> {
13
37
  /**
14
- * Find activities within a date range (inclusive).
38
+ * Per-entity Activity config. The template emits this from `config:
39
+ * { Activity: {...} }`; entities that only use date-range / user scoping omit
40
+ * it (and must not call the subject finders).
41
+ */
42
+ protected readonly patternConfig?: ActivityPatternConfig;
43
+
44
+ /**
45
+ * camelCase key for the recency-ordering column. Defaults to `occurredAt`
46
+ * (column `occurred_at`); override via `patternConfig.occurredAt`.
47
+ */
48
+ protected get occurredAtColumn(): string {
49
+ const snake = this.patternConfig?.occurredAt ?? 'occurred_at';
50
+ return toCamel(snake);
51
+ }
52
+
53
+ /**
54
+ * camelCase key for the subject FK column, resolved from `patternConfig`:
55
+ * `subjectColumn` (explicit) → `<subject>_id` (derived). Throws when neither
56
+ * is configured — the subject finders are unusable without it, and a clear
57
+ * error beats a silent `undefined` column index.
58
+ */
59
+ protected get subjectColumn(): string {
60
+ const explicit = this.patternConfig?.subjectColumn;
61
+ if (explicit) return toCamel(explicit);
62
+ const subject = this.patternConfig?.subject;
63
+ if (subject) return toCamel(`${subject}_id`);
64
+ throw new Error(
65
+ 'ActivityEntityRepository: subject finders require a subject column. ' +
66
+ "Set `config: { Activity: { subject: '<entity>' } }` (→ <entity>_id) " +
67
+ "or `config: { Activity: { subjectColumn: '<column>' } }` on the entity YAML.",
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Find activities within a date range (inclusive), by the recency column.
15
73
  */
16
74
  async findByDateRange(start: Date, end: Date): Promise<TEntity[]> {
17
75
  const rows = await this.baseQuery()
18
- .where(between(this.table['occurredAt'], start, end));
76
+ .where(between(this.table[this.occurredAtColumn], start, end));
19
77
  return rows as TEntity[];
20
78
  }
21
79
 
22
80
  /**
23
- * Find all activities for a specific user.
81
+ * Find all activities for a specific user (actor / owner scoping).
24
82
  */
25
83
  async findByUserId(userId: string): Promise<TEntity[]> {
26
84
  const rows = await this.baseQuery()
@@ -29,21 +87,22 @@ export abstract class ActivityEntityRepository<TEntity> extends BaseRepository<T
29
87
  }
30
88
 
31
89
  /**
32
- * Find all activities for a specific opportunity.
90
+ * Find all activities for a specific subject (config-driven FK column).
33
91
  */
34
- async findByOpportunityId(opportunityId: string): Promise<TEntity[]> {
92
+ async findBySubjectId(subjectId: string): Promise<TEntity[]> {
35
93
  const rows = await this.baseQuery()
36
- .where(eq(this.table['opportunityId'], opportunityId));
94
+ .where(eq(this.table[this.subjectColumn], subjectId));
37
95
  return rows as TEntity[];
38
96
  }
39
97
 
40
98
  /**
41
- * Find the most recent activities for an opportunity, ordered by occurredAt desc.
99
+ * Find the most recent activities for a subject, ordered by the recency
100
+ * column descending.
42
101
  */
43
- async findRecentByOpportunityId(opportunityId: string, limit = 10): Promise<TEntity[]> {
102
+ async findRecentBySubjectId(subjectId: string, limit = 10): Promise<TEntity[]> {
44
103
  const rows = await this.baseQuery()
45
- .where(eq(this.table['opportunityId'], opportunityId))
46
- .orderBy(desc(this.table['occurredAt']))
104
+ .where(eq(this.table[this.subjectColumn], subjectId))
105
+ .orderBy(desc(this.table[this.occurredAtColumn]))
47
106
  .limit(limit);
48
107
  return rows as TEntity[];
49
108
  }
@@ -1,17 +1,19 @@
1
1
  /**
2
2
  * ActivityEntityService<TRepo, TEntity>
3
3
  *
4
- * Family-specific base service for activity entities.
5
- * Delegates to an activity repository that provides date-range,
6
- * user, and opportunity queries.
4
+ * Family-specific base service for activity / interaction entities. Delegates
5
+ * to an activity repository that provides date-range, actor (`user_id`), and
6
+ * config-driven subject queries. The subject FK column is resolved inside the
7
+ * repository from its `patternConfig` (ADR-031 §4) — the service is
8
+ * subject-name-agnostic. See ACTIVITY-SUBJECT-1.
7
9
  */
8
10
  import { BaseService, type IBaseRepository } from './base-service';
9
11
 
10
12
  export interface IActivityEntityRepository<TEntity> extends IBaseRepository<TEntity> {
11
13
  findByDateRange(start: Date, end: Date): Promise<TEntity[]>;
12
14
  findByUserId(userId: string): Promise<TEntity[]>;
13
- findByOpportunityId(opportunityId: string): Promise<TEntity[]>;
14
- findRecentByOpportunityId(opportunityId: string, limit?: number): Promise<TEntity[]>;
15
+ findBySubjectId(subjectId: string): Promise<TEntity[]>;
16
+ findRecentBySubjectId(subjectId: string, limit?: number): Promise<TEntity[]>;
15
17
  }
16
18
 
17
19
  export abstract class ActivityEntityService<
@@ -26,23 +28,23 @@ export abstract class ActivityEntityService<
26
28
  }
27
29
 
28
30
  /**
29
- * Find all activities for a specific user.
31
+ * Find all activities for a specific user (actor / owner scoping).
30
32
  */
31
33
  findByUser(userId: string): Promise<TEntity[]> {
32
34
  return this.repository.findByUserId(userId);
33
35
  }
34
36
 
35
37
  /**
36
- * Find all activities for a specific opportunity.
38
+ * Find all activities for a specific subject (config-driven FK column).
37
39
  */
38
- findByOpportunity(opportunityId: string): Promise<TEntity[]> {
39
- return this.repository.findByOpportunityId(opportunityId);
40
+ findBySubject(subjectId: string): Promise<TEntity[]> {
41
+ return this.repository.findBySubjectId(subjectId);
40
42
  }
41
43
 
42
44
  /**
43
- * Find the most recent activities for an opportunity.
45
+ * Find the most recent activities for a subject.
44
46
  */
45
- findRecent(opportunityId: string, limit?: number): Promise<TEntity[]> {
46
- return this.repository.findRecentByOpportunityId(opportunityId, limit);
47
+ findRecent(subjectId: string, limit?: number): Promise<TEntity[]> {
48
+ return this.repository.findRecentBySubjectId(subjectId, limit);
47
49
  }
48
50
  }
@@ -12,7 +12,11 @@
12
12
  * `id`, `createdAt`, `updatedAt`, `deletedAt`, `type`,
13
13
  * `lastModifiedAt`, `fields`, `providerMetadata`
14
14
  * (`fields` is the EAV bag — it's diffed by the sink's EAV dual-write
15
- * path, not at the canonical-record layer.)
15
+ * path, not at the canonical-record layer.) Consumers augment the list via
16
+ * `options.ignore` and — when a default is domain data for their entity —
17
+ * REMOVE a default via `options.unignore` (e.g. an entity whose
18
+ * `deletedAt` is a vendor-observed retraction tombstone, not row metadata;
19
+ * see `DeepEqualDifferOptions.unignore`).
16
20
  *
17
21
  * 2. **`providerChangedFields` hint (CDC)** — when present, restricts the
18
22
  * comparison to the hinted field set. The hint is advisory; fields in
@@ -75,6 +79,26 @@ export interface DeepEqualDifferOptions {
75
79
  * merged (not replaced) with `DEFAULT_IGNORE_FIELDS`.
76
80
  */
77
81
  readonly ignore?: readonly string[];
82
+
83
+ /**
84
+ * Field names to REMOVE from the default ignore list — the inverse of
85
+ * `ignore`. Use this to declare that a normally-metadata column is in fact
86
+ * DOMAIN DATA for this entity and must register as a field change.
87
+ *
88
+ * The canonical case (swe-brain ADR-0008 §1, the gap this knob closes):
89
+ * `deletedAt` is in `DEFAULT_IGNORE_FIELDS` because most sinks stamp it as
90
+ * row metadata sinks own unconditionally. But an entity with
91
+ * `softDelete: false` and a domain-owned `deleted_at` carries the
92
+ * vendor-observed retraction tombstone ON the canonical record (a Slack
93
+ * `message_deleted` → `deletedAt`). Without un-ignoring it, the tombstone
94
+ * overlay diffs to `'noop'`, the upsert is skipped, and `deleted_at` never
95
+ * lands. `unignore: ['deletedAt']` makes the differ treat it as domain data.
96
+ *
97
+ * Applied AFTER `ignore` is merged, so `unignore` wins on a field listed in
98
+ * both. Subtracting a field not in the (merged) ignore set is a harmless
99
+ * no-op. Does not touch `DEFAULT_IGNORE_FIELDS` for any other instance.
100
+ */
101
+ readonly unignore?: readonly string[];
78
102
  }
79
103
 
80
104
  @Injectable()
@@ -84,11 +108,16 @@ export class DeepEqualDiffer<T extends Record<string, unknown>>
84
108
  private readonly ignore: ReadonlySet<string>;
85
109
 
86
110
  constructor(opts: DeepEqualDifferOptions = {}) {
87
- if (opts.ignore && opts.ignore.length > 0) {
88
- this.ignore = new Set([...DEFAULT_IGNORE_FIELDS, ...opts.ignore]);
89
- } else {
90
- this.ignore = DEFAULT_IGNORE_FIELDS;
111
+ const merged = new Set<string>(DEFAULT_IGNORE_FIELDS);
112
+ if (opts.ignore) {
113
+ for (const field of opts.ignore) merged.add(field);
114
+ }
115
+ // `unignore` is subtracted last so it wins over a field that also appears
116
+ // in `ignore` or the defaults — "this column is domain data here."
117
+ if (opts.unignore) {
118
+ for (const field of opts.unignore) merged.delete(field);
91
119
  }
120
+ this.ignore = merged;
92
121
  }
93
122
 
94
123
  diff(
@@ -73,7 +73,7 @@ import { MemoryCursorStore } from './integration-cursor-store.memory-backend';
73
73
  import { MemoryRunRecorder } from './integration-run-recorder.memory-backend';
74
74
  import { PostgresCursorStore } from './integration-cursor-store.drizzle-backend';
75
75
  import { DrizzleIntegrationRunRecorder } from './integration-run-recorder.drizzle-backend';
76
- import { DeepEqualDiffer } from './deep-equal.differ';
76
+ import { DeepEqualDiffer, type DeepEqualDifferOptions } from './deep-equal.differ';
77
77
 
78
78
  export interface IntegrationModuleOptions {
79
79
  /**
@@ -100,6 +100,24 @@ export interface IntegrationModuleOptions {
100
100
  * Defaults to `false`.
101
101
  */
102
102
  multiTenant?: boolean;
103
+
104
+ /**
105
+ * Default-differ configuration (DIFFER-UNIGNORE, 0.17.1). Threaded into the
106
+ * `DeepEqualDiffer` bound to `INTEGRATION_FIELD_DIFFER`. Omit for the
107
+ * historical behaviour (the default ignore list, unchanged).
108
+ *
109
+ * Mirrors `DeepEqualDifferOptions`:
110
+ * - `ignore` — extra field names to ignore (merged with the defaults).
111
+ * - `unignore` — default-ignored field names to RE-include as domain data
112
+ * (e.g. `['deletedAt']` for an entity whose `deletedAt` is a
113
+ * vendor-observed retraction tombstone, not row metadata — swe-brain
114
+ * ADR-0008 §1). Subtracted after the merge, so it wins.
115
+ *
116
+ * A feature module that binds its own `IFieldDiffer<T>` to
117
+ * `INTEGRATION_FIELD_DIFFER` overrides this entirely (per-entity escape hatch
118
+ * unchanged).
119
+ */
120
+ differ?: DeepEqualDifferOptions;
103
121
  }
104
122
 
105
123
  @Module({})
@@ -112,7 +130,13 @@ export class IntegrationModule {
112
130
  { provide: INTEGRATION_MULTI_TENANT, useValue: multiTenant },
113
131
  // Default differ — consumers can override by binding a different
114
132
  // `IFieldDiffer<T>` to `INTEGRATION_FIELD_DIFFER` in their feature module.
115
- { provide: INTEGRATION_FIELD_DIFFER, useValue: new DeepEqualDiffer() },
133
+ // DIFFER-UNIGNORE: `options.differ` (ignore/unignore) is threaded here so
134
+ // a consumer can declare a default-ignored column (e.g. `deletedAt`) as
135
+ // domain data for their entities without binding a bespoke differ.
136
+ {
137
+ provide: INTEGRATION_FIELD_DIFFER,
138
+ useValue: new DeepEqualDiffer(options.differ ?? {}),
139
+ },
116
140
  ];
117
141
 
118
142
  const backendProviders: Provider[] =
@@ -42,13 +42,33 @@ export interface RetryPolicy {
42
42
  nonRetryableErrors?: string[];
43
43
  }
44
44
 
45
+ /**
46
+ * Concurrency lane key (JOB-FN-KEY, 0.16.2).
47
+ *
48
+ * Two authoring forms, both honored end-to-end (the typed function form was
49
+ * previously dropped to `null` at registration — see `upsertJobRows` — so
50
+ * `collisionMode` silently never engaged):
51
+ *
52
+ * - **`string`** — a `{{field}}` template evaluated against the start
53
+ * payload by `evaluateKeyTemplate` (single-key substitution, no dotted
54
+ * paths). Persisted verbatim to `job.concurrency_key_template`.
55
+ * - **`(input) => string`** — an arbitrary function of the input. Persisted
56
+ * as the `FN_KEY_SENTINEL` marker so the definition-hash gate stays stable
57
+ * and the collision path engages; `start()` re-resolves the live function
58
+ * from `JOB_HANDLER_REGISTRY` and evaluates it against the payload.
59
+ *
60
+ * Both forms produce a per-lane key; same key + in-flight incumbent ⇒
61
+ * `collisionMode` ('queue' | 'reject' | 'replace') decides.
62
+ */
63
+ export type JobKeySelector<TInput> = string | ((input: TInput) => string);
64
+
45
65
  export interface ConcurrencyPolicy<TInput> {
46
- key: (input: TInput) => string;
66
+ key: JobKeySelector<TInput>;
47
67
  collisionMode: 'queue' | 'reject' | 'replace';
48
68
  }
49
69
 
50
70
  export interface DedupePolicy<TInput> {
51
- key: (input: TInput) => string;
71
+ key: JobKeySelector<TInput>;
52
72
  windowMs: number;
53
73
  }
54
74
 
@@ -245,3 +265,96 @@ export namespace HandlerRegistry {
245
265
  return JOB_HANDLER_REGISTRY.get(type);
246
266
  }
247
267
  }
268
+
269
+ // ─── Key resolution (JOB-FN-KEY, 0.16.2) ────────────────────────────────────
270
+
271
+ /**
272
+ * Sentinel persisted to `job.concurrency_key_template` / `dedupe_key_template`
273
+ * when the authored `key` is a function rather than a `{{field}}` template.
274
+ *
275
+ * Why a sentinel (not `null`): the collision/dedupe paths in both backends gate
276
+ * on `definition.concurrencyKeyTemplate != null`. A function key persisted as
277
+ * `null` (the pre-0.16.2 bug) left those columns empty, so `collisionMode` /
278
+ * the dedupe window never engaged — the job ran with NO key. A stable sentinel
279
+ * keeps the column non-null (path engages) AND keeps the definition-hash gate
280
+ * (`upsertJobRows`' `IS DISTINCT FROM` clause) stable across boots, since the
281
+ * function identity itself can't be hashed. `start()` detects the sentinel and
282
+ * re-resolves the live function from `JOB_HANDLER_REGISTRY`.
283
+ *
284
+ * Chosen as an angle-bracketed token so it can never collide with a real
285
+ * `{{field}}` template (which never contains a literal `<`).
286
+ */
287
+ export const FN_KEY_SENTINEL = '<fn>';
288
+
289
+ /**
290
+ * Registration-time projection: collapse an authored `JobKeySelector` to the
291
+ * string stored in the `job` definition row. A string template is stored
292
+ * verbatim; a function is stored as `FN_KEY_SENTINEL`; absence stays `null`.
293
+ */
294
+ export function keySelectorToTemplate(
295
+ key: JobKeySelector<unknown> | undefined,
296
+ ): string | null {
297
+ if (typeof key === 'string') return key;
298
+ if (typeof key === 'function') return FN_KEY_SENTINEL;
299
+ return null;
300
+ }
301
+
302
+ /** Which meta policy a key belongs to — selects the live fn at `start()`. */
303
+ export type KeyKind = 'concurrency' | 'dedupe';
304
+
305
+ /**
306
+ * `start()`-time resolution shared by every backend. Turns the persisted
307
+ * template column into the concrete per-run key for the given payload.
308
+ *
309
+ * - `template == null` → `null` (no key; caller skips the collision/dedupe path).
310
+ * - `template === FN_KEY_SENTINEL` → look the live `@JobHandler` meta up in
311
+ * `JOB_HANDLER_REGISTRY`, pull `meta[kind].key`, and invoke it against the
312
+ * payload. The registry is the runtime source of truth (the worker already
313
+ * resolves handler classes the same way), so the function survives the DB
314
+ * round-trip even though it can't be persisted.
315
+ * - otherwise → a `{{field}}` template, evaluated via the injected
316
+ * `evaluateTemplate` (each backend passes its own copy to avoid a runtime
317
+ * import cycle).
318
+ *
319
+ * Throws `JobKeyFunctionUnavailableError` if the sentinel is present but no
320
+ * live function can be found (e.g. the registry was reset, or a function key
321
+ * was persisted by a newer build and read by an older one). Failing loud beats
322
+ * silently degrading to no-key — the exact regression this fix exists to kill.
323
+ */
324
+ export function resolveJobKey(
325
+ kind: KeyKind,
326
+ type: string,
327
+ template: string | null,
328
+ payload: Record<string, unknown>,
329
+ evaluateTemplate: (template: string, payload: Record<string, unknown>) => string,
330
+ ): string | null {
331
+ if (template == null) return null;
332
+ if (template !== FN_KEY_SENTINEL) return evaluateTemplate(template, payload);
333
+
334
+ const meta = JOB_HANDLER_REGISTRY.get(type)?.meta;
335
+ const key = (meta?.[kind] as { key?: unknown } | undefined)?.key;
336
+ if (typeof key !== 'function') {
337
+ throw new JobKeyFunctionUnavailableError(type, kind);
338
+ }
339
+ return (key as (input: unknown) => string)(payload);
340
+ }
341
+
342
+ /**
343
+ * Raised when a `${FN_KEY_SENTINEL}` template is read but the live function
344
+ * key is missing from `JOB_HANDLER_REGISTRY`. Kept here (not in `jobs-errors`)
345
+ * so `job-handler.base` stays import-cycle-free.
346
+ */
347
+ export class JobKeyFunctionUnavailableError extends Error {
348
+ constructor(
349
+ readonly jobType: string,
350
+ readonly kind: KeyKind,
351
+ ) {
352
+ super(
353
+ `[jobs] ${kind} key for job '${jobType}' was persisted as a function ` +
354
+ `sentinel ('${FN_KEY_SENTINEL}') but no live function is registered ` +
355
+ `for it. The @JobHandler must be imported before start() so its meta ` +
356
+ `is in JOB_HANDLER_REGISTRY.`,
357
+ );
358
+ this.name = 'JobKeyFunctionUnavailableError';
359
+ }
360
+ }
@@ -36,6 +36,11 @@ import {
36
36
  import { jobSteps } from './job-orchestration.schema';
37
37
  import { JOBS_MULTI_TENANT, JOBS_LISTEN_NOTIFY } from './jobs-domain.tokens';
38
38
  import { JOBS_WAKE_CHANNEL, pgNotify } from './pg-notify';
39
+ import {
40
+ keySelectorToTemplate,
41
+ resolveJobKey,
42
+ type JobKeySelector,
43
+ } from './job-handler.base';
39
44
 
40
45
  /**
41
46
  * Terminal statuses — transitions into these are final. Used by `cancel`
@@ -140,9 +145,17 @@ export class DrizzleJobOrchestrator implements IJobOrchestrator {
140
145
  if (!def) throw new JobTypeNotFoundError(type);
141
146
  const definition = def as JobDefinitionRow;
142
147
 
143
- // 1b. Dedupe check.
148
+ // 1b. Dedupe check. JOB-FN-KEY: `resolveJobKey` honors both the `{{field}}`
149
+ // template AND a function key persisted as `FN_KEY_SENTINEL` (re-resolved
150
+ // live from JOB_HANDLER_REGISTRY).
144
151
  if (definition.dedupeKeyTemplate && definition.dedupeWindowMs) {
145
- const dedupeKey = evaluateKeyTemplate(definition.dedupeKeyTemplate, payload);
152
+ const dedupeKey = resolveJobKey(
153
+ 'dedupe',
154
+ type,
155
+ definition.dedupeKeyTemplate,
156
+ payload,
157
+ evaluateKeyTemplate,
158
+ ) as string;
146
159
  const windowStart = new Date(Date.now() - definition.dedupeWindowMs);
147
160
  const existing = await client
148
161
  .select()
@@ -166,10 +179,15 @@ export class DrizzleJobOrchestrator implements IJobOrchestrator {
166
179
  // 1c. Concurrency collision check.
167
180
  let concurrencyKey: string | null = null;
168
181
  if (definition.concurrencyKeyTemplate) {
169
- concurrencyKey = evaluateKeyTemplate(
182
+ // Non-null cast: the branch guard proves the template is present, so the
183
+ // resolver never returns null here (it only nulls on a null template).
184
+ concurrencyKey = resolveJobKey(
185
+ 'concurrency',
186
+ type,
170
187
  definition.concurrencyKeyTemplate,
171
188
  payload,
172
- );
189
+ evaluateKeyTemplate,
190
+ ) as string;
173
191
  const inFlight = await client
174
192
  .select()
175
193
  .from(jobRuns)
@@ -223,10 +241,13 @@ export class DrizzleJobOrchestrator implements IJobOrchestrator {
223
241
  rootRunId = parent.rootRunId;
224
242
  }
225
243
 
226
- const dedupeKey =
227
- definition.dedupeKeyTemplate
228
- ? evaluateKeyTemplate(definition.dedupeKeyTemplate, payload)
229
- : null;
244
+ const dedupeKey = resolveJobKey(
245
+ 'dedupe',
246
+ type,
247
+ definition.dedupeKeyTemplate,
248
+ payload,
249
+ evaluateKeyTemplate,
250
+ );
230
251
 
231
252
  const [inserted] = await client
232
253
  .insert(jobRuns)
@@ -460,17 +481,23 @@ export class DrizzleJobOrchestrator implements IJobOrchestrator {
460
481
  backoff: 'fixed' as const,
461
482
  baseMs: 0,
462
483
  };
463
- const concurrencyKeyTemplate =
464
- (meta.concurrency as { key?: unknown } | undefined)?.key;
465
- const concurrencyKeyTemplateStr =
466
- typeof concurrencyKeyTemplate === 'string' ? concurrencyKeyTemplate : null;
484
+ // JOB-FN-KEY (0.16.2): both authored key forms are honored. A `{{field}}`
485
+ // string is persisted verbatim; a function is persisted as
486
+ // `FN_KEY_SENTINEL` (non-null so the collision/dedupe path engages, and
487
+ // hash-stable so the definition-hash gate doesn't churn on every boot —
488
+ // the function identity can't be hashed). `start()` re-resolves the live
489
+ // function from `JOB_HANDLER_REGISTRY`. The pre-0.16.2 `typeof === string
490
+ // ? … : null` dropped function keys to null, so `collisionMode` silently
491
+ // never engaged.
492
+ const concurrencyKeyTemplateStr = keySelectorToTemplate(
493
+ meta.concurrency?.key as JobKeySelector<unknown> | undefined,
494
+ );
467
495
  const collisionMode =
468
496
  (meta.concurrency?.collisionMode as JobDefinitionRow['collisionMode']) ??
469
497
  'queue';
470
- const dedupeKeyTemplate =
471
- (meta.dedupe as { key?: unknown } | undefined)?.key;
472
- const dedupeKeyTemplateStr =
473
- typeof dedupeKeyTemplate === 'string' ? dedupeKeyTemplate : null;
498
+ const dedupeKeyTemplateStr = keySelectorToTemplate(
499
+ meta.dedupe?.key as JobKeySelector<unknown> | undefined,
500
+ );
474
501
  const dedupeWindowMs = meta.dedupe?.windowMs ?? null;
475
502
  const timeoutMs = meta.timeoutMs ?? null;
476
503
  const replayFrom = meta.replayFrom ?? 'last_checkpoint';