@apifuse/provider-sdk 2.2.0-beta.23 → 2.2.0-beta.24

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/CHANGELOG.md +4 -0
  2. package/bin/apifuse-dev.ts +2 -0
  3. package/bin/apifuse-pack-types.ts +234 -38
  4. package/bin/apifuse-perf.ts +15 -12
  5. package/bin/apifuse-record.ts +2 -0
  6. package/dist/config/loader.d.ts +8 -19
  7. package/dist/config/loader.js +28 -86
  8. package/dist/define.d.ts +4 -1
  9. package/dist/define.js +64 -6
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.js +1 -0
  12. package/dist/provider.d.ts +1 -1
  13. package/dist/runtime/auth-flow.js +2 -0
  14. package/dist/runtime/browser.js +50 -0
  15. package/dist/runtime/http.js +0 -1
  16. package/dist/runtime/instrumentation.js +26 -1
  17. package/dist/runtime/resolver-vendors/bindings.d.ts +8 -0
  18. package/dist/runtime/resolver-vendors/bindings.js +15 -0
  19. package/dist/runtime/resolver-vendors/browser.d.ts +24 -0
  20. package/dist/runtime/resolver-vendors/browser.js +287 -0
  21. package/dist/runtime/resolver-vendors/types.d.ts +42 -0
  22. package/dist/runtime/resolver-vendors/types.js +57 -0
  23. package/dist/runtime/resolver.d.ts +39 -0
  24. package/dist/runtime/resolver.js +414 -0
  25. package/dist/runtime/state.d.ts +3 -0
  26. package/dist/runtime/state.js +245 -141
  27. package/dist/runtime/stealth.js +3 -6
  28. package/dist/server/serve.d.ts +4 -1
  29. package/dist/server/serve.js +35 -8
  30. package/dist/testing/run.js +7 -0
  31. package/dist/types.d.ts +112 -4
  32. package/package.json +1 -1
  33. package/src/config/loader.ts +35 -111
  34. package/src/define.ts +105 -8
  35. package/src/index.ts +20 -1
  36. package/src/provider.ts +1 -0
  37. package/src/runtime/auth-flow.ts +2 -0
  38. package/src/runtime/browser.ts +69 -0
  39. package/src/runtime/http.ts +0 -1
  40. package/src/runtime/instrumentation.ts +36 -2
  41. package/src/runtime/resolver-vendors/bindings.ts +31 -0
  42. package/src/runtime/resolver-vendors/browser.ts +420 -0
  43. package/src/runtime/resolver-vendors/types.ts +113 -0
  44. package/src/runtime/resolver.ts +668 -0
  45. package/src/runtime/state.ts +323 -166
  46. package/src/runtime/stealth.ts +3 -6
  47. package/src/server/serve.ts +73 -5
  48. package/src/testing/run.ts +8 -0
  49. package/src/types.ts +130 -4
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { providerStateRedisUrlFromEnv } from "../config/loader.js";
2
3
  import { ProviderError } from "../errors.js";
3
4
  import type {
@@ -16,9 +17,10 @@ import {
16
17
  } from "./redis.js";
17
18
 
18
19
  const DEFAULT_REDIS_TIMEOUT_MS = 250;
19
- const REDIS_STATE_PREFIX = "apifuse:provider-state:v1";
20
- const LEGACY_INDEX_SCAN_COUNT = 256;
21
- const LEGACY_INDEX_SCAN_MAX_PAGES = 8;
20
+ const REDIS_STATE_PREFIX = "apifuse:provider-state:v2";
21
+ const LEGACY_REDIS_STATE_PREFIX = "apifuse:provider-state:v1";
22
+ const PROVIDER_SCOPE_DISCRIMINATOR = "scope:provider";
23
+ const MISSING_CONNECTION_SCOPE_DISCRIMINATOR = "scope:connection:missing";
22
24
  const SET_WITH_QUOTA_SCRIPT = `
23
25
  local now = tonumber(ARGV[1])
24
26
  local max_entries = tonumber(ARGV[2])
@@ -27,87 +29,164 @@ local index_ttl = tonumber(ARGV[4])
27
29
  local envelope = ARGV[5]
28
30
 
29
31
  redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
30
- local exists = redis.call("EXISTS", KEYS[1])
32
+ redis.call("ZREM", KEYS[2], KEYS[3])
31
33
  local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
32
- if exists == 0 and not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
34
+ if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
33
35
  return {0, false}
34
36
  end
35
37
 
36
38
  redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
37
39
  redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
38
40
  redis.call("PEXPIRE", KEYS[2], index_ttl)
41
+ if ARGV[6] == "1" then
42
+ redis.call("SET", KEYS[3], ARGV[7], "PX", index_ttl)
43
+ end
39
44
  return {1, envelope}
40
45
  `;
41
46
 
42
47
  const COMPARE_AND_SET_WITH_QUOTA_SCRIPT = `
43
48
  local current = redis.call("GET", KEYS[1])
49
+ local scoped_present = current and true or false
50
+ if current then
51
+ local ok, decoded = pcall(cjson.decode, current)
52
+ if ok and type(decoded) == "table" and decoded.deleted == true then
53
+ current = false
54
+ end
55
+ end
56
+
57
+ local now = tonumber(ARGV[2])
58
+ local max_entries = tonumber(ARGV[3])
59
+ local expires_at = tonumber(ARGV[4])
60
+ local index_ttl = tonumber(ARGV[5])
61
+ local envelope = ARGV[6]
62
+ local allow_legacy_claim = ARGV[7] == "1"
63
+ local legacy_tombstone = ARGV[8]
64
+
65
+ if not scoped_present then
66
+ local legacy = redis.call("GET", KEYS[3])
67
+ if legacy then
68
+ local ok, decoded = pcall(cjson.decode, legacy)
69
+ if ok and type(decoded) == "table" and decoded.deleted == true then
70
+ legacy = false
71
+ end
72
+ end
73
+ if legacy and not allow_legacy_claim then
74
+ return {-1, false}
75
+ end
76
+ if legacy then
77
+ local legacy_ttl = redis.call("PTTL", KEYS[3])
78
+ if legacy_ttl == -1 then
79
+ legacy_ttl = index_ttl
80
+ elseif legacy_ttl < 1 then
81
+ legacy_ttl = 1
82
+ end
83
+ if legacy_ttl > 0 then
84
+ redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
85
+ redis.call("ZREM", KEYS[2], KEYS[3])
86
+ local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
87
+ if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
88
+ return {0, false}
89
+ end
90
+ redis.call("SET", KEYS[1], legacy, "PX", legacy_ttl)
91
+ redis.call("ZADD", KEYS[2], now + legacy_ttl, KEYS[1])
92
+ redis.call("PEXPIRE", KEYS[2], index_ttl)
93
+ redis.call("SET", KEYS[3], legacy_tombstone, "PX", index_ttl)
94
+ current = legacy
95
+ end
96
+ end
97
+ end
98
+
44
99
  local current_version = 0
100
+ local current_decoded = false
45
101
  if current then
46
102
  local ok, decoded = pcall(cjson.decode, current)
47
103
  if not ok or type(decoded) ~= "table" or type(decoded.version) ~= "number" then
48
104
  return {-2, current}
49
105
  end
50
106
  current_version = decoded.version
107
+ current_decoded = decoded
51
108
  end
52
109
  if current_version ~= tonumber(ARGV[1]) then
53
110
  return {-1, current or false}
54
111
  end
55
112
 
56
- local now = tonumber(ARGV[2])
57
- local max_entries = tonumber(ARGV[3])
58
- local expires_at = tonumber(ARGV[4])
59
- local index_ttl = tonumber(ARGV[5])
60
- local envelope = ARGV[6]
61
113
  redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
62
- local exists = current and 1 or 0
114
+ redis.call("ZREM", KEYS[2], KEYS[3])
63
115
  local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
64
- if exists == 0 and not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
116
+ if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
65
117
  return {0, false}
66
118
  end
67
119
 
120
+ if current_decoded and type(current_decoded.createdAt) == "string" then
121
+ local next_decoded = cjson.decode(envelope)
122
+ next_decoded.createdAt = current_decoded.createdAt
123
+ envelope = cjson.encode(next_decoded)
124
+ end
68
125
  redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
69
126
  redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
70
127
  redis.call("PEXPIRE", KEYS[2], index_ttl)
128
+ if allow_legacy_claim then
129
+ redis.call("SET", KEYS[3], legacy_tombstone, "PX", index_ttl)
130
+ end
71
131
  return {1, envelope}
72
132
  `;
73
133
 
74
134
  const DELETE_WITH_INDEX_SCRIPT = `
75
- redis.call("DEL", KEYS[1])
76
- redis.call("ZREM", KEYS[2], KEYS[1])
135
+ redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2])
136
+ if ARGV[3] == "1" then
137
+ redis.call("SET", KEYS[3], ARGV[1], "PX", ARGV[2])
138
+ end
139
+ redis.call("ZREM", KEYS[2], KEYS[1], KEYS[3])
77
140
  return 1
78
141
  `;
79
142
 
80
- // Older SDKs wrote only the value key. Every operation that depends on the
81
- // namespace index advances a bounded SCAN cursor and lazily imports active
82
- // legacy envelopes into the new ZSET. The cursor is
83
- // deliberately cyclic rather than permanently "complete": an old pod may
84
- // still write an unindexed key during a rolling deploy. Each list/write call
85
- // does a fixed amount of migration work; Redis KEYS and unbounded scans remain
86
- // forbidden.
87
- const BACKFILL_LEGACY_INDEX_SCRIPT = `
88
- local now = tonumber(ARGV[1])
89
- local index_ttl = tonumber(ARGV[2])
90
- local next_cursor = ARGV[3]
91
-
92
- redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", now)
93
- for i = 4, #ARGV, 3 do
94
- local key = ARGV[i]
95
- local expected = ARGV[i + 1]
96
- local expires_at = tonumber(ARGV[i + 2])
97
- if redis.call("GET", key) == expected then
98
- redis.call("ZADD", KEYS[1], "NX", expires_at, key)
99
- end
143
+ // A v1 key has no connection discriminator, so it cannot safely be exposed to
144
+ // every v2 scope. The first concrete connection (or explicit provider scope) to
145
+ // request a key atomically adopts that value and tombstones v1. The missing-
146
+ // connection sentinel never claims legacy state.
147
+ const CLAIM_LEGACY_SCRIPT = `
148
+ local scoped = redis.call("GET", KEYS[1])
149
+ if scoped then
150
+ return {1, scoped}
151
+ end
152
+
153
+ local legacy = redis.call("GET", KEYS[3])
154
+ if not legacy then
155
+ return {1, false}
156
+ end
157
+ local ok, decoded = pcall(cjson.decode, legacy)
158
+ if ok and type(decoded) == "table" and decoded.deleted == true then
159
+ return {1, false}
100
160
  end
101
- if redis.call("EXISTS", KEYS[1]) == 1 then
102
- redis.call("PEXPIRE", KEYS[1], index_ttl)
161
+ local legacy_ttl = redis.call("PTTL", KEYS[3])
162
+ if legacy_ttl == -1 then
163
+ legacy_ttl = tonumber(ARGV[3])
164
+ elseif legacy_ttl < 1 then
165
+ legacy_ttl = 1
166
+ end
167
+
168
+ local now = tonumber(ARGV[1])
169
+ local max_entries = tonumber(ARGV[2])
170
+ local index_ttl = tonumber(ARGV[3])
171
+ redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
172
+ redis.call("ZREM", KEYS[2], KEYS[3])
173
+ local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
174
+ if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
175
+ return {0, false}
103
176
  end
104
- redis.call("SET", KEYS[2], next_cursor, "PX", index_ttl)
105
- return redis.call("ZCARD", KEYS[1])
177
+
178
+ redis.call("SET", KEYS[1], legacy, "PX", legacy_ttl)
179
+ redis.call("ZADD", KEYS[2], now + legacy_ttl, KEYS[1])
180
+ redis.call("PEXPIRE", KEYS[2], index_ttl)
181
+ redis.call("SET", KEYS[3], ARGV[4], "PX", index_ttl)
182
+ return {1, legacy}
106
183
  `;
107
184
 
108
185
  type RedisProviderRuntimeStateOptions = {
109
186
  readonly redisUrl: string;
110
187
  readonly providerId?: string;
188
+ /** Test seam; production callers use redisUrl-backed client sharing. */
189
+ readonly __redisClient?: ProviderRedisClient;
111
190
  };
112
191
 
113
192
  type RedisStateEnvelope = {
@@ -118,23 +197,32 @@ type RedisStateEnvelope = {
118
197
  readonly updatedAt: string;
119
198
  };
120
199
 
200
+ type RedisStateTombstone = {
201
+ readonly deleted: true;
202
+ };
203
+
121
204
  type RedisBackend = {
122
205
  readonly redis: ProviderRedisClient;
123
206
  };
124
207
 
125
208
  const redisBackends = new Map<string, RedisBackend>();
126
209
 
127
- function getRedisBackend(redisUrl: string): RedisBackend {
210
+ function getRedisBackend(
211
+ redisUrl: string,
212
+ injectedRedis?: ProviderRedisClient,
213
+ ): RedisBackend {
128
214
  const existing = redisBackends.get(redisUrl);
129
215
  if (existing) return existing;
130
- const redis = createProviderRedisClient({
131
- redisUrl,
132
- timeoutMs: DEFAULT_REDIS_TIMEOUT_MS,
133
- onError: () => {
134
- // Runtime state operations fail closed at their call sites. Avoid noisy
135
- // unhandled Redis errors from background reconnect attempts.
136
- },
137
- });
216
+ const redis =
217
+ injectedRedis ??
218
+ createProviderRedisClient({
219
+ redisUrl,
220
+ timeoutMs: DEFAULT_REDIS_TIMEOUT_MS,
221
+ onError: () => {
222
+ // Runtime state operations fail closed at their call sites. Avoid noisy
223
+ // unhandled Redis errors from background reconnect attempts.
224
+ },
225
+ });
138
226
  const backend = { redis };
139
227
  redisBackends.set(redisUrl, backend);
140
228
  return backend;
@@ -157,25 +245,49 @@ async function requireRedisReady(redis: ProviderRedisClient): Promise<void> {
157
245
  throw new UnsupportedProviderStateError("Provider runtime state Redis is unavailable");
158
246
  }
159
247
 
160
- function providerStatePrefix(providerId: string | undefined, namespace: string): string {
161
- return `${REDIS_STATE_PREFIX}:${providerId ?? "default"}:${namespace}`;
248
+ function connectionScopeDiscriminator(connectionId: string | undefined): string {
249
+ if (connectionId === undefined) return MISSING_CONNECTION_SCOPE_DISCRIMINATOR;
250
+ const digest = createHash("sha256").update(connectionId, "utf8").digest("hex");
251
+ return `scope:connection:sha256:${digest}`;
162
252
  }
163
253
 
164
- function providerStateKey(providerId: string | undefined, namespace: string, key: string): string {
165
- return `${providerStatePrefix(providerId, namespace)}:${key}`;
254
+ function providerStatePrefix(
255
+ providerId: string | undefined,
256
+ namespace: string,
257
+ scopeDiscriminator: string,
258
+ ): string {
259
+ return `${REDIS_STATE_PREFIX}:${providerId ?? "default"}:${namespace}:${scopeDiscriminator}`;
166
260
  }
167
261
 
168
- function publicStateKey(
262
+ function providerStateKey(
169
263
  providerId: string | undefined,
170
264
  namespace: string,
171
- redisKey: string,
265
+ scopeDiscriminator: string,
266
+ key: string,
267
+ ): string {
268
+ return `${providerStatePrefix(providerId, namespace, scopeDiscriminator)}:${key}`;
269
+ }
270
+
271
+ function legacyProviderStatePrefix(providerId: string | undefined, namespace: string): string {
272
+ return `${LEGACY_REDIS_STATE_PREFIX}:${providerId ?? "default"}:${namespace}`;
273
+ }
274
+
275
+ function legacyProviderStateKey(
276
+ providerId: string | undefined,
277
+ namespace: string,
278
+ key: string,
172
279
  ): string {
173
- const prefix = `${providerStatePrefix(providerId, namespace)}:`;
174
- return redisKey.startsWith(prefix) ? redisKey.slice(prefix.length) : redisKey;
280
+ return `${legacyProviderStatePrefix(providerId, namespace)}:${key}`;
175
281
  }
176
282
 
177
- function redisGlobLiteral(value: string): string {
178
- return value.replace(/[\\*?\[\]]/g, "\\$&");
283
+ function publicStateKey(redisKey: string, prefixes: readonly string[]): string {
284
+ for (const prefix of prefixes) {
285
+ const prefixWithSeparator = `${prefix}:`;
286
+ if (redisKey.startsWith(prefixWithSeparator)) {
287
+ return redisKey.slice(prefixWithSeparator.length);
288
+ }
289
+ }
290
+ return redisKey;
179
291
  }
180
292
 
181
293
  function parseStateDurationMs(ttl: StateWriteOptions["ttl"]): number {
@@ -196,10 +308,6 @@ function parseStateDurationMs(ttl: StateWriteOptions["ttl"]): number {
196
308
  return Math.max(1, amount * multiplier);
197
309
  }
198
310
 
199
- function resolveExpiresAt(ttl: StateWriteOptions["ttl"]): string {
200
- return new Date(Date.now() + parseStateDurationMs(ttl)).toISOString();
201
- }
202
-
203
311
  function envelopeFromJson(
204
312
  key: string,
205
313
  raw: string | null,
@@ -240,6 +348,21 @@ function envelopeFromJson(
240
348
  };
241
349
  }
242
350
 
351
+ function isStateTombstone(raw: string | null): boolean {
352
+ if (!raw) return false;
353
+ try {
354
+ const parsed: unknown = JSON.parse(raw);
355
+ return (
356
+ parsed !== null &&
357
+ typeof parsed === "object" &&
358
+ !Array.isArray(parsed) &&
359
+ (parsed as { deleted?: unknown }).deleted === true
360
+ );
361
+ } catch {
362
+ return false;
363
+ }
364
+ }
365
+
243
366
  function redisEnvelope(
244
367
  value: unknown,
245
368
  version: number,
@@ -256,10 +379,28 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
256
379
  private readonly providerId: string | undefined,
257
380
  private readonly namespaceName: string,
258
381
  private readonly options: StateNamespaceOptions,
382
+ private readonly scopeDiscriminator: string,
259
383
  ) {}
260
384
 
261
385
  private redisKey(key: string): string {
262
- return providerStateKey(this.providerId, this.namespaceName, key);
386
+ return providerStateKey(
387
+ this.providerId,
388
+ this.namespaceName,
389
+ this.scopeDiscriminator,
390
+ key,
391
+ );
392
+ }
393
+
394
+ private legacyRedisKey(key: string): string {
395
+ return legacyProviderStateKey(this.providerId, this.namespaceName, key);
396
+ }
397
+
398
+ private statePrefix(): string {
399
+ return providerStatePrefix(
400
+ this.providerId,
401
+ this.namespaceName,
402
+ this.scopeDiscriminator,
403
+ );
263
404
  }
264
405
 
265
406
  private indexKey(): string {
@@ -267,70 +408,18 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
267
408
  // any state key (including "__index"), so a suffix inside the namespace
268
409
  // could turn the ZSET into a string and break every subsequent write.
269
410
  const namespaceIdentity = Buffer.from(
270
- providerStatePrefix(this.providerId, this.namespaceName),
411
+ providerStatePrefix(
412
+ this.providerId,
413
+ this.namespaceName,
414
+ this.scopeDiscriminator,
415
+ ),
271
416
  "utf8",
272
417
  ).toString("base64url");
273
418
  return `${REDIS_STATE_PREFIX}:index:${namespaceIdentity}`;
274
419
  }
275
420
 
276
- private legacyScanCursorKey(): string {
277
- return `${this.indexKey()}:legacy-scan-cursor`;
278
- }
279
-
280
- private legacyPrefix(): string {
281
- return `${providerStatePrefix(this.providerId, this.namespaceName)}:`;
282
- }
283
-
284
- private async backfillLegacyIndex(): Promise<void> {
285
- await requireRedisReady(this.backend.redis);
286
- const cursorKey = this.legacyScanCursorKey();
287
- let cursor =
288
- (await withRequiredRedis(() => this.backend.redis.get(cursorKey))) ?? "0";
289
- const pattern = `${redisGlobLiteral(this.legacyPrefix())}*`;
290
- const indexTtlMs = parseStateDurationMs(this.options.maxTtl);
291
-
292
- for (let page = 0; page < LEGACY_INDEX_SCAN_MAX_PAGES; page += 1) {
293
- const [nextCursor, keys] = await withRequiredRedis(() =>
294
- this.backend.redis.scan(
295
- cursor,
296
- "MATCH",
297
- pattern,
298
- "COUNT",
299
- LEGACY_INDEX_SCAN_COUNT,
300
- ),
301
- );
302
- const rawValues =
303
- keys.length > 0
304
- ? await withRequiredRedis(() => this.backend.redis.mget(keys))
305
- : [];
306
- const now = Date.now();
307
- const activeLegacyArgs: string[] = [];
308
- for (const [index, raw] of rawValues.entries()) {
309
- const key = keys[index];
310
- if (!key || !raw) continue;
311
- const envelope = envelopeFromJson(
312
- publicStateKey(this.providerId, this.namespaceName, key),
313
- raw,
314
- );
315
- const expiresAtMs = envelope ? Date.parse(envelope.expiresAt) : Number.NaN;
316
- if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) continue;
317
- activeLegacyArgs.push(key, raw, String(expiresAtMs));
318
- }
319
- await withRequiredRedis(() =>
320
- this.backend.redis.eval(
321
- BACKFILL_LEGACY_INDEX_SCRIPT,
322
- 2,
323
- this.indexKey(),
324
- cursorKey,
325
- String(now),
326
- String(indexTtlMs),
327
- nextCursor,
328
- ...activeLegacyArgs,
329
- ),
330
- );
331
- cursor = nextCursor;
332
- if (cursor === "0") break;
333
- }
421
+ private canClaimLegacy(): boolean {
422
+ return this.scopeDiscriminator !== MISSING_CONNECTION_SCOPE_DISCRIMINATOR;
334
423
  }
335
424
 
336
425
  private async indexedKeys(limit: number): Promise<string[]> {
@@ -349,6 +438,31 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
349
438
  });
350
439
  }
351
440
 
441
+ private async readRaw(key: string): Promise<string | null> {
442
+ await requireRedisReady(this.backend.redis);
443
+ if (!this.canClaimLegacy()) {
444
+ return await withRequiredRedis(() => this.backend.redis.get(this.redisKey(key)));
445
+ }
446
+ const tombstone = JSON.stringify({ deleted: true } satisfies RedisStateTombstone);
447
+ const result = await withRequiredRedis(() =>
448
+ this.backend.redis.eval(
449
+ CLAIM_LEGACY_SCRIPT,
450
+ 3,
451
+ this.redisKey(key),
452
+ this.indexKey(),
453
+ this.legacyRedisKey(key),
454
+ String(Date.now()),
455
+ String(this.options.maxEntries),
456
+ String(parseStateDurationMs(this.options.maxTtl)),
457
+ tombstone,
458
+ ),
459
+ );
460
+ if (Array.isArray(result) && Number(result[0]) === 0) {
461
+ throw this.quotaExceeded();
462
+ }
463
+ return Array.isArray(result) && typeof result[1] === "string" ? result[1] : null;
464
+ }
465
+
352
466
  private enforceValueSize(value: unknown): void {
353
467
  const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
354
468
  if (bytes > this.options.maxValueBytes) {
@@ -387,31 +501,30 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
387
501
  async list<T>(options?: { limit?: number; prefix?: string }): Promise<StateValue<T>[]> {
388
502
  const requestedLimit = Math.max(0, options?.limit ?? this.options.maxEntries);
389
503
  if (requestedLimit === 0) return [];
390
- await this.backfillLegacyIndex();
391
- const keys = (await this.indexedKeys(this.options.maxEntries)).filter((key) => {
392
- const publicKey = publicStateKey(this.providerId, this.namespaceName, key);
393
- return options?.prefix ? publicKey.startsWith(options.prefix) : true;
394
- });
395
- const limited = keys.slice(0, requestedLimit);
396
- if (limited.length === 0) return [];
397
- const values = await withRequiredRedis(() => this.backend.redis.mget(limited));
398
- return values.flatMap((raw, index) => {
399
- const key = limited[index];
400
- if (!key) return [];
401
- const value = envelopeFromJson(publicStateKey(this.providerId, this.namespaceName, key), raw);
402
- return value ? [value] : [];
403
- });
504
+ const keys = await this.indexedKeys(this.options.maxEntries);
505
+ if (keys.length === 0) return [];
506
+ const values = await withRequiredRedis(() => this.backend.redis.mget(keys));
507
+ const rows: StateValue<T>[] = [];
508
+ for (const [index, raw] of values.entries()) {
509
+ const redisKey = keys[index];
510
+ if (!redisKey) continue;
511
+ const publicKey = publicStateKey(redisKey, [this.statePrefix()]);
512
+ if (options?.prefix && !publicKey.startsWith(options.prefix)) continue;
513
+ const value = envelopeFromJson(publicKey, raw);
514
+ if (!value) continue;
515
+ rows.push(value);
516
+ }
517
+ return rows.slice(0, requestedLimit);
404
518
  }
405
519
 
406
520
  async get<T>(key: string): Promise<StateValue<T> | null> {
407
- await requireRedisReady(this.backend.redis);
408
- const raw = await withRequiredRedis(() => this.backend.redis.get(this.redisKey(key)));
521
+ const raw = await this.readRaw(key);
522
+ if (isStateTombstone(raw)) return null;
409
523
  return envelopeFromJson(key, raw);
410
524
  }
411
525
 
412
526
  async set<T>(key: string, value: T, options?: StateWriteOptions): Promise<StateValue<T>> {
413
527
  this.enforceValueSize(value);
414
- await this.backfillLegacyIndex();
415
528
  const current = await this.get<T>(key);
416
529
  const createdAt = current?.createdAt ?? new Date().toISOString();
417
530
  const version = (current?.version ?? 0) + 1;
@@ -421,14 +534,17 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
421
534
  const result = await withRequiredRedis(() =>
422
535
  this.backend.redis.eval(
423
536
  SET_WITH_QUOTA_SCRIPT,
424
- 2,
537
+ 3,
425
538
  this.redisKey(key),
426
539
  this.indexKey(),
540
+ this.legacyRedisKey(key),
427
541
  String(Date.now()),
428
542
  String(this.options.maxEntries),
429
543
  String(timing.expiresAtMs),
430
544
  String(timing.indexTtlMs),
431
545
  JSON.stringify(envelope),
546
+ this.canClaimLegacy() ? "1" : "0",
547
+ JSON.stringify({ deleted: true } satisfies RedisStateTombstone),
432
548
  ),
433
549
  );
434
550
  if (!Array.isArray(result) || Number(result[0]) !== 1) {
@@ -462,12 +578,7 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
462
578
  options?: StateWriteOptions,
463
579
  ): Promise<StateCasResult<T>> {
464
580
  this.enforceValueSize(value);
465
- await this.backfillLegacyIndex();
466
- const current = await this.get<T>(key);
467
- if ((current?.version ?? 0) !== expectedVersion) {
468
- return { ok: false, current };
469
- }
470
- const createdAt = current?.createdAt ?? new Date().toISOString();
581
+ const createdAt = new Date().toISOString();
471
582
  const timing = this.writeTiming(options?.ttl);
472
583
  const envelope = redisEnvelope(
473
584
  value,
@@ -479,15 +590,18 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
479
590
  const result = await withRequiredRedis(() =>
480
591
  this.backend.redis.eval(
481
592
  COMPARE_AND_SET_WITH_QUOTA_SCRIPT,
482
- 2,
593
+ 3,
483
594
  this.redisKey(key),
484
595
  this.indexKey(),
596
+ this.legacyRedisKey(key),
485
597
  String(expectedVersion),
486
598
  String(Date.now()),
487
599
  String(this.options.maxEntries),
488
600
  String(timing.expiresAtMs),
489
601
  String(timing.indexTtlMs),
490
602
  JSON.stringify(envelope),
603
+ this.canClaimLegacy() ? "1" : "0",
604
+ JSON.stringify({ deleted: true } satisfies RedisStateTombstone),
491
605
  ),
492
606
  );
493
607
  if (Array.isArray(result) && Number(result[0]) === 0) {
@@ -497,27 +611,29 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
497
611
  const rawCurrent = Array.isArray(result) && typeof result[1] === "string" ? result[1] : null;
498
612
  return { ok: false, current: envelopeFromJson(key, rawCurrent) };
499
613
  }
500
- return {
501
- ok: true,
502
- value: {
503
- key,
504
- value,
505
- version: envelope.version,
506
- expiresAt: timing.expiresAt,
507
- createdAt,
508
- updatedAt: envelope.updatedAt,
509
- },
510
- };
614
+ const rawWritten = typeof result[1] === "string" ? result[1] : null;
615
+ const written = envelopeFromJson(key, rawWritten) as StateValue<T> | null;
616
+ if (!written) {
617
+ throw new UnsupportedProviderStateError(
618
+ "Provider runtime state CAS returned an invalid value",
619
+ );
620
+ }
621
+ return { ok: true, value: written };
511
622
  }
512
623
 
513
624
  async delete(key: string): Promise<void> {
514
625
  await requireRedisReady(this.backend.redis);
626
+ const tombstone = JSON.stringify({ deleted: true } satisfies RedisStateTombstone);
515
627
  await withRequiredRedis(() =>
516
628
  this.backend.redis.eval(
517
629
  DELETE_WITH_INDEX_SCRIPT,
518
- 2,
630
+ 3,
519
631
  this.redisKey(key),
520
632
  this.indexKey(),
633
+ this.legacyRedisKey(key),
634
+ tombstone,
635
+ String(parseStateDurationMs(this.options.maxTtl)),
636
+ this.canClaimLegacy() ? "1" : "0",
521
637
  ),
522
638
  );
523
639
  }
@@ -537,14 +653,34 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
537
653
  class RedisProviderRuntimeState implements ProviderRuntimeState {
538
654
  readonly backend: RedisBackend;
539
655
  readonly providerId?: string;
656
+ readonly redisUrl: string;
657
+ readonly scopeDiscriminator: string;
540
658
 
541
- constructor(options: RedisProviderRuntimeStateOptions) {
542
- this.backend = getRedisBackend(options.redisUrl);
659
+ constructor(
660
+ options: RedisProviderRuntimeStateOptions,
661
+ scopeDiscriminator = MISSING_CONNECTION_SCOPE_DISCRIMINATOR,
662
+ ) {
663
+ this.backend = getRedisBackend(options.redisUrl, options.__redisClient);
543
664
  this.providerId = options.providerId;
665
+ this.redisUrl = options.redisUrl;
666
+ this.scopeDiscriminator = scopeDiscriminator;
667
+ }
668
+
669
+ forConnection(connectionId: string | undefined): ProviderRuntimeState {
670
+ return new RedisProviderRuntimeState(
671
+ { redisUrl: this.redisUrl, providerId: this.providerId },
672
+ connectionScopeDiscriminator(connectionId),
673
+ );
544
674
  }
545
675
 
546
676
  namespace(name: string, options: StateNamespaceOptions): ProviderStateNamespace {
547
- return new RedisProviderStateNamespace(this.backend, this.providerId, name, options);
677
+ return new RedisProviderStateNamespace(
678
+ this.backend,
679
+ this.providerId,
680
+ name,
681
+ options,
682
+ options.scope === "provider" ? PROVIDER_SCOPE_DISCRIMINATOR : this.scopeDiscriminator,
683
+ );
548
684
  }
549
685
  }
550
686
 
@@ -594,6 +730,10 @@ class UnsupportedProviderStateNamespace implements ProviderStateNamespace {
594
730
  }
595
731
 
596
732
  class UnsupportedProviderRuntimeState implements ProviderRuntimeState {
733
+ forConnection(_connectionId: string | undefined): ProviderRuntimeState {
734
+ return new UnsupportedProviderRuntimeState();
735
+ }
736
+
597
737
  namespace(_name: string, _options: StateNamespaceOptions): ProviderStateNamespace {
598
738
  return new UnsupportedProviderStateNamespace();
599
739
  }
@@ -722,14 +862,31 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
722
862
  }
723
863
  }
724
864
 
865
+ type MemoryProviderStateBackend = {
866
+ readonly namespaces: Map<string, MemoryProviderStateNamespace>;
867
+ };
868
+
725
869
  class MemoryProviderRuntimeState implements ProviderRuntimeState {
726
- readonly namespaces = new Map<string, MemoryProviderStateNamespace>();
870
+ constructor(
871
+ private readonly backend: MemoryProviderStateBackend = { namespaces: new Map() },
872
+ private readonly scopeDiscriminator = MISSING_CONNECTION_SCOPE_DISCRIMINATOR,
873
+ ) {}
727
874
 
728
- namespace(name: string, _options: StateNamespaceOptions): ProviderStateNamespace {
729
- const existing = this.namespaces.get(name);
875
+ forConnection(connectionId: string | undefined): ProviderRuntimeState {
876
+ return new MemoryProviderRuntimeState(
877
+ this.backend,
878
+ connectionScopeDiscriminator(connectionId),
879
+ );
880
+ }
881
+
882
+ namespace(name: string, options: StateNamespaceOptions): ProviderStateNamespace {
883
+ const scopeDiscriminator =
884
+ options.scope === "provider" ? PROVIDER_SCOPE_DISCRIMINATOR : this.scopeDiscriminator;
885
+ const namespaceIdentity = `${scopeDiscriminator}\0${name}`;
886
+ const existing = this.backend.namespaces.get(namespaceIdentity);
730
887
  if (existing) return existing;
731
- const created = new MemoryProviderStateNamespace(_options);
732
- this.namespaces.set(name, created);
888
+ const created = new MemoryProviderStateNamespace(options);
889
+ this.backend.namespaces.set(namespaceIdentity, created);
733
890
  return created;
734
891
  }
735
892
  }