@adcp/sdk 9.2.2 → 9.4.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.
Files changed (49) hide show
  1. package/README.md +23 -0
  2. package/dist/lib/discovery/inline-publisher-properties.js +10 -3
  3. package/dist/lib/discovery/inline-publisher-properties.js.map +1 -1
  4. package/dist/lib/discovery/resolve-agent-properties.d.ts +39 -11
  5. package/dist/lib/discovery/resolve-agent-properties.d.ts.map +1 -1
  6. package/dist/lib/discovery/resolve-agent-properties.js +136 -16
  7. package/dist/lib/discovery/resolve-agent-properties.js.map +1 -1
  8. package/dist/lib/discovery/types.d.ts +9 -2
  9. package/dist/lib/discovery/types.d.ts.map +1 -1
  10. package/dist/lib/discovery/validate-adagents.d.ts +2 -0
  11. package/dist/lib/discovery/validate-adagents.d.ts.map +1 -1
  12. package/dist/lib/discovery/validate-adagents.js +14 -6
  13. package/dist/lib/discovery/validate-adagents.js.map +1 -1
  14. package/dist/lib/index.d.ts +4 -3
  15. package/dist/lib/index.d.ts.map +1 -1
  16. package/dist/lib/index.js +24 -12
  17. package/dist/lib/index.js.map +1 -1
  18. package/dist/lib/registry/feed-stream.d.ts +147 -0
  19. package/dist/lib/registry/feed-stream.d.ts.map +1 -0
  20. package/dist/lib/registry/feed-stream.js +378 -0
  21. package/dist/lib/registry/feed-stream.js.map +1 -0
  22. package/dist/lib/registry/index.d.ts +52 -3
  23. package/dist/lib/registry/index.d.ts.map +1 -1
  24. package/dist/lib/registry/index.js +205 -2
  25. package/dist/lib/registry/index.js.map +1 -1
  26. package/dist/lib/registry/sync.d.ts +175 -10
  27. package/dist/lib/registry/sync.d.ts.map +1 -1
  28. package/dist/lib/registry/sync.js +601 -43
  29. package/dist/lib/registry/sync.js.map +1 -1
  30. package/dist/lib/registry/types.d.ts +38 -2
  31. package/dist/lib/registry/types.d.ts.map +1 -1
  32. package/dist/lib/registry/types.generated.d.ts +231 -8
  33. package/dist/lib/registry/types.generated.d.ts.map +1 -1
  34. package/dist/lib/registry/types.generated.js +1 -1
  35. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  36. package/dist/lib/signing/agent-fetch.d.ts +5 -4
  37. package/dist/lib/signing/agent-fetch.d.ts.map +1 -1
  38. package/dist/lib/signing/agent-fetch.js +31 -4
  39. package/dist/lib/signing/agent-fetch.js.map +1 -1
  40. package/dist/lib/signing/verifier.d.ts.map +1 -1
  41. package/dist/lib/signing/verifier.js +10 -37
  42. package/dist/lib/signing/verifier.js.map +1 -1
  43. package/dist/lib/signing/webhook-auth-detection.d.ts +10 -0
  44. package/dist/lib/signing/webhook-auth-detection.d.ts.map +1 -0
  45. package/dist/lib/signing/webhook-auth-detection.js +37 -0
  46. package/dist/lib/signing/webhook-auth-detection.js.map +1 -0
  47. package/dist/lib/version.d.ts +3 -3
  48. package/dist/lib/version.js +3 -3
  49. 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 polls the event feed
11
- * to maintain up-to-date indexes for zero-latency lookups.
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 polling the event feed. */
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
- await this.bootstrap();
57
- this.schedulePoll();
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 polling. In-memory state is preserved. */
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
- if (this._state === 'syncing') {
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
- /** Stop, clear all state. Call start() again to re-bootstrap. */
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 { agents: this.agents.size, authorizations: authCount };
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
- // Clear state and re-bootstrap inline; the existing poll loop continues
213
- this.clearIndexes();
214
- this.cursor = null;
215
- await this.bootstrap();
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 && this.cursor != null;
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
- cursor: this.cursor ?? undefined,
243
- limit: 1000,
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.cursor = feed.cursor;
249
- hasMore = feed.has_more && this.cursor != null;
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;