@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.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/cjs/apps/publish.d.ts +73 -0
- package/dist/cjs/apps/publish.d.ts.map +1 -0
- package/dist/cjs/apps/publish.js +166 -0
- package/dist/cjs/apps/publish.js.map +1 -0
- package/dist/cjs/auth/index.d.ts +22 -0
- package/dist/cjs/auth/index.d.ts.map +1 -0
- package/dist/cjs/auth/index.js +101 -0
- package/dist/cjs/auth/index.js.map +1 -0
- package/dist/cjs/config/index.d.ts +8 -0
- package/dist/cjs/config/index.d.ts.map +1 -0
- package/dist/cjs/config/index.js +14 -0
- package/dist/cjs/config/index.js.map +1 -0
- package/dist/cjs/entities/cache.d.ts +31 -0
- package/dist/cjs/entities/cache.d.ts.map +1 -0
- package/dist/cjs/entities/cache.js +84 -0
- package/dist/cjs/entities/cache.js.map +1 -0
- package/dist/cjs/entities/client.d.ts +32 -0
- package/dist/cjs/entities/client.d.ts.map +1 -0
- package/dist/cjs/entities/client.js +130 -0
- package/dist/cjs/entities/client.js.map +1 -0
- package/dist/cjs/entities/errors.d.ts +35 -0
- package/dist/cjs/entities/errors.d.ts.map +1 -0
- package/dist/cjs/entities/errors.js +68 -0
- package/dist/cjs/entities/errors.js.map +1 -0
- package/dist/cjs/entities/handler.d.ts +89 -0
- package/dist/cjs/entities/handler.d.ts.map +1 -0
- package/dist/cjs/entities/handler.js +332 -0
- package/dist/cjs/entities/handler.js.map +1 -0
- package/dist/cjs/entities/identity.d.ts +90 -0
- package/dist/cjs/entities/identity.d.ts.map +1 -0
- package/dist/cjs/entities/identity.js +180 -0
- package/dist/cjs/entities/identity.js.map +1 -0
- package/dist/cjs/entities/index.d.ts +12 -0
- package/dist/cjs/entities/index.d.ts.map +1 -0
- package/dist/cjs/entities/index.js +64 -0
- package/dist/cjs/entities/index.js.map +1 -0
- package/dist/cjs/entities/redis.d.ts +128 -0
- package/dist/cjs/entities/redis.d.ts.map +1 -0
- package/dist/cjs/entities/redis.js +354 -0
- package/dist/cjs/entities/redis.js.map +1 -0
- package/dist/cjs/entities/resolver.d.ts +25 -0
- package/dist/cjs/entities/resolver.d.ts.map +1 -0
- package/dist/cjs/entities/resolver.js +128 -0
- package/dist/cjs/entities/resolver.js.map +1 -0
- package/dist/cjs/entities/schema.d.ts +15 -0
- package/dist/cjs/entities/schema.d.ts.map +1 -0
- package/dist/cjs/entities/schema.js +140 -0
- package/dist/cjs/entities/schema.js.map +1 -0
- package/dist/cjs/entities/scopedDb.d.ts +92 -0
- package/dist/cjs/entities/scopedDb.d.ts.map +1 -0
- package/dist/cjs/entities/scopedDb.js +96 -0
- package/dist/cjs/entities/scopedDb.js.map +1 -0
- package/dist/cjs/entities/token.d.ts +18 -0
- package/dist/cjs/entities/token.d.ts.map +1 -0
- package/dist/cjs/entities/token.js +87 -0
- package/dist/cjs/entities/token.js.map +1 -0
- package/dist/cjs/entities/types.d.ts +90 -0
- package/dist/cjs/entities/types.d.ts.map +1 -0
- package/dist/cjs/entities/types.js +33 -0
- package/dist/cjs/entities/types.js.map +1 -0
- package/dist/cjs/events/index.d.ts +39 -0
- package/dist/cjs/events/index.d.ts.map +1 -0
- package/dist/cjs/events/index.js +112 -0
- package/dist/cjs/events/index.js.map +1 -0
- package/dist/cjs/index.d.ts +7 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +44 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/preview.d.ts +28 -0
- package/dist/cjs/preview.d.ts.map +1 -0
- package/dist/cjs/preview.js +49 -0
- package/dist/cjs/preview.js.map +1 -0
- package/dist/esm/apps/publish.js +162 -0
- package/dist/esm/apps/publish.js.map +1 -0
- package/dist/esm/auth/index.js +90 -0
- package/dist/esm/auth/index.js.map +1 -0
- package/dist/esm/config/index.js +10 -0
- package/dist/esm/config/index.js.map +1 -0
- package/dist/esm/entities/cache.js +74 -0
- package/dist/esm/entities/cache.js.map +1 -0
- package/dist/esm/entities/client.js +127 -0
- package/dist/esm/entities/client.js.map +1 -0
- package/dist/esm/entities/errors.js +60 -0
- package/dist/esm/entities/errors.js.map +1 -0
- package/dist/esm/entities/handler.js +320 -0
- package/dist/esm/entities/handler.js.map +1 -0
- package/dist/esm/entities/identity.js +167 -0
- package/dist/esm/entities/identity.js.map +1 -0
- package/dist/esm/entities/index.js +14 -0
- package/dist/esm/entities/index.js.map +1 -0
- package/dist/esm/entities/redis.js +310 -0
- package/dist/esm/entities/redis.js.map +1 -0
- package/dist/esm/entities/resolver.js +121 -0
- package/dist/esm/entities/resolver.js.map +1 -0
- package/dist/esm/entities/schema.js +98 -0
- package/dist/esm/entities/schema.js.map +1 -0
- package/dist/esm/entities/scopedDb.js +92 -0
- package/dist/esm/entities/scopedDb.js.map +1 -0
- package/dist/esm/entities/token.js +82 -0
- package/dist/esm/entities/token.js.map +1 -0
- package/dist/esm/entities/types.js +29 -0
- package/dist/esm/entities/types.js.map +1 -0
- package/dist/esm/events/index.js +69 -0
- package/dist/esm/events/index.js.map +1 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/preview.js +45 -0
- package/dist/esm/preview.js.map +1 -0
- package/package.json +62 -0
- package/src/apps/publish.test.ts +178 -0
- package/src/apps/publish.ts +251 -0
- package/src/auth/index.ts +114 -0
- package/src/config/index.ts +16 -0
- package/src/entities/cache.ts +103 -0
- package/src/entities/client.ts +180 -0
- package/src/entities/entities.test.ts +611 -0
- package/src/entities/errors.ts +66 -0
- package/src/entities/handler.ts +408 -0
- package/src/entities/identity.ts +222 -0
- package/src/entities/index.ts +108 -0
- package/src/entities/redis.test.ts +192 -0
- package/src/entities/redis.ts +454 -0
- package/src/entities/resolver.ts +163 -0
- package/src/entities/schema.ts +130 -0
- package/src/entities/scopedDb.ts +165 -0
- package/src/entities/token.ts +106 -0
- package/src/entities/types.ts +108 -0
- package/src/events/index.ts +94 -0
- package/src/index.ts +43 -0
- 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
|
+
}
|