@adcp/sdk 9.2.0 → 9.3.0
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/README.md +23 -0
- package/dist/lib/core/AgentClient.d.ts.map +1 -1
- package/dist/lib/core/SingleAgentClient.js +8 -8
- package/dist/lib/core/SingleAgentClient.js.map +1 -1
- package/dist/lib/discovery/validate-adagents.d.ts +2 -0
- package/dist/lib/discovery/validate-adagents.d.ts.map +1 -1
- package/dist/lib/discovery/validate-adagents.js +14 -6
- package/dist/lib/discovery/validate-adagents.js.map +1 -1
- package/dist/lib/index.d.ts +3 -2
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +22 -12
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/registry/feed-stream.d.ts +147 -0
- package/dist/lib/registry/feed-stream.d.ts.map +1 -0
- package/dist/lib/registry/feed-stream.js +378 -0
- package/dist/lib/registry/feed-stream.js.map +1 -0
- package/dist/lib/registry/index.d.ts +52 -3
- package/dist/lib/registry/index.d.ts.map +1 -1
- package/dist/lib/registry/index.js +205 -2
- package/dist/lib/registry/index.js.map +1 -1
- package/dist/lib/registry/sync.d.ts +175 -10
- package/dist/lib/registry/sync.d.ts.map +1 -1
- package/dist/lib/registry/sync.js +601 -43
- package/dist/lib/registry/sync.js.map +1 -1
- package/dist/lib/registry/types.d.ts +38 -2
- package/dist/lib/registry/types.d.ts.map +1 -1
- package/dist/lib/registry/types.generated.d.ts +231 -8
- package/dist/lib/registry/types.generated.d.ts.map +1 -1
- package/dist/lib/registry/types.generated.js +1 -1
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/server/decisioning/runtime/from-platform.d.ts.map +1 -1
- package/dist/lib/server/decisioning/runtime/from-platform.js +2 -0
- package/dist/lib/server/decisioning/runtime/from-platform.js.map +1 -1
- package/dist/lib/server/test-controller.d.ts +4 -1
- package/dist/lib/server/test-controller.d.ts.map +1 -1
- package/dist/lib/server/test-controller.js +18 -0
- package/dist/lib/server/test-controller.js.map +1 -1
- package/dist/lib/testing/comply-controller.d.ts +8 -0
- package/dist/lib/testing/comply-controller.d.ts.map +1 -1
- package/dist/lib/testing/comply-controller.js +7 -0
- package/dist/lib/testing/comply-controller.js.map +1 -1
- package/dist/lib/testing/index.d.ts +1 -1
- package/dist/lib/testing/index.d.ts.map +1 -1
- package/dist/lib/testing/index.js.map +1 -1
- package/dist/lib/testing/storyboard/seeding.d.ts +1 -1
- package/dist/lib/testing/storyboard/seeding.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/seeding.js +20 -0
- package/dist/lib/testing/storyboard/seeding.js.map +1 -1
- package/dist/lib/testing/storyboard/types.d.ts +4 -0
- package/dist/lib/testing/storyboard/types.d.ts.map +1 -1
- package/dist/lib/testing/storyboard/types.js.map +1 -1
- package/dist/lib/v2/projection/v1-to-v2.d.ts +14 -8
- package/dist/lib/v2/projection/v1-to-v2.d.ts.map +1 -1
- package/dist/lib/v2/projection/v1-to-v2.js +14 -10
- package/dist/lib/v2/projection/v1-to-v2.js.map +1 -1
- package/dist/lib/version.d.ts +3 -3
- package/dist/lib/version.js +3 -3
- package/package.json +1 -1
|
@@ -2,77 +2,168 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.RegistrySync = void 0;
|
|
4
4
|
const node_events_1 = require("node:events");
|
|
5
|
+
const feed_stream_1 = require("./feed-stream");
|
|
5
6
|
const cursor_store_1 = require("./cursor-store");
|
|
6
7
|
// ====== RegistrySync ======
|
|
7
8
|
/**
|
|
8
9
|
* In-memory replica of the AdCP registry.
|
|
9
10
|
*
|
|
10
|
-
* Bootstraps from the agent search endpoint, then
|
|
11
|
-
*
|
|
11
|
+
* Bootstraps from the agent search endpoint, then tails the change feed —
|
|
12
|
+
* Server-Sent Events by default (`transport: 'auto'`), falling back to polling
|
|
13
|
+
* `/api/registry/feed` when streaming is unavailable — to keep its indexes
|
|
14
|
+
* current for zero-latency lookups.
|
|
15
|
+
*
|
|
16
|
+
* **Staleness:** lookups (`getAgent`, `isAuthorized`, the authorization getters)
|
|
17
|
+
* return the last synced state. After a transient failure the engine keeps
|
|
18
|
+
* reconnecting/polling, but a fatal error (e.g. `401`) leaves `state === 'error'`
|
|
19
|
+
* while the indexes still hold their last values. For decisions where staleness
|
|
20
|
+
* is unsafe (e.g. authorization enforcement), gate on `state` and
|
|
21
|
+
* `getLagSeconds()` / `getFreshness()` rather than trusting a lookup blindly.
|
|
22
|
+
* Index size is bounded only by registry trust — a replica mirrors the whole
|
|
23
|
+
* registry, so an untrusted/compromised feed could grow memory without limit.
|
|
12
24
|
*
|
|
13
25
|
* @example
|
|
14
26
|
* ```ts
|
|
15
27
|
* const client = new RegistryClient({ apiKey: 'sk_...' });
|
|
16
|
-
* const sync = new RegistrySync({ client });
|
|
17
|
-
* await sync.start();
|
|
28
|
+
* const sync = new RegistrySync({ client }); // transport: 'auto' (SSE, polling fallback)
|
|
18
29
|
*
|
|
19
30
|
* // Zero-latency lookups
|
|
31
|
+
* sync.on('event', ({ event }) => console.log('registry change:', event.event_type));
|
|
32
|
+
*
|
|
33
|
+
* // Lag monitoring for the SSE feed
|
|
34
|
+
* sync.on('transport', ({ transport }) => console.log('feed transport:', transport));
|
|
35
|
+
* sync.on('freshness', ({ freshness }) => {
|
|
36
|
+
* if ((freshness.lag_seconds ?? 0) > 300) console.warn('registry feed lag > 5m');
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* await sync.start();
|
|
20
40
|
* const agent = sync.getAgent('https://ads.example.com');
|
|
21
41
|
* const authorized = sync.isAuthorized('https://ads.example.com', 'publisher.com');
|
|
22
42
|
* const ctv = sync.findAgents({ channels: ['ctv'], markets: ['US'] });
|
|
23
|
-
*
|
|
24
|
-
* sync.on('event', ({ event }) => console.log('registry change:', event.event_type));
|
|
25
43
|
* sync.stop();
|
|
26
44
|
* ```
|
|
27
45
|
*/
|
|
28
46
|
class RegistrySync extends node_events_1.EventEmitter {
|
|
29
47
|
client;
|
|
48
|
+
transportMode;
|
|
30
49
|
pollIntervalMs;
|
|
50
|
+
types;
|
|
51
|
+
feedPageLimit;
|
|
52
|
+
streamPollIntervalSeconds;
|
|
53
|
+
streamIdleTimeoutMs;
|
|
54
|
+
streamReconnectMinMs;
|
|
55
|
+
streamReconnectMaxMs;
|
|
56
|
+
maxStreamFailures;
|
|
31
57
|
indexAgents;
|
|
32
58
|
indexAuthorizations;
|
|
59
|
+
indexBrandHierarchies;
|
|
33
60
|
errorHandler;
|
|
34
61
|
cursorStore;
|
|
35
62
|
_state = 'idle';
|
|
36
63
|
cursor = null;
|
|
37
64
|
pollTimer = null;
|
|
65
|
+
// Streaming state
|
|
66
|
+
activeTransport = null;
|
|
67
|
+
streamController = null;
|
|
68
|
+
streamIdleTimer = null;
|
|
69
|
+
lastFreshness = null;
|
|
70
|
+
/**
|
|
71
|
+
* Incremented by stop()/reset() and each start(). Async work (bootstrap,
|
|
72
|
+
* rebootstrap, the stream loop) captures its generation and bails the moment
|
|
73
|
+
* it no longer matches — so a stop() that lands mid-bootstrap is honored and
|
|
74
|
+
* a stale loop can never resume after the caller stopped or restarted.
|
|
75
|
+
*/
|
|
76
|
+
generation = 0;
|
|
38
77
|
// Indexes
|
|
39
78
|
agents = new Map();
|
|
40
79
|
authByDomain = new Map();
|
|
41
80
|
authByAgent = new Map();
|
|
81
|
+
brandAncestorsByDomain = new Map();
|
|
82
|
+
brandHierarchyByDomain = new Map();
|
|
83
|
+
brandHierarchyKeysByEntity = new Map();
|
|
84
|
+
brandHierarchyEntityByKey = new Map();
|
|
42
85
|
constructor(config) {
|
|
43
86
|
super();
|
|
44
87
|
this.client = config.client;
|
|
88
|
+
this.transportMode = config.transport ?? 'auto';
|
|
45
89
|
this.pollIntervalMs = config.pollIntervalMs ?? 30_000;
|
|
90
|
+
this.types = config.types;
|
|
91
|
+
this.feedPageLimit = config.feedPageLimit ?? 1000;
|
|
92
|
+
// Fail closed on out-of-range values rather than letting the registry reject
|
|
93
|
+
// them with a 400 that the reconnect/poll loop would hot-retry forever.
|
|
94
|
+
if (!Number.isInteger(this.feedPageLimit) || this.feedPageLimit < 1 || this.feedPageLimit > 10_000) {
|
|
95
|
+
throw new Error('feedPageLimit must be an integer between 1 and 10000');
|
|
96
|
+
}
|
|
97
|
+
this.streamPollIntervalSeconds = config.streamPollIntervalSeconds ?? 15;
|
|
98
|
+
if (!Number.isInteger(this.streamPollIntervalSeconds) ||
|
|
99
|
+
this.streamPollIntervalSeconds < 5 ||
|
|
100
|
+
this.streamPollIntervalSeconds > 60) {
|
|
101
|
+
throw new Error('streamPollIntervalSeconds must be an integer between 5 and 60');
|
|
102
|
+
}
|
|
103
|
+
this.streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? 90_000;
|
|
104
|
+
this.streamReconnectMinMs = config.streamReconnectMinMs ?? 1_000;
|
|
105
|
+
this.streamReconnectMaxMs = config.streamReconnectMaxMs ?? 30_000;
|
|
106
|
+
this.maxStreamFailures = config.maxStreamFailures ?? 3;
|
|
46
107
|
this.indexAgents = config.indexes?.agents !== false;
|
|
47
108
|
this.indexAuthorizations = config.indexes?.authorizations !== false;
|
|
109
|
+
this.indexBrandHierarchies = config.indexes?.brandHierarchies !== false;
|
|
48
110
|
this.cursorStore = config.cursorStore ?? new cursor_store_1.InMemoryCursorStore();
|
|
49
111
|
this.errorHandler = config.onError;
|
|
50
112
|
}
|
|
51
113
|
// ====== Lifecycle ======
|
|
52
|
-
/** Bootstrap from the registry and begin
|
|
114
|
+
/** Bootstrap from the registry and begin tailing the event feed. */
|
|
53
115
|
async start() {
|
|
54
116
|
if (this._state === 'syncing' || this._state === 'bootstrapping')
|
|
55
117
|
return;
|
|
56
|
-
|
|
57
|
-
this.
|
|
118
|
+
const gen = ++this.generation;
|
|
119
|
+
await this.bootstrap(gen);
|
|
120
|
+
// A stop()/reset() (or another start()) during bootstrap bumps the
|
|
121
|
+
// generation; beginSync re-validates both generation and state before
|
|
122
|
+
// starting the sync loop.
|
|
123
|
+
this.beginSync(gen);
|
|
58
124
|
}
|
|
59
|
-
/** Stop
|
|
125
|
+
/** Stop tailing the feed. In-memory state is preserved. */
|
|
60
126
|
stop() {
|
|
127
|
+
// Invalidate any in-flight bootstrap/rebootstrap/stream loop, even while
|
|
128
|
+
// state is still 'bootstrapping'.
|
|
129
|
+
this.generation++;
|
|
61
130
|
if (this.pollTimer) {
|
|
62
131
|
clearTimeout(this.pollTimer);
|
|
63
132
|
this.pollTimer = null;
|
|
64
133
|
}
|
|
65
|
-
|
|
134
|
+
this.abortStream();
|
|
135
|
+
this.activeTransport = null;
|
|
136
|
+
if (this._state === 'syncing' || this._state === 'bootstrapping') {
|
|
66
137
|
this.setState('idle');
|
|
67
138
|
}
|
|
68
139
|
}
|
|
69
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Stop, clear all in-memory state, and drop the persisted cursor. Call start()
|
|
142
|
+
* again to re-bootstrap from scratch. Because this clears the stored cursor,
|
|
143
|
+
* it is the correct way to switch the `types` subscription: `reset()` then
|
|
144
|
+
* `start()` begins a fresh subscription rather than resuming a cursor minted
|
|
145
|
+
* under the previous filter.
|
|
146
|
+
*/
|
|
70
147
|
async reset() {
|
|
71
148
|
this.stop();
|
|
72
149
|
this.clearIndexes();
|
|
73
150
|
this.cursor = null;
|
|
151
|
+
this.lastFreshness = null;
|
|
152
|
+
await this.cursorStore.clearCursor();
|
|
74
153
|
this.setState('idle');
|
|
75
154
|
}
|
|
155
|
+
/** Begin tailing the feed using the configured transport. Bootstrap leaves state at 'syncing'. */
|
|
156
|
+
beginSync(gen) {
|
|
157
|
+
if (gen !== this.generation || this._state !== 'syncing')
|
|
158
|
+
return;
|
|
159
|
+
if (this.transportMode === 'poll') {
|
|
160
|
+
this.setTransport('poll');
|
|
161
|
+
this.schedulePoll(gen);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.setTransport('stream');
|
|
165
|
+
void this.runStream(gen);
|
|
166
|
+
}
|
|
76
167
|
// ====== Agent Lookups ======
|
|
77
168
|
/** Get an agent by URL. */
|
|
78
169
|
getAgent(url) {
|
|
@@ -125,11 +216,30 @@ class RegistrySync extends node_events_1.EventEmitter {
|
|
|
125
216
|
* Check if an agent has any authorization for a publisher domain.
|
|
126
217
|
* Does not evaluate property_id scoping, time bounds, or effective dates.
|
|
127
218
|
* For scoped checks, use getAuthorizationsForDomain() and inspect entries directly.
|
|
219
|
+
*
|
|
220
|
+
* Returns the last synced state — see the class-level staleness note. For
|
|
221
|
+
* enforcement, confirm `state === 'syncing'` and an acceptable `getLagSeconds()`
|
|
222
|
+
* before trusting an allow decision, so a stalled feed can't serve a stale
|
|
223
|
+
* authorization after a missed revocation.
|
|
128
224
|
*/
|
|
129
225
|
isAuthorized(agentUrl, domain) {
|
|
130
226
|
const entries = this.authByDomain.get(domain);
|
|
131
227
|
return entries != null && entries.some(e => e.agent_url === agentUrl);
|
|
132
228
|
}
|
|
229
|
+
// ====== Brand Hierarchy Lookups ======
|
|
230
|
+
/**
|
|
231
|
+
* Get the ordered corporate ancestor domain chain for a brand domain.
|
|
232
|
+
*
|
|
233
|
+
* The returned array includes the resolved brand itself as the first entry and
|
|
234
|
+
* the house domain as the last entry when known.
|
|
235
|
+
*/
|
|
236
|
+
getAncestors(domain) {
|
|
237
|
+
return [...(this.brandAncestorsByDomain.get(this.normalizeDomainKey(domain)) ?? [])];
|
|
238
|
+
}
|
|
239
|
+
/** Get the ordered resolved brand chain for a brand domain, when the feed supplied it. */
|
|
240
|
+
getBrandHierarchy(domain) {
|
|
241
|
+
return (this.brandHierarchyByDomain.get(this.normalizeDomainKey(domain)) ?? []).map(brand => ({ ...brand }));
|
|
242
|
+
}
|
|
133
243
|
// ====== State ======
|
|
134
244
|
get state() {
|
|
135
245
|
return this._state;
|
|
@@ -141,10 +251,30 @@ class RegistrySync extends node_events_1.EventEmitter {
|
|
|
141
251
|
let authCount = 0;
|
|
142
252
|
for (const entries of this.authByDomain.values())
|
|
143
253
|
authCount += entries.length;
|
|
144
|
-
return {
|
|
254
|
+
return {
|
|
255
|
+
agents: this.agents.size,
|
|
256
|
+
authorizations: authCount,
|
|
257
|
+
brandHierarchies: this.brandHierarchyKeysByEntity.size,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/** The active feed transport once syncing, or null when idle. */
|
|
261
|
+
getTransport() {
|
|
262
|
+
return this.activeTransport;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Latest feed freshness metadata, or null if the registry has not reported it
|
|
266
|
+
* yet (e.g. before the first feed page/heartbeat lands). For push updates,
|
|
267
|
+
* prefer the `freshness` event over polling this right after `start()`.
|
|
268
|
+
*/
|
|
269
|
+
getFreshness() {
|
|
270
|
+
return this.lastFreshness;
|
|
271
|
+
}
|
|
272
|
+
/** Latest feed lag in seconds, or null when unavailable. Convenience over getFreshness(). */
|
|
273
|
+
getLagSeconds() {
|
|
274
|
+
return this.lastFreshness?.lag_seconds ?? null;
|
|
145
275
|
}
|
|
146
276
|
// ====== Private: Bootstrap ======
|
|
147
|
-
async bootstrap() {
|
|
277
|
+
async bootstrap(gen) {
|
|
148
278
|
this.setState('bootstrapping');
|
|
149
279
|
try {
|
|
150
280
|
// Restore cursor from store if available
|
|
@@ -167,7 +297,10 @@ class RegistrySync extends node_events_1.EventEmitter {
|
|
|
167
297
|
} while (cursor);
|
|
168
298
|
}
|
|
169
299
|
// Get initial feed cursor and apply any events
|
|
170
|
-
await this.drainFeed();
|
|
300
|
+
await this.drainFeed(gen);
|
|
301
|
+
// A stop()/reset() landed mid-bootstrap: do not flip to 'syncing' or emit.
|
|
302
|
+
if (gen !== this.generation)
|
|
303
|
+
return;
|
|
171
304
|
this.setState('syncing');
|
|
172
305
|
this.emit('bootstrap', {
|
|
173
306
|
agentCount: this.agents.size,
|
|
@@ -175,44 +308,61 @@ class RegistrySync extends node_events_1.EventEmitter {
|
|
|
175
308
|
});
|
|
176
309
|
}
|
|
177
310
|
catch (err) {
|
|
178
|
-
this.setState('error');
|
|
179
311
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
312
|
+
// If we were stopped mid-bootstrap, swallow the abort rather than parking
|
|
313
|
+
// the engine in 'error'.
|
|
314
|
+
if (gen !== this.generation)
|
|
315
|
+
return;
|
|
316
|
+
this.setState('error');
|
|
180
317
|
this.errorHandler?.(error);
|
|
181
318
|
this.emit('error', { error });
|
|
182
319
|
throw error;
|
|
183
320
|
}
|
|
184
321
|
}
|
|
322
|
+
/** Clear all state, drop the persisted cursor, and bootstrap again from scratch. */
|
|
323
|
+
async rebootstrap(gen) {
|
|
324
|
+
this.clearIndexes();
|
|
325
|
+
this.cursor = null;
|
|
326
|
+
await this.cursorStore.clearCursor();
|
|
327
|
+
await this.bootstrap(gen);
|
|
328
|
+
}
|
|
185
329
|
// ====== Private: Polling ======
|
|
186
|
-
schedulePoll() {
|
|
187
|
-
this.pollTimer = setTimeout(() => this.pollLoop(), this.pollIntervalMs);
|
|
330
|
+
schedulePoll(gen) {
|
|
331
|
+
this.pollTimer = setTimeout(() => this.pollLoop(gen), this.pollIntervalMs);
|
|
188
332
|
}
|
|
189
|
-
async pollLoop() {
|
|
333
|
+
async pollLoop(gen) {
|
|
334
|
+
if (gen !== this.generation)
|
|
335
|
+
return;
|
|
190
336
|
try {
|
|
191
|
-
await this.poll();
|
|
337
|
+
await this.poll(gen);
|
|
192
338
|
}
|
|
193
339
|
catch (err) {
|
|
194
340
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
195
341
|
this.emit('error', { error });
|
|
196
342
|
this.errorHandler?.(error);
|
|
197
343
|
}
|
|
198
|
-
// Schedule next poll even after errors (will retry), unless stopped
|
|
199
|
-
if (this._state === 'syncing') {
|
|
200
|
-
this.schedulePoll();
|
|
344
|
+
// Schedule next poll even after errors (will retry), unless stopped/superseded.
|
|
345
|
+
if (gen === this.generation && this._state === 'syncing') {
|
|
346
|
+
this.schedulePoll(gen);
|
|
201
347
|
}
|
|
202
348
|
}
|
|
203
|
-
async poll() {
|
|
349
|
+
async poll(gen) {
|
|
204
350
|
let totalEventsApplied = 0;
|
|
205
351
|
let hasMore = true;
|
|
206
352
|
while (hasMore) {
|
|
207
|
-
const feed = await this.client.getFeed(
|
|
208
|
-
cursor: this.cursor ?? undefined,
|
|
209
|
-
limit: 1000,
|
|
210
|
-
});
|
|
353
|
+
const feed = await this.client.getFeed(this.feedQuery());
|
|
211
354
|
if (feed.cursor_expired) {
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
355
|
+
// Cursor aged out of retention: drop state and re-bootstrap, then resume.
|
|
356
|
+
try {
|
|
357
|
+
await this.rebootstrap(gen);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// bootstrap() already emitted 'error' and parked state in 'error'.
|
|
361
|
+
// Restore 'syncing' so pollLoop reschedules and retries (mirrors the
|
|
362
|
+
// stream path, which also keeps trying after a failed rebootstrap).
|
|
363
|
+
if (gen === this.generation && this._state === 'error')
|
|
364
|
+
this.setState('syncing');
|
|
365
|
+
}
|
|
216
366
|
return;
|
|
217
367
|
}
|
|
218
368
|
for (const event of feed.events) {
|
|
@@ -220,14 +370,13 @@ class RegistrySync extends node_events_1.EventEmitter {
|
|
|
220
370
|
this.emit('event', { event });
|
|
221
371
|
totalEventsApplied++;
|
|
222
372
|
}
|
|
373
|
+
this.observeFreshness(feed.freshness);
|
|
223
374
|
if (feed.cursor) {
|
|
224
375
|
this.cursor = feed.cursor;
|
|
225
376
|
}
|
|
226
|
-
hasMore = feed.has_more &&
|
|
227
|
-
}
|
|
228
|
-
if (this.cursor) {
|
|
229
|
-
await this.cursorStore.setCursor(this.cursor);
|
|
377
|
+
hasMore = feed.has_more && feed.cursor != null;
|
|
230
378
|
}
|
|
379
|
+
await this.persistCursor();
|
|
231
380
|
if (totalEventsApplied > 0) {
|
|
232
381
|
this.emit('sync', { cursor: this.cursor, eventsApplied: totalEventsApplied });
|
|
233
382
|
}
|
|
@@ -235,23 +384,314 @@ class RegistrySync extends node_events_1.EventEmitter {
|
|
|
235
384
|
/**
|
|
236
385
|
* Drain all available feed pages. Used during bootstrap (does not emit 'event' per event).
|
|
237
386
|
*/
|
|
238
|
-
async drainFeed() {
|
|
387
|
+
async drainFeed(gen) {
|
|
239
388
|
let hasMore = true;
|
|
389
|
+
let recoveredFromExpiry = false;
|
|
240
390
|
while (hasMore) {
|
|
241
|
-
const feed = await this.client.getFeed(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
391
|
+
const feed = await this.client.getFeed(this.feedQuery());
|
|
392
|
+
if (feed.cursor_expired) {
|
|
393
|
+
// Stored cursor aged out of retention: drop it and retry once from the
|
|
394
|
+
// start of the window. A second expiry (now cursor-less) means a
|
|
395
|
+
// misbehaving server — stop draining rather than hot-looping.
|
|
396
|
+
this.cursor = null;
|
|
397
|
+
await this.cursorStore.clearCursor();
|
|
398
|
+
if (recoveredFromExpiry)
|
|
399
|
+
break;
|
|
400
|
+
recoveredFromExpiry = true;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
245
403
|
for (const event of feed.events) {
|
|
246
404
|
this.applyEvent(event);
|
|
247
405
|
}
|
|
248
|
-
this.
|
|
249
|
-
|
|
406
|
+
this.observeFreshness(feed.freshness);
|
|
407
|
+
if (feed.cursor)
|
|
408
|
+
this.cursor = feed.cursor;
|
|
409
|
+
hasMore = feed.has_more && feed.cursor != null;
|
|
410
|
+
}
|
|
411
|
+
// A stop()/reset() during the drain bumps the generation — don't persist a
|
|
412
|
+
// cursor a later start() would read back.
|
|
413
|
+
if (gen === this.generation)
|
|
414
|
+
await this.persistCursor();
|
|
415
|
+
}
|
|
416
|
+
// ====== Private: Streaming ======
|
|
417
|
+
async runStream(gen) {
|
|
418
|
+
// `consecutiveFailures` drives 'auto' polling fallback. It counts connections
|
|
419
|
+
// that ended in a transport/parse failure — a heartbeat does NOT reset it
|
|
420
|
+
// (else heartbeat → malformed-frame → reconnect could loop forever and never
|
|
421
|
+
// fall back, despite the parse-failure fallback contract). It resets only on
|
|
422
|
+
// real feed progress or a clean close that delivered something.
|
|
423
|
+
// `consecutiveRebootstraps` bounds a tight cursor_expired loop.
|
|
424
|
+
let consecutiveFailures = 0;
|
|
425
|
+
let consecutiveRebootstraps = 0;
|
|
426
|
+
while (gen === this.generation && this.transportMode !== 'poll') {
|
|
427
|
+
const controller = new AbortController();
|
|
428
|
+
this.streamController = controller;
|
|
429
|
+
let feedApplied = false;
|
|
430
|
+
let receivedAny = false;
|
|
431
|
+
let disposition;
|
|
432
|
+
try {
|
|
433
|
+
disposition = await this.streamConnection(gen, controller.signal, isFeed => {
|
|
434
|
+
receivedAny = true;
|
|
435
|
+
if (isFeed)
|
|
436
|
+
feedApplied = true;
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
// Aborted by stop()/reset() (which bumps the generation): exit quietly.
|
|
441
|
+
if (controller.signal.aborted && gen !== this.generation) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
445
|
+
disposition = this.classifyStreamError(error);
|
|
446
|
+
if (disposition === 'reconnect' || disposition === 'fatal') {
|
|
447
|
+
this.emit('error', { error });
|
|
448
|
+
this.errorHandler?.(error);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
this.clearStreamIdleTimer();
|
|
453
|
+
}
|
|
454
|
+
if (gen !== this.generation)
|
|
455
|
+
return;
|
|
456
|
+
// Applying a feed page proves the stream works end to end.
|
|
457
|
+
if (feedApplied) {
|
|
458
|
+
consecutiveFailures = 0;
|
|
459
|
+
consecutiveRebootstraps = 0;
|
|
460
|
+
}
|
|
461
|
+
if (disposition === 'stopped')
|
|
462
|
+
return;
|
|
463
|
+
if (disposition === 'fatal') {
|
|
464
|
+
// Permanent error (e.g. 400/401): retrying cannot help. Park in 'error'.
|
|
465
|
+
this.setState('error');
|
|
466
|
+
this.activeTransport = null;
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (disposition === 'rebootstrap') {
|
|
470
|
+
let ok = false;
|
|
471
|
+
try {
|
|
472
|
+
await this.rebootstrap(gen);
|
|
473
|
+
ok = true;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// bootstrap() already emitted 'error' and parked state in 'error'.
|
|
477
|
+
}
|
|
478
|
+
if (gen !== this.generation)
|
|
479
|
+
return;
|
|
480
|
+
if (!ok) {
|
|
481
|
+
consecutiveFailures++;
|
|
482
|
+
if (this.shouldFallBack(consecutiveFailures)) {
|
|
483
|
+
this.fallBackToPolling(gen);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
// Recover: bootstrap left us in 'error'. In 'stream' mode the contract
|
|
487
|
+
// is to keep reconnecting, so restore 'syncing' and back off.
|
|
488
|
+
this.setState('syncing');
|
|
489
|
+
await this.delay(this.reconnectBackoffMs(consecutiveFailures), controller.signal);
|
|
490
|
+
if (gen !== this.generation)
|
|
491
|
+
return;
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
// Successful re-bootstrap. Back off proportionally to repeated expiries
|
|
495
|
+
// so a server stuck emitting cursor_expired can't drive a tight loop.
|
|
496
|
+
consecutiveRebootstraps++;
|
|
497
|
+
await this.delay(this.reconnectBackoffMs(consecutiveRebootstraps), controller.signal);
|
|
498
|
+
if (gen !== this.generation)
|
|
499
|
+
return;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (disposition === 'fallback') {
|
|
503
|
+
this.fallBackToPolling(gen);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (disposition === 'closed' && receivedAny) {
|
|
507
|
+
// Clean close after real activity (feed or heartbeat): the transport
|
|
508
|
+
// works; reconnect promptly without counting it as a failure.
|
|
509
|
+
consecutiveFailures = 0;
|
|
510
|
+
await this.delay(this.reconnectBackoffMs(0), controller.signal);
|
|
511
|
+
if (gen !== this.generation)
|
|
512
|
+
return;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
// 'reconnect' (transport/parse failure or server feed_stream_error), or a
|
|
516
|
+
// clean close that delivered nothing — count toward fallback so 'auto'
|
|
517
|
+
// degrades to polling rather than looping on a stream that never delivers.
|
|
518
|
+
consecutiveFailures++;
|
|
519
|
+
if (this.shouldFallBack(consecutiveFailures)) {
|
|
520
|
+
this.fallBackToPolling(gen);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
await this.delay(this.reconnectBackoffMs(consecutiveFailures), controller.signal);
|
|
524
|
+
if (gen !== this.generation)
|
|
525
|
+
return;
|
|
250
526
|
}
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Consume one SSE connection. Invokes `onMessage(isFeed)` for each feed page
|
|
530
|
+
* (true) or heartbeat (false) received. Returns the disposition for the
|
|
531
|
+
* reconnect loop: `closed` on a clean server EOF, `reconnect` on a server
|
|
532
|
+
* `feed_stream_error`, `rebootstrap` on `cursor_expired`, `stopped` if the
|
|
533
|
+
* engine was stopped mid-stream.
|
|
534
|
+
*/
|
|
535
|
+
async streamConnection(gen, signal, onMessage) {
|
|
536
|
+
this.armStreamIdleTimer();
|
|
537
|
+
const query = {
|
|
538
|
+
cursor: this.cursor ?? undefined,
|
|
539
|
+
types: this.types,
|
|
540
|
+
limit: this.feedPageLimit,
|
|
541
|
+
pollIntervalSeconds: this.streamPollIntervalSeconds,
|
|
542
|
+
};
|
|
543
|
+
for await (const msg of this.client.streamFeed(query, { signal })) {
|
|
544
|
+
if (gen !== this.generation)
|
|
545
|
+
return 'stopped';
|
|
546
|
+
this.armStreamIdleTimer();
|
|
547
|
+
if (msg.type === 'feed') {
|
|
548
|
+
onMessage(true);
|
|
549
|
+
await this.applyStreamPage(msg.page);
|
|
550
|
+
}
|
|
551
|
+
else if (msg.type === 'heartbeat') {
|
|
552
|
+
onMessage(false);
|
|
553
|
+
// Heartbeats keep the connection alive and expose freshness; they do NOT
|
|
554
|
+
// advance the cursor and do NOT count as feed progress for fallback.
|
|
555
|
+
this.observeFreshness(msg.heartbeat.freshness);
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
// 'error' — the server closes the stream after this frame.
|
|
559
|
+
if (msg.error.error === 'cursor_expired') {
|
|
560
|
+
return 'rebootstrap';
|
|
561
|
+
}
|
|
562
|
+
// Registry-supplied strings are untrusted: escape before logging/emitting.
|
|
563
|
+
const code = (0, feed_stream_1.sanitizeStreamText)(msg.error.error);
|
|
564
|
+
const detail = msg.error.message ? ` (${(0, feed_stream_1.sanitizeStreamText)(msg.error.message)})` : '';
|
|
565
|
+
const error = new Error(`registry feed stream error: ${code}${detail}`);
|
|
566
|
+
this.emit('error', { error });
|
|
567
|
+
this.errorHandler?.(error);
|
|
568
|
+
return 'reconnect';
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Stream closed cleanly by the server — reconnect from the last cursor.
|
|
572
|
+
return 'closed';
|
|
573
|
+
}
|
|
574
|
+
/** Apply one SSE feed page: events, freshness, then advance + persist the cursor. */
|
|
575
|
+
async applyStreamPage(page) {
|
|
576
|
+
let applied = 0;
|
|
577
|
+
for (const event of page.events) {
|
|
578
|
+
this.applyEvent(event);
|
|
579
|
+
this.emit('event', { event });
|
|
580
|
+
applied++;
|
|
581
|
+
}
|
|
582
|
+
this.observeFreshness(page.freshness);
|
|
583
|
+
// Advance the cursor only after the full page is applied, so a mid-page
|
|
584
|
+
// disconnect resumes from the last fully-applied page.
|
|
585
|
+
if (page.cursor) {
|
|
586
|
+
this.cursor = page.cursor;
|
|
587
|
+
try {
|
|
588
|
+
await this.persistCursor();
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
// Persisting is best-effort; replay after restart is idempotent.
|
|
592
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
593
|
+
this.emit('error', { error });
|
|
594
|
+
this.errorHandler?.(error);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (applied > 0) {
|
|
598
|
+
this.emit('sync', { cursor: this.cursor, eventsApplied: applied });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
classifyStreamError(error) {
|
|
602
|
+
if (error instanceof feed_stream_1.FeedStreamCursorExpiredError)
|
|
603
|
+
return 'rebootstrap';
|
|
604
|
+
if (error instanceof feed_stream_1.FeedStreamUnsupportedError) {
|
|
605
|
+
// Endpoint absent (older registry) or a proxy returned non-stream content.
|
|
606
|
+
return this.transportMode === 'auto' ? 'fallback' : 'reconnect';
|
|
607
|
+
}
|
|
608
|
+
if (error instanceof feed_stream_1.FeedStreamHttpError && (error.status === 400 || error.status === 401)) {
|
|
609
|
+
// Permanent client-side error: a malformed request or bad credentials.
|
|
610
|
+
// Retrying (or polling, which hits the same status) cannot recover.
|
|
611
|
+
return 'fatal';
|
|
612
|
+
}
|
|
613
|
+
// Other HTTP errors, parse failures, idle timeouts, network/abort errors.
|
|
614
|
+
return 'reconnect';
|
|
615
|
+
}
|
|
616
|
+
shouldFallBack(consecutiveFailures) {
|
|
617
|
+
return this.transportMode === 'auto' && consecutiveFailures >= this.maxStreamFailures;
|
|
618
|
+
}
|
|
619
|
+
fallBackToPolling(gen) {
|
|
620
|
+
if (gen !== this.generation || this._state !== 'syncing')
|
|
621
|
+
return;
|
|
622
|
+
this.abortStream();
|
|
623
|
+
this.setTransport('poll');
|
|
624
|
+
this.schedulePoll(gen);
|
|
625
|
+
}
|
|
626
|
+
armStreamIdleTimer() {
|
|
627
|
+
this.clearStreamIdleTimer();
|
|
628
|
+
const controller = this.streamController;
|
|
629
|
+
if (!controller)
|
|
630
|
+
return;
|
|
631
|
+
this.streamIdleTimer = setTimeout(() => {
|
|
632
|
+
controller.abort(new Error('registry feed stream idle timeout'));
|
|
633
|
+
}, this.streamIdleTimeoutMs);
|
|
634
|
+
this.streamIdleTimer.unref?.();
|
|
635
|
+
}
|
|
636
|
+
clearStreamIdleTimer() {
|
|
637
|
+
if (this.streamIdleTimer) {
|
|
638
|
+
clearTimeout(this.streamIdleTimer);
|
|
639
|
+
this.streamIdleTimer = null;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
abortStream() {
|
|
643
|
+
this.clearStreamIdleTimer();
|
|
644
|
+
if (this.streamController) {
|
|
645
|
+
this.streamController.abort();
|
|
646
|
+
this.streamController = null;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
reconnectBackoffMs(attempt) {
|
|
650
|
+
const exp = this.streamReconnectMinMs * 2 ** Math.max(0, attempt - 1);
|
|
651
|
+
return Math.min(this.streamReconnectMaxMs, Math.max(this.streamReconnectMinMs, exp));
|
|
652
|
+
}
|
|
653
|
+
delay(ms, signal) {
|
|
654
|
+
if (signal.aborted)
|
|
655
|
+
return Promise.resolve();
|
|
656
|
+
return new Promise(resolve => {
|
|
657
|
+
const onAbort = () => {
|
|
658
|
+
clearTimeout(timer);
|
|
659
|
+
resolve();
|
|
660
|
+
};
|
|
661
|
+
const timer = setTimeout(() => {
|
|
662
|
+
signal.removeEventListener('abort', onAbort);
|
|
663
|
+
resolve();
|
|
664
|
+
}, ms);
|
|
665
|
+
timer.unref?.();
|
|
666
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
// ====== Private: Shared feed helpers ======
|
|
670
|
+
feedQuery() {
|
|
671
|
+
const query = { limit: this.feedPageLimit };
|
|
672
|
+
if (this.cursor)
|
|
673
|
+
query.cursor = this.cursor;
|
|
674
|
+
if (this.types)
|
|
675
|
+
query.types = this.types;
|
|
676
|
+
return query;
|
|
677
|
+
}
|
|
678
|
+
observeFreshness(freshness) {
|
|
679
|
+
if (!freshness)
|
|
680
|
+
return;
|
|
681
|
+
this.lastFreshness = freshness;
|
|
682
|
+
this.emit('freshness', { freshness });
|
|
683
|
+
}
|
|
684
|
+
async persistCursor() {
|
|
251
685
|
if (this.cursor) {
|
|
252
686
|
await this.cursorStore.setCursor(this.cursor);
|
|
253
687
|
}
|
|
254
688
|
}
|
|
689
|
+
setTransport(transport) {
|
|
690
|
+
if (this.activeTransport === transport)
|
|
691
|
+
return;
|
|
692
|
+
this.activeTransport = transport;
|
|
693
|
+
this.emit('transport', { transport });
|
|
694
|
+
}
|
|
255
695
|
// ====== Private: Event Application ======
|
|
256
696
|
applyEvent(event) {
|
|
257
697
|
const payload = event.payload;
|
|
@@ -367,6 +807,19 @@ class RegistrySync extends node_events_1.EventEmitter {
|
|
|
367
807
|
});
|
|
368
808
|
break;
|
|
369
809
|
}
|
|
810
|
+
case 'brand.hierarchy_updated':
|
|
811
|
+
case 'brand.updated':
|
|
812
|
+
case 'brand.resolved': {
|
|
813
|
+
if (this.indexBrandHierarchies)
|
|
814
|
+
this.applyBrandHierarchyEvent(event.entity_id, payload);
|
|
815
|
+
break;
|
|
816
|
+
}
|
|
817
|
+
case 'brand.removed':
|
|
818
|
+
case 'brand.deleted': {
|
|
819
|
+
if (this.indexBrandHierarchies)
|
|
820
|
+
this.deleteBrandHierarchy(event.entity_id, payload);
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
370
823
|
// Property and publisher events: no-op for v1 (no property index yet)
|
|
371
824
|
default:
|
|
372
825
|
break;
|
|
@@ -377,6 +830,111 @@ class RegistrySync extends node_events_1.EventEmitter {
|
|
|
377
830
|
this.agents.clear();
|
|
378
831
|
this.authByDomain.clear();
|
|
379
832
|
this.authByAgent.clear();
|
|
833
|
+
this.brandAncestorsByDomain.clear();
|
|
834
|
+
this.brandHierarchyByDomain.clear();
|
|
835
|
+
this.brandHierarchyKeysByEntity.clear();
|
|
836
|
+
this.brandHierarchyEntityByKey.clear();
|
|
837
|
+
}
|
|
838
|
+
applyBrandHierarchyEvent(entityId, payload) {
|
|
839
|
+
const resolvedChain = this.extractResolvedBrandChain(payload.chain);
|
|
840
|
+
const domainChain = resolvedChain.length > 0
|
|
841
|
+
? resolvedChain.map(brand => this.domainFromBrand(brand)).filter((domain) => domain != null)
|
|
842
|
+
: (this.extractDomainChain(payload.chain) ??
|
|
843
|
+
this.extractDomainChain(payload.ancestor_domains) ??
|
|
844
|
+
this.extractDomainChain(payload.domains));
|
|
845
|
+
if (!domainChain || domainChain.length === 0)
|
|
846
|
+
return;
|
|
847
|
+
const keys = new Set();
|
|
848
|
+
keys.add(this.normalizeDomainKey(entityId));
|
|
849
|
+
for (const field of ['domain', 'canonical_domain', 'canonical_id']) {
|
|
850
|
+
const value = payload[field];
|
|
851
|
+
if (typeof value === 'string' && value.trim())
|
|
852
|
+
keys.add(this.normalizeDomainKey(value));
|
|
853
|
+
}
|
|
854
|
+
keys.add(this.normalizeDomainKey(domainChain[0]));
|
|
855
|
+
const entityKey = this.normalizeDomainKey(entityId);
|
|
856
|
+
const entitiesToClear = new Set([entityKey]);
|
|
857
|
+
for (const key of keys) {
|
|
858
|
+
const existingEntity = this.brandHierarchyEntityByKey.get(key);
|
|
859
|
+
if (existingEntity)
|
|
860
|
+
entitiesToClear.add(existingEntity);
|
|
861
|
+
}
|
|
862
|
+
for (const key of entitiesToClear)
|
|
863
|
+
this.clearBrandHierarchyEntity(key);
|
|
864
|
+
for (const key of keys) {
|
|
865
|
+
this.brandAncestorsByDomain.set(key, [...domainChain]);
|
|
866
|
+
if (resolvedChain.length > 0)
|
|
867
|
+
this.brandHierarchyByDomain.set(key, resolvedChain.map(brand => ({ ...brand })));
|
|
868
|
+
else
|
|
869
|
+
this.brandHierarchyByDomain.delete(key);
|
|
870
|
+
this.brandHierarchyEntityByKey.set(key, entityKey);
|
|
871
|
+
}
|
|
872
|
+
this.brandHierarchyKeysByEntity.set(entityKey, keys);
|
|
873
|
+
}
|
|
874
|
+
deleteBrandHierarchy(entityId, payload) {
|
|
875
|
+
const keys = new Set([this.normalizeDomainKey(entityId)]);
|
|
876
|
+
for (const field of ['domain', 'canonical_domain', 'canonical_id']) {
|
|
877
|
+
const value = payload[field];
|
|
878
|
+
if (typeof value === 'string' && value.trim())
|
|
879
|
+
keys.add(this.normalizeDomainKey(value));
|
|
880
|
+
}
|
|
881
|
+
const entitiesToClear = new Set();
|
|
882
|
+
for (const key of keys) {
|
|
883
|
+
const existingEntity = this.brandHierarchyEntityByKey.get(key);
|
|
884
|
+
if (existingEntity)
|
|
885
|
+
entitiesToClear.add(existingEntity);
|
|
886
|
+
}
|
|
887
|
+
if (entitiesToClear.size > 0) {
|
|
888
|
+
for (const key of entitiesToClear)
|
|
889
|
+
this.clearBrandHierarchyEntity(key);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
for (const key of keys) {
|
|
893
|
+
this.brandAncestorsByDomain.delete(key);
|
|
894
|
+
this.brandHierarchyByDomain.delete(key);
|
|
895
|
+
this.brandHierarchyEntityByKey.delete(key);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
clearBrandHierarchyEntity(entityKey) {
|
|
899
|
+
const keys = this.brandHierarchyKeysByEntity.get(entityKey);
|
|
900
|
+
if (!keys)
|
|
901
|
+
return;
|
|
902
|
+
for (const key of keys) {
|
|
903
|
+
this.brandAncestorsByDomain.delete(key);
|
|
904
|
+
this.brandHierarchyByDomain.delete(key);
|
|
905
|
+
this.brandHierarchyEntityByKey.delete(key);
|
|
906
|
+
}
|
|
907
|
+
this.brandHierarchyKeysByEntity.delete(entityKey);
|
|
908
|
+
}
|
|
909
|
+
extractResolvedBrandChain(value) {
|
|
910
|
+
if (!Array.isArray(value))
|
|
911
|
+
return [];
|
|
912
|
+
if (!value.every(item => item && typeof item === 'object' && !Array.isArray(item)))
|
|
913
|
+
return [];
|
|
914
|
+
return value.filter(item => typeof item.canonical_domain === 'string');
|
|
915
|
+
}
|
|
916
|
+
extractDomainChain(value) {
|
|
917
|
+
if (!Array.isArray(value))
|
|
918
|
+
return null;
|
|
919
|
+
const domains = [];
|
|
920
|
+
for (const item of value) {
|
|
921
|
+
if (typeof item === 'string' && item.trim()) {
|
|
922
|
+
domains.push(item);
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
926
|
+
const domain = this.domainFromBrand(item);
|
|
927
|
+
if (domain)
|
|
928
|
+
domains.push(domain);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return domains.length > 0 ? domains : null;
|
|
932
|
+
}
|
|
933
|
+
domainFromBrand(brand) {
|
|
934
|
+
return brand.canonical_domain ?? brand.canonical_id ?? null;
|
|
935
|
+
}
|
|
936
|
+
normalizeDomainKey(domain) {
|
|
937
|
+
return domain.trim().toLowerCase();
|
|
380
938
|
}
|
|
381
939
|
setState(next) {
|
|
382
940
|
const from = this._state;
|