@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.
- package/CHANGELOG.md +121 -0
- package/consumer-skills/entities/families-and-queries.md +5 -3
- package/consumer-skills/integration/audit-and-detection.md +29 -4
- package/dist/{chunk-H6FO2ZDJ.js → chunk-4PFF3ED4.js} +4 -4
- package/dist/{chunk-CO6LUM72.js → chunk-7P5ODGLA.js} +34 -2
- package/dist/chunk-7P5ODGLA.js.map +1 -0
- package/dist/{chunk-QSJ3J4HE.js → chunk-BHZP6LOV.js} +7 -7
- package/dist/{chunk-RUSUZZAF.js → chunk-BK5ICA2F.js} +4 -4
- package/dist/{chunk-T4YJRD22.js → chunk-DUMI2J5M.js} +45 -14
- package/dist/chunk-DUMI2J5M.js.map +1 -0
- package/dist/{chunk-TKVTEUBD.js → chunk-EJBK7I4F.js} +2 -2
- package/dist/{chunk-IT6FRTEW.js → chunk-FVNAU7VO.js} +39 -18
- package/dist/chunk-FVNAU7VO.js.map +1 -0
- package/dist/{chunk-JM3T27ZW.js → chunk-FWRL7BZ5.js} +7 -7
- package/dist/{chunk-DGYTSCKN.js → chunk-HOIRY5XP.js} +14 -14
- package/dist/{chunk-AYC2HEAL.js → chunk-HPS554L4.js} +9 -9
- package/dist/{chunk-2WDX6I7T.js → chunk-IOQMMH6C.js} +16 -6
- package/dist/{chunk-2WDX6I7T.js.map → chunk-IOQMMH6C.js.map} +1 -1
- package/dist/{chunk-24WXSC3C.js → chunk-JA7GJDNI.js} +15 -9
- package/dist/chunk-JA7GJDNI.js.map +1 -0
- package/dist/{chunk-36U5UGIO.js → chunk-JEINYUJH.js} +8 -5
- package/dist/chunk-JEINYUJH.js.map +1 -0
- package/dist/{chunk-BOPZWRJK.js → chunk-JYBFPNBJ.js} +8 -8
- package/dist/chunk-JYBFPNBJ.js.map +1 -0
- package/dist/{chunk-K2I6XIK5.js → chunk-KSTZIULO.js} +4 -4
- package/dist/chunk-MKWQKKK7.js +72 -0
- package/dist/chunk-MKWQKKK7.js.map +1 -0
- package/dist/{chunk-CRBVI4GE.js → chunk-PSDVGPQR.js} +5 -5
- package/dist/{chunk-DLG62MQY.js → chunk-SFQRETXJ.js} +7 -7
- package/dist/{chunk-NXNVTXKG.js → chunk-SGSWVNNB.js} +5 -5
- package/dist/{chunk-5LXOJGO2.js → chunk-VNBC3VXM.js} +6 -6
- package/dist/{job-orchestrator.protocol-DubMVbm9.d.ts → job-orchestrator.protocol-ZuJ3ow-O.d.ts} +77 -3
- package/dist/runtime/base-classes/activity-entity-repository.d.ts +39 -7
- package/dist/runtime/base-classes/activity-entity-repository.js +1 -1
- package/dist/runtime/base-classes/activity-entity-service.d.ts +12 -10
- package/dist/runtime/base-classes/activity-entity-service.js +1 -1
- package/dist/runtime/base-classes/index.js +18 -18
- package/dist/runtime/shared/openapi/index.js +3 -3
- package/dist/runtime/subsystems/auth/index.js +3 -3
- package/dist/runtime/subsystems/bridge/bridge-delivery-handler.d.ts +1 -1
- package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +2 -2
- package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +6 -6
- package/dist/runtime/subsystems/bridge/bridge.module.d.ts +1 -1
- package/dist/runtime/subsystems/bridge/bridge.module.js +19 -19
- package/dist/runtime/subsystems/bridge/event-flow.service.d.ts +1 -1
- package/dist/runtime/subsystems/bridge/index.d.ts +1 -1
- package/dist/runtime/subsystems/bridge/index.js +21 -21
- package/dist/runtime/subsystems/cache/cache.module.js +1 -1
- package/dist/runtime/subsystems/cache/index.js +3 -3
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +2 -2
- package/dist/runtime/subsystems/events/events.module.js +3 -3
- package/dist/runtime/subsystems/events/index.js +3 -3
- package/dist/runtime/subsystems/index.d.ts +1 -1
- package/dist/runtime/subsystems/index.js +50 -50
- package/dist/runtime/subsystems/integration/deep-equal.differ.d.ts +19 -0
- package/dist/runtime/subsystems/integration/deep-equal.differ.js +1 -1
- package/dist/runtime/subsystems/integration/index.js +22 -22
- package/dist/runtime/subsystems/integration/integration.module.d.ts +20 -0
- package/dist/runtime/subsystems/integration/integration.module.js +4 -4
- package/dist/runtime/subsystems/jobs/index.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/index.js +43 -43
- package/dist/runtime/subsystems/jobs/job-handler.base.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-handler.base.js +11 -3
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +7 -6
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +4 -3
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.d.ts +11 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +3 -3
- package/dist/runtime/subsystems/jobs/job-orchestrator.protocol.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +3 -3
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +3 -3
- package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +3 -3
- package/dist/runtime/subsystems/jobs/job-worker.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.js +3 -3
- package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.module.js +13 -13
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +11 -11
- package/dist/runtime/subsystems/jobs/jobs-errors.d.ts +1 -1
- package/dist/runtime/subsystems/observability/index.d.ts +1 -1
- package/dist/runtime/subsystems/observability/observability.protocol.d.ts +1 -1
- package/dist/runtime/subsystems/observability/observability.service.d.ts +1 -1
- package/dist/runtime/subsystems/observability/reporters/bridge-metrics.reporter.d.ts +1 -1
- package/dist/runtime/subsystems/observability/reporters/index.d.ts +1 -1
- package/dist/runtime/subsystems/storage/index.js +4 -4
- package/dist/runtime/subsystems/storage/storage.module.js +2 -2
- package/dist/src/cli/index.js +34 -12
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.d.ts +23 -8
- package/dist/src/index.js +7 -7
- package/package.json +2 -1
- package/runtime/base-classes/activity-entity-repository.ts +72 -13
- package/runtime/base-classes/activity-entity-service.ts +14 -12
- package/runtime/subsystems/integration/deep-equal.differ.ts +34 -5
- package/runtime/subsystems/integration/integration.module.ts +26 -2
- package/runtime/subsystems/jobs/job-handler.base.ts +115 -2
- package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +43 -16
- package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +58 -18
- package/src/patterns/library/activity.pattern.ts +40 -10
- package/templates/subsystem/integration-config/codegen-config-integration-block.ejs.t +17 -0
- package/dist/chunk-24WXSC3C.js.map +0 -1
- package/dist/chunk-36U5UGIO.js.map +0 -1
- package/dist/chunk-BOPZWRJK.js.map +0 -1
- package/dist/chunk-CO6LUM72.js.map +0 -1
- package/dist/chunk-IT6FRTEW.js.map +0 -1
- package/dist/chunk-T4YJRD22.js.map +0 -1
- package/dist/chunk-XCEI7NUH.js +0 -41
- package/dist/chunk-XCEI7NUH.js.map +0 -1
- /package/dist/{chunk-H6FO2ZDJ.js.map → chunk-4PFF3ED4.js.map} +0 -0
- /package/dist/{chunk-QSJ3J4HE.js.map → chunk-BHZP6LOV.js.map} +0 -0
- /package/dist/{chunk-RUSUZZAF.js.map → chunk-BK5ICA2F.js.map} +0 -0
- /package/dist/{chunk-TKVTEUBD.js.map → chunk-EJBK7I4F.js.map} +0 -0
- /package/dist/{chunk-JM3T27ZW.js.map → chunk-FWRL7BZ5.js.map} +0 -0
- /package/dist/{chunk-DGYTSCKN.js.map → chunk-HOIRY5XP.js.map} +0 -0
- /package/dist/{chunk-AYC2HEAL.js.map → chunk-HPS554L4.js.map} +0 -0
- /package/dist/{chunk-K2I6XIK5.js.map → chunk-KSTZIULO.js.map} +0 -0
- /package/dist/{chunk-CRBVI4GE.js.map → chunk-PSDVGPQR.js.map} +0 -0
- /package/dist/{chunk-DLG62MQY.js.map → chunk-SFQRETXJ.js.map} +0 -0
- /package/dist/{chunk-NXNVTXKG.js.map → chunk-SGSWVNNB.js.map} +0 -0
- /package/dist/{chunk-5LXOJGO2.js.map → chunk-VNBC3VXM.js.map} +0 -0
package/dist/src/index.d.ts
CHANGED
|
@@ -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 —
|
|
4138
|
+
* ActivityPattern — config-driven subject-scoped interaction base.
|
|
4139
4139
|
*
|
|
4140
|
-
* Activity entities represent
|
|
4141
|
-
*
|
|
4142
|
-
*
|
|
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
|
-
*
|
|
4145
|
-
*
|
|
4146
|
-
*
|
|
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<
|
|
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-
|
|
47
|
+
} from "../chunk-IOQMMH6C.js";
|
|
48
48
|
import "../chunk-KVOWSC5S.js";
|
|
49
|
-
import "../chunk-
|
|
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-
|
|
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.
|
|
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,
|
|
5
|
-
* Adds date-range queries,
|
|
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
|
-
*
|
|
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[
|
|
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
|
|
90
|
+
* Find all activities for a specific subject (config-driven FK column).
|
|
33
91
|
*/
|
|
34
|
-
async
|
|
92
|
+
async findBySubjectId(subjectId: string): Promise<TEntity[]> {
|
|
35
93
|
const rows = await this.baseQuery()
|
|
36
|
-
.where(eq(this.table[
|
|
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
|
|
99
|
+
* Find the most recent activities for a subject, ordered by the recency
|
|
100
|
+
* column descending.
|
|
42
101
|
*/
|
|
43
|
-
async
|
|
102
|
+
async findRecentBySubjectId(subjectId: string, limit = 10): Promise<TEntity[]> {
|
|
44
103
|
const rows = await this.baseQuery()
|
|
45
|
-
.where(eq(this.table[
|
|
46
|
-
.orderBy(desc(this.table[
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
38
|
+
* Find all activities for a specific subject (config-driven FK column).
|
|
37
39
|
*/
|
|
38
|
-
|
|
39
|
-
return this.repository.
|
|
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
|
|
45
|
+
* Find the most recent activities for a subject.
|
|
44
46
|
*/
|
|
45
|
-
findRecent(
|
|
46
|
-
return this.repository.
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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:
|
|
66
|
+
key: JobKeySelector<TInput>;
|
|
47
67
|
collisionMode: 'queue' | 'reject' | 'replace';
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
export interface DedupePolicy<TInput> {
|
|
51
|
-
key:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
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';
|