@anby/platform-sdk 0.1.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 (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/dist/cjs/apps/publish.d.ts +73 -0
  4. package/dist/cjs/apps/publish.d.ts.map +1 -0
  5. package/dist/cjs/apps/publish.js +166 -0
  6. package/dist/cjs/apps/publish.js.map +1 -0
  7. package/dist/cjs/auth/index.d.ts +22 -0
  8. package/dist/cjs/auth/index.d.ts.map +1 -0
  9. package/dist/cjs/auth/index.js +101 -0
  10. package/dist/cjs/auth/index.js.map +1 -0
  11. package/dist/cjs/config/index.d.ts +8 -0
  12. package/dist/cjs/config/index.d.ts.map +1 -0
  13. package/dist/cjs/config/index.js +14 -0
  14. package/dist/cjs/config/index.js.map +1 -0
  15. package/dist/cjs/entities/cache.d.ts +31 -0
  16. package/dist/cjs/entities/cache.d.ts.map +1 -0
  17. package/dist/cjs/entities/cache.js +84 -0
  18. package/dist/cjs/entities/cache.js.map +1 -0
  19. package/dist/cjs/entities/client.d.ts +32 -0
  20. package/dist/cjs/entities/client.d.ts.map +1 -0
  21. package/dist/cjs/entities/client.js +130 -0
  22. package/dist/cjs/entities/client.js.map +1 -0
  23. package/dist/cjs/entities/errors.d.ts +35 -0
  24. package/dist/cjs/entities/errors.d.ts.map +1 -0
  25. package/dist/cjs/entities/errors.js +68 -0
  26. package/dist/cjs/entities/errors.js.map +1 -0
  27. package/dist/cjs/entities/handler.d.ts +89 -0
  28. package/dist/cjs/entities/handler.d.ts.map +1 -0
  29. package/dist/cjs/entities/handler.js +332 -0
  30. package/dist/cjs/entities/handler.js.map +1 -0
  31. package/dist/cjs/entities/identity.d.ts +90 -0
  32. package/dist/cjs/entities/identity.d.ts.map +1 -0
  33. package/dist/cjs/entities/identity.js +180 -0
  34. package/dist/cjs/entities/identity.js.map +1 -0
  35. package/dist/cjs/entities/index.d.ts +12 -0
  36. package/dist/cjs/entities/index.d.ts.map +1 -0
  37. package/dist/cjs/entities/index.js +64 -0
  38. package/dist/cjs/entities/index.js.map +1 -0
  39. package/dist/cjs/entities/redis.d.ts +128 -0
  40. package/dist/cjs/entities/redis.d.ts.map +1 -0
  41. package/dist/cjs/entities/redis.js +354 -0
  42. package/dist/cjs/entities/redis.js.map +1 -0
  43. package/dist/cjs/entities/resolver.d.ts +25 -0
  44. package/dist/cjs/entities/resolver.d.ts.map +1 -0
  45. package/dist/cjs/entities/resolver.js +128 -0
  46. package/dist/cjs/entities/resolver.js.map +1 -0
  47. package/dist/cjs/entities/schema.d.ts +15 -0
  48. package/dist/cjs/entities/schema.d.ts.map +1 -0
  49. package/dist/cjs/entities/schema.js +140 -0
  50. package/dist/cjs/entities/schema.js.map +1 -0
  51. package/dist/cjs/entities/scopedDb.d.ts +92 -0
  52. package/dist/cjs/entities/scopedDb.d.ts.map +1 -0
  53. package/dist/cjs/entities/scopedDb.js +96 -0
  54. package/dist/cjs/entities/scopedDb.js.map +1 -0
  55. package/dist/cjs/entities/token.d.ts +18 -0
  56. package/dist/cjs/entities/token.d.ts.map +1 -0
  57. package/dist/cjs/entities/token.js +87 -0
  58. package/dist/cjs/entities/token.js.map +1 -0
  59. package/dist/cjs/entities/types.d.ts +90 -0
  60. package/dist/cjs/entities/types.d.ts.map +1 -0
  61. package/dist/cjs/entities/types.js +33 -0
  62. package/dist/cjs/entities/types.js.map +1 -0
  63. package/dist/cjs/events/index.d.ts +39 -0
  64. package/dist/cjs/events/index.d.ts.map +1 -0
  65. package/dist/cjs/events/index.js +112 -0
  66. package/dist/cjs/events/index.js.map +1 -0
  67. package/dist/cjs/index.d.ts +7 -0
  68. package/dist/cjs/index.d.ts.map +1 -0
  69. package/dist/cjs/index.js +44 -0
  70. package/dist/cjs/index.js.map +1 -0
  71. package/dist/cjs/preview.d.ts +28 -0
  72. package/dist/cjs/preview.d.ts.map +1 -0
  73. package/dist/cjs/preview.js +49 -0
  74. package/dist/cjs/preview.js.map +1 -0
  75. package/dist/esm/apps/publish.js +162 -0
  76. package/dist/esm/apps/publish.js.map +1 -0
  77. package/dist/esm/auth/index.js +90 -0
  78. package/dist/esm/auth/index.js.map +1 -0
  79. package/dist/esm/config/index.js +10 -0
  80. package/dist/esm/config/index.js.map +1 -0
  81. package/dist/esm/entities/cache.js +74 -0
  82. package/dist/esm/entities/cache.js.map +1 -0
  83. package/dist/esm/entities/client.js +127 -0
  84. package/dist/esm/entities/client.js.map +1 -0
  85. package/dist/esm/entities/errors.js +60 -0
  86. package/dist/esm/entities/errors.js.map +1 -0
  87. package/dist/esm/entities/handler.js +320 -0
  88. package/dist/esm/entities/handler.js.map +1 -0
  89. package/dist/esm/entities/identity.js +167 -0
  90. package/dist/esm/entities/identity.js.map +1 -0
  91. package/dist/esm/entities/index.js +14 -0
  92. package/dist/esm/entities/index.js.map +1 -0
  93. package/dist/esm/entities/redis.js +310 -0
  94. package/dist/esm/entities/redis.js.map +1 -0
  95. package/dist/esm/entities/resolver.js +121 -0
  96. package/dist/esm/entities/resolver.js.map +1 -0
  97. package/dist/esm/entities/schema.js +98 -0
  98. package/dist/esm/entities/schema.js.map +1 -0
  99. package/dist/esm/entities/scopedDb.js +92 -0
  100. package/dist/esm/entities/scopedDb.js.map +1 -0
  101. package/dist/esm/entities/token.js +82 -0
  102. package/dist/esm/entities/token.js.map +1 -0
  103. package/dist/esm/entities/types.js +29 -0
  104. package/dist/esm/entities/types.js.map +1 -0
  105. package/dist/esm/events/index.js +69 -0
  106. package/dist/esm/events/index.js.map +1 -0
  107. package/dist/esm/index.js +10 -0
  108. package/dist/esm/index.js.map +1 -0
  109. package/dist/esm/preview.js +45 -0
  110. package/dist/esm/preview.js.map +1 -0
  111. package/package.json +62 -0
  112. package/src/apps/publish.test.ts +178 -0
  113. package/src/apps/publish.ts +251 -0
  114. package/src/auth/index.ts +114 -0
  115. package/src/config/index.ts +16 -0
  116. package/src/entities/cache.ts +103 -0
  117. package/src/entities/client.ts +180 -0
  118. package/src/entities/entities.test.ts +611 -0
  119. package/src/entities/errors.ts +66 -0
  120. package/src/entities/handler.ts +408 -0
  121. package/src/entities/identity.ts +222 -0
  122. package/src/entities/index.ts +108 -0
  123. package/src/entities/redis.test.ts +192 -0
  124. package/src/entities/redis.ts +454 -0
  125. package/src/entities/resolver.ts +163 -0
  126. package/src/entities/schema.ts +130 -0
  127. package/src/entities/scopedDb.ts +165 -0
  128. package/src/entities/token.ts +106 -0
  129. package/src/entities/types.ts +108 -0
  130. package/src/events/index.ts +94 -0
  131. package/src/index.ts +43 -0
  132. package/src/preview.ts +50 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Redis integration for the entity sharing layer (CR-6).
3
+ *
4
+ * Provides three pieces that all depend on a shared `ioredis` connection:
5
+ *
6
+ * 1. `RedisEntityCache` — implements `EntityCache` against Redis. Falls
7
+ * through to the in-memory cache if Redis is unreachable at call
8
+ * time, so a Redis outage degrades gracefully instead of taking the
9
+ * consumer down.
10
+ *
11
+ * 2. `startInvalidationSubscriber` — subscribes to
12
+ * `anby:invalidate:{tenantId}:{entity}@{version}` pub/sub channels
13
+ * and clears the corresponding cache keys from BOTH L1 (in-process
14
+ * LRU) and L2 (Redis). Pub/sub is acceptable here because
15
+ * invalidation miss = cache staleness ≤ TTL, not data corruption.
16
+ *
17
+ * 3. `startResolverMapSubscriber` — subscribes to a Redis Stream
18
+ * (`anby:registry:stream:{tenantId}`) for install/uninstall updates.
19
+ * Streams persist messages and track consumer offsets, so a reconnect
20
+ * catches up on missed updates. Failure mode: if catch-up breaks,
21
+ * we fall back to the polling reconciliation already wired in
22
+ * `resolver.ts`.
23
+ *
24
+ * Usage from a consumer (e.g. anby-okr-service) at boot:
25
+ *
26
+ * import { configureRedis, startInvalidationSubscriber,
27
+ * startResolverMapSubscriber, RedisEntityCache,
28
+ * configureEntityCache } from '@anby/platform-sdk';
29
+ *
30
+ * if (process.env.PLATFORM_REDIS_URL) {
31
+ * const redis = configureRedis(process.env.PLATFORM_REDIS_URL);
32
+ * configureEntityCache(new RedisEntityCache({ redis }));
33
+ * startInvalidationSubscriber({ redis, tenantIds: ['default'] });
34
+ * startResolverMapSubscriber({ redis, tenantIds: ['default'] });
35
+ * }
36
+ *
37
+ * `ioredis` is an OPTIONAL peer dependency — consumers who don't set
38
+ * `PLATFORM_REDIS_URL` should never import this module.
39
+ */
40
+
41
+ import type {
42
+ default as IORedisDefault,
43
+ RedisOptions,
44
+ } from 'ioredis';
45
+ import type { EntityCache } from './cache.js';
46
+ import { InMemoryEntityCache } from './cache.js';
47
+ import {
48
+ bootstrapEntityMap,
49
+ _injectEntityMap,
50
+ } from './resolver.js';
51
+ import type { EntityProviderEntry } from './types.js';
52
+
53
+ // We load ioredis dynamically so the SDK stays usable without the peer.
54
+ // Cast the type to a loose shape to avoid importing types at module load.
55
+ type RedisLike = InstanceType<typeof IORedisDefault>;
56
+
57
+ let _redis: RedisLike | null = null;
58
+
59
+ export interface ConfigureRedisOptions {
60
+ /** Redis connection URL, e.g. redis://localhost:6379 */
61
+ url: string;
62
+ /** Advanced ioredis options. */
63
+ options?: RedisOptions;
64
+ }
65
+
66
+ /**
67
+ * Lazily instantiate the shared Redis client. Safe to call multiple
68
+ * times — only the first call opens a connection. Later calls return
69
+ * the same instance.
70
+ */
71
+ export async function configureRedis(
72
+ input: string | ConfigureRedisOptions,
73
+ ): Promise<RedisLike> {
74
+ if (_redis) return _redis;
75
+ const cfg: ConfigureRedisOptions =
76
+ typeof input === 'string' ? { url: input } : input;
77
+ // Dynamic import — ioredis is an optional peer dep.
78
+ // @ts-ignore
79
+ const mod = await import('ioredis');
80
+ const IORedisCtor = (mod.default ?? mod) as typeof IORedisDefault;
81
+ _redis = new IORedisCtor(cfg.url, cfg.options ?? {});
82
+ return _redis;
83
+ }
84
+
85
+ export function getRedisOrNull(): RedisLike | null {
86
+ return _redis;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Dual-level cache (L1 in-process LRU + L2 Redis)
91
+ // ---------------------------------------------------------------------------
92
+
93
+ export interface RedisEntityCacheOptions {
94
+ redis: RedisLike;
95
+ l1?: EntityCache;
96
+ /** Default TTL applied when callers don't pass one. */
97
+ defaultTtlMs?: number;
98
+ /** Prefix for Redis keys. Defaults to "anby:entity:". */
99
+ keyPrefix?: string;
100
+ }
101
+
102
+ export class RedisEntityCache implements EntityCache {
103
+ private readonly l1: EntityCache;
104
+ private readonly redis: RedisLike;
105
+ private readonly defaultTtlMs: number;
106
+ private readonly keyPrefix: string;
107
+ private degraded = false;
108
+
109
+ constructor(opts: RedisEntityCacheOptions) {
110
+ this.redis = opts.redis;
111
+ this.l1 = opts.l1 ?? new InMemoryEntityCache({ defaultTtlMs: 30_000 });
112
+ this.defaultTtlMs = opts.defaultTtlMs ?? 300_000; // 5 min
113
+ this.keyPrefix = opts.keyPrefix ?? 'anby:entity:';
114
+ }
115
+
116
+ private redisKey(logicalKey: string): string {
117
+ return this.keyPrefix + logicalKey;
118
+ }
119
+
120
+ get(key: string): unknown | undefined {
121
+ // L1 only — the async Redis read is deliberately NOT part of this
122
+ // synchronous surface because EntityCache.get is sync. Redis reads
123
+ // happen in getAsync below; callers of the entity client should go
124
+ // through that helper. For the EntityCache interface contract, this
125
+ // implementation behaves as an L1 cache.
126
+ return this.l1.get(key);
127
+ }
128
+
129
+ /**
130
+ * Async two-level read: L1 first, then L2 (Redis), then undefined.
131
+ * Populates L1 on L2 hit. Catches Redis errors and falls back to L1.
132
+ */
133
+ async getAsync(key: string): Promise<unknown | undefined> {
134
+ const l1Hit = this.l1.get(key);
135
+ if (l1Hit !== undefined) return l1Hit;
136
+ if (this.degraded) return undefined;
137
+ try {
138
+ const raw = await this.redis.get(this.redisKey(key));
139
+ if (raw === null) return undefined;
140
+ const value = JSON.parse(raw);
141
+ this.l1.set(key, value, 30_000);
142
+ return value;
143
+ } catch (err) {
144
+ this.markDegraded(err);
145
+ return undefined;
146
+ }
147
+ }
148
+
149
+ set(key: string, value: unknown, ttlMs?: number): void {
150
+ this.l1.set(key, value, ttlMs);
151
+ void this.setAsync(key, value, ttlMs).catch((err) =>
152
+ this.markDegraded(err),
153
+ );
154
+ }
155
+
156
+ private async setAsync(
157
+ key: string,
158
+ value: unknown,
159
+ ttlMs?: number,
160
+ ): Promise<void> {
161
+ if (this.degraded) return;
162
+ try {
163
+ const serialized = JSON.stringify(value);
164
+ const ttl = Math.max(1, Math.floor((ttlMs ?? this.defaultTtlMs) / 1000));
165
+ await this.redis.set(this.redisKey(key), serialized, 'EX', ttl);
166
+ } catch (err) {
167
+ this.markDegraded(err);
168
+ }
169
+ }
170
+
171
+ del(key: string): void {
172
+ this.l1.del(key);
173
+ if (this.degraded) return;
174
+ void this.redis.del(this.redisKey(key)).catch((err) =>
175
+ this.markDegraded(err),
176
+ );
177
+ }
178
+
179
+ delPattern(prefix: string): void {
180
+ this.l1.delPattern(prefix);
181
+ if (this.degraded) return;
182
+ void this.scanAndDelete(this.redisKey(prefix)).catch((err) =>
183
+ this.markDegraded(err),
184
+ );
185
+ }
186
+
187
+ private async scanAndDelete(redisPrefix: string): Promise<void> {
188
+ // Use SCAN to avoid KEYS — safe for production.
189
+ let cursor = '0';
190
+ const pattern = redisPrefix + '*';
191
+ do {
192
+ const [next, batch] = (await this.redis.scan(
193
+ cursor,
194
+ 'MATCH',
195
+ pattern,
196
+ 'COUNT',
197
+ '100',
198
+ )) as [string, string[]];
199
+ cursor = next;
200
+ if (batch.length > 0) await this.redis.del(...batch);
201
+ } while (cursor !== '0');
202
+ }
203
+
204
+ clear(): void {
205
+ this.l1.clear();
206
+ // Do NOT clear the whole redis keyspace — other consumers may share
207
+ // this instance. Callers who really want to nuke must go through
208
+ // delPattern with the prefix for their tenant.
209
+ }
210
+
211
+ private markDegraded(err: unknown): void {
212
+ if (!this.degraded) {
213
+ this.degraded = true;
214
+ // eslint-disable-next-line no-console
215
+ console.warn(
216
+ `[platform-sdk] RedisEntityCache degraded to L1-only: ${(err as Error).message}`,
217
+ );
218
+ // Try to recover after 30s.
219
+ setTimeout(() => {
220
+ this.degraded = false;
221
+ }, 30_000).unref?.();
222
+ }
223
+ }
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Cache invalidation subscriber (pub/sub)
228
+ // ---------------------------------------------------------------------------
229
+
230
+ export interface InvalidationSubscriberOptions {
231
+ redis: RedisLike;
232
+ /** The consumer's local cache instance. Defaults to the SDK's global. */
233
+ cache?: EntityCache;
234
+ /** Tenants this consumer cares about. Use "*" for all. */
235
+ tenantIds: string[] | '*';
236
+ }
237
+
238
+ const _invalidationSubscribers = new Map<string, RedisLike>();
239
+
240
+ /**
241
+ * Subscribe to invalidation pub/sub channels for a set of tenants.
242
+ * Returns a handle with `stop()` for test cleanup.
243
+ */
244
+ export async function startInvalidationSubscriber(
245
+ opts: InvalidationSubscriberOptions,
246
+ ): Promise<{ stop: () => Promise<void> }> {
247
+ // ioredis requires a separate connection for subscribers — pub/sub
248
+ // mode locks the connection.
249
+ const subscriber = opts.redis.duplicate();
250
+
251
+ const patterns =
252
+ opts.tenantIds === '*'
253
+ ? ['anby:invalidate:*']
254
+ : opts.tenantIds.map((t) => `anby:invalidate:${t}:*`);
255
+
256
+ await subscriber.psubscribe(...patterns);
257
+
258
+ const cache = opts.cache; // may be undefined — we'll resolve lazily
259
+ subscriber.on('pmessage', async (_pattern, channel, _message) => {
260
+ // channel format: anby:invalidate:{tenantId}:{entity}@{version}
261
+ const parts = channel.split(':');
262
+ if (parts.length < 4) return;
263
+ const tenantId = parts[2];
264
+ const entityQualified = parts.slice(3).join(':');
265
+ const prefix = `${tenantId}:${entityQualified}:`;
266
+ const target =
267
+ cache ??
268
+ (await import('./cache.js')).getEntityCache();
269
+ target.delPattern(prefix);
270
+ });
271
+
272
+ const id = patterns.join('|');
273
+ _invalidationSubscribers.set(id, subscriber);
274
+
275
+ return {
276
+ stop: async () => {
277
+ try {
278
+ await subscriber.punsubscribe(...patterns);
279
+ } catch {
280
+ /* already closed */
281
+ }
282
+ subscriber.disconnect();
283
+ _invalidationSubscribers.delete(id);
284
+ },
285
+ };
286
+ }
287
+
288
+ export async function publishInvalidation(
289
+ redis: RedisLike,
290
+ tenantId: string,
291
+ entityName: string,
292
+ version: string = 'v1',
293
+ ): Promise<void> {
294
+ const channel = `anby:invalidate:${tenantId}:${entityName}@${version}`;
295
+ await redis.publish(channel, '1');
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Resolver map subscriber (Redis Streams — durable)
300
+ // ---------------------------------------------------------------------------
301
+
302
+ export interface ResolverStreamOptions {
303
+ redis: RedisLike;
304
+ tenantIds: string[];
305
+ /**
306
+ * When set, start reading from this stream ID (e.g. persisted from a
307
+ * previous run). Defaults to "$" which means "only new messages after
308
+ * this subscriber started".
309
+ */
310
+ startFromId?: string;
311
+ }
312
+
313
+ interface StreamMessage {
314
+ seq: number;
315
+ type: 'install' | 'uninstall' | 'upgrade';
316
+ tenantId: string;
317
+ appId: string;
318
+ entries?: Record<string, EntityProviderEntry>;
319
+ }
320
+
321
+ export async function startResolverMapSubscriber(
322
+ opts: ResolverStreamOptions,
323
+ ): Promise<{ stop: () => Promise<void> }> {
324
+ let stopped = false;
325
+ const streams = opts.tenantIds.map((t) => `anby:registry:stream:${t}`);
326
+ const ids = new Map<string, string>();
327
+ for (const s of streams) ids.set(s, opts.startFromId ?? '$');
328
+
329
+ // Ensure we have an initial snapshot before we start listening for
330
+ // deltas — avoids a race where the subscriber starts but the consumer
331
+ // has no map loaded yet.
332
+ for (const t of opts.tenantIds) {
333
+ try {
334
+ await bootstrapEntityMap(t);
335
+ } catch {
336
+ /* let the polling fallback in resolver handle this */
337
+ }
338
+ }
339
+
340
+ const loop = async () => {
341
+ while (!stopped) {
342
+ try {
343
+ const args: (string | number)[] = ['BLOCK', 5000, 'STREAMS'];
344
+ for (const s of streams) args.push(s);
345
+ for (const s of streams) args.push(ids.get(s)!);
346
+ const result = (await (opts.redis as unknown as {
347
+ xread: (...args: unknown[]) => Promise<unknown>;
348
+ }).xread(...args)) as
349
+ | null
350
+ | Array<[string, Array<[string, string[]]>]>;
351
+ if (!result) continue;
352
+ for (const [stream, entries] of result) {
353
+ for (const [id, fields] of entries) {
354
+ ids.set(stream, id);
355
+ const msg = parseStreamFields(fields);
356
+ if (msg) applyStreamMessage(stream, msg);
357
+ }
358
+ }
359
+ } catch (err) {
360
+ if (stopped) return;
361
+ // eslint-disable-next-line no-console
362
+ console.warn(
363
+ `[platform-sdk] resolver stream read failed: ${(err as Error).message}`,
364
+ );
365
+ // Back off briefly then retry — polling fallback still runs.
366
+ await new Promise((r) => setTimeout(r, 2_000));
367
+ }
368
+ }
369
+ };
370
+ // Fire and forget — the loop stops when stopped=true.
371
+ void loop();
372
+
373
+ return {
374
+ stop: async () => {
375
+ stopped = true;
376
+ },
377
+ };
378
+ }
379
+
380
+ function parseStreamFields(fields: string[]): StreamMessage | null {
381
+ // Redis streams field lists look like ["type", "install", "tenantId", ...]
382
+ const map: Record<string, string> = {};
383
+ for (let i = 0; i < fields.length; i += 2) {
384
+ map[fields[i]] = fields[i + 1];
385
+ }
386
+ if (!map.type || !map.tenantId) return null;
387
+ return {
388
+ seq: Number(map.seq ?? 0),
389
+ type: map.type as StreamMessage['type'],
390
+ tenantId: map.tenantId,
391
+ appId: map.appId ?? '',
392
+ entries: map.entries ? (JSON.parse(map.entries) as Record<string, EntityProviderEntry>) : undefined,
393
+ };
394
+ }
395
+
396
+ function applyStreamMessage(_stream: string, msg: StreamMessage): void {
397
+ // For Wave 1 we keep the update logic simple: any resolver message
398
+ // triggers a re-bootstrap for that tenant. Phase 2.x can switch to
399
+ // differential updates once we have a production workload to benchmark
400
+ // against.
401
+ bootstrapEntityMap(msg.tenantId).catch((err) => {
402
+ // eslint-disable-next-line no-console
403
+ console.warn(
404
+ `[platform-sdk] resolver refresh after stream update failed: ${(err as Error).message}`,
405
+ );
406
+ });
407
+ }
408
+
409
+ /**
410
+ * Publish a registry stream message. Registry side uses this to notify
411
+ * subscribers of install / uninstall / upgrade events.
412
+ */
413
+ export async function publishResolverMapUpdate(
414
+ redis: RedisLike,
415
+ tenantId: string,
416
+ update: {
417
+ type: 'install' | 'uninstall' | 'upgrade';
418
+ appId: string;
419
+ seq?: number;
420
+ },
421
+ ): Promise<string> {
422
+ const stream = `anby:registry:stream:${tenantId}`;
423
+ // MAXLEN ~ keeps the stream bounded so it doesn't grow without limit.
424
+ const id = (await (redis as unknown as {
425
+ xadd: (...args: unknown[]) => Promise<string>;
426
+ }).xadd(
427
+ stream,
428
+ 'MAXLEN',
429
+ '~',
430
+ '10000',
431
+ '*',
432
+ 'type',
433
+ update.type,
434
+ 'tenantId',
435
+ tenantId,
436
+ 'appId',
437
+ update.appId,
438
+ 'seq',
439
+ String(update.seq ?? Date.now()),
440
+ )) as string;
441
+ return id;
442
+ }
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // Test helpers
446
+ // ---------------------------------------------------------------------------
447
+
448
+ export function _resetRedisModule(): void {
449
+ _redis = null;
450
+ _invalidationSubscribers.clear();
451
+ }
452
+
453
+ /** For tests: directly inject an entity map without going through the registry. */
454
+ export const _testInject = _injectEntityMap;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Entity provider resolver (CR original Issue 4).
3
+ *
4
+ * Consumer SDK fetches the full entity map for a tenant from the registry
5
+ * once (at boot, or on first entity access for a tenant) and keeps it in
6
+ * process memory. Phase 2 adds Redis Streams subscription for live updates;
7
+ * Phase 1 falls back to polling with ETag.
8
+ *
9
+ * Registry down at runtime does NOT break reads — the cached map keeps
10
+ * serving. Only cold-start for a brand new tenant is blocked.
11
+ */
12
+
13
+ import type {
14
+ EntityMapResponse,
15
+ EntityProviderEntry,
16
+ } from './types.js';
17
+ import { EntityNotInstalledError } from './errors.js';
18
+ import { registerEntitySchema } from './schema.js';
19
+ import { getPlatformConfig } from '../config/index.js';
20
+
21
+ interface TenantMap {
22
+ entries: Map<string, EntityProviderEntry>;
23
+ etag: string;
24
+ fetchedAt: number;
25
+ }
26
+
27
+ const _tenantMaps = new Map<string, TenantMap>();
28
+ const _inflightBootstraps = new Map<string, Promise<TenantMap>>();
29
+
30
+ const POLL_INTERVAL_MS = 60_000;
31
+ const _pollTimers = new Map<string, NodeJS.Timeout>();
32
+
33
+ export async function bootstrapEntityMap(
34
+ tenantId: string,
35
+ ): Promise<TenantMap> {
36
+ const existing = _tenantMaps.get(tenantId);
37
+ if (existing) return existing;
38
+
39
+ const pending = _inflightBootstraps.get(tenantId);
40
+ if (pending) return pending;
41
+
42
+ const promise = doFetch(tenantId).finally(() => {
43
+ _inflightBootstraps.delete(tenantId);
44
+ });
45
+ _inflightBootstraps.set(tenantId, promise);
46
+ const map = await promise;
47
+ _tenantMaps.set(tenantId, map);
48
+ startPolling(tenantId);
49
+ return map;
50
+ }
51
+
52
+ async function doFetch(
53
+ tenantId: string,
54
+ ifNoneMatch?: string,
55
+ ): Promise<TenantMap> {
56
+ const { registryUrl } = getPlatformConfig();
57
+ const url = `${registryUrl}/registry/entity-map?tenantId=${encodeURIComponent(tenantId)}`;
58
+ const res = await fetch(url, {
59
+ headers: ifNoneMatch ? { 'if-none-match': ifNoneMatch } : {},
60
+ });
61
+ if (res.status === 304) {
62
+ // Not modified — keep current map, just bump fetchedAt.
63
+ const cur = _tenantMaps.get(tenantId);
64
+ if (cur) return { ...cur, fetchedAt: Date.now() };
65
+ }
66
+ if (!res.ok) {
67
+ throw new Error(
68
+ `registry entity-map fetch failed (${res.status}) for tenant ${tenantId}`,
69
+ );
70
+ }
71
+ const json = (await res.json()) as EntityMapResponse;
72
+ const entries = new Map<string, EntityProviderEntry>();
73
+ for (const [key, value] of Object.entries(json.entries)) {
74
+ entries.set(key, value);
75
+ // Register schema for validation
76
+ if (value.schema) {
77
+ await registerEntitySchema(value.entityName, value.version, value.schema);
78
+ }
79
+ }
80
+ return {
81
+ entries,
82
+ etag: json.etag,
83
+ fetchedAt: Date.now(),
84
+ };
85
+ }
86
+
87
+ function startPolling(tenantId: string): void {
88
+ if (_pollTimers.has(tenantId)) return;
89
+ const timer = setInterval(() => {
90
+ const cur = _tenantMaps.get(tenantId);
91
+ doFetch(tenantId, cur?.etag)
92
+ .then((map) => {
93
+ _tenantMaps.set(tenantId, map);
94
+ })
95
+ .catch((err) => {
96
+ // Swallow — we keep serving from the cached map. Log for visibility.
97
+ // eslint-disable-next-line no-console
98
+ console.warn(
99
+ `[platform-sdk] entity-map poll failed for tenant ${tenantId}: ${(err as Error).message}`,
100
+ );
101
+ });
102
+ }, POLL_INTERVAL_MS);
103
+ // Don't block process exit.
104
+ if (typeof timer.unref === 'function') timer.unref();
105
+ _pollTimers.set(tenantId, timer);
106
+ }
107
+
108
+ export function resolveEntityProvider(
109
+ tenantId: string,
110
+ entityName: string,
111
+ version: string = 'v1',
112
+ ): EntityProviderEntry {
113
+ const map = _tenantMaps.get(tenantId);
114
+ if (!map) {
115
+ throw new EntityNotInstalledError(
116
+ `${entityName}@${version}`,
117
+ tenantId,
118
+ );
119
+ }
120
+ const key = `${entityName}@${version}`;
121
+ const entry = map.entries.get(key);
122
+ if (!entry) {
123
+ throw new EntityNotInstalledError(
124
+ `${entityName}@${version}`,
125
+ tenantId,
126
+ );
127
+ }
128
+ return entry;
129
+ }
130
+
131
+ /** Await bootstrap if needed, then resolve. */
132
+ export async function ensureAndResolveEntityProvider(
133
+ tenantId: string,
134
+ entityName: string,
135
+ version: string = 'v1',
136
+ ): Promise<EntityProviderEntry> {
137
+ if (!_tenantMaps.has(tenantId)) {
138
+ await bootstrapEntityMap(tenantId);
139
+ }
140
+ return resolveEntityProvider(tenantId, entityName, version);
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Test / manual hooks
145
+ // ---------------------------------------------------------------------------
146
+
147
+ export function _injectEntityMap(
148
+ tenantId: string,
149
+ entries: Record<string, EntityProviderEntry>,
150
+ ): void {
151
+ _tenantMaps.set(tenantId, {
152
+ entries: new Map(Object.entries(entries)),
153
+ etag: 'test',
154
+ fetchedAt: Date.now(),
155
+ });
156
+ }
157
+
158
+ export function _clearResolver(): void {
159
+ for (const timer of _pollTimers.values()) clearInterval(timer);
160
+ _pollTimers.clear();
161
+ _tenantMaps.clear();
162
+ _inflightBootstraps.clear();
163
+ }