@apifuse/provider-sdk 2.2.0-beta.22 → 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 (64) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/bin/apifuse-dev.ts +4 -0
  3. package/bin/apifuse-pack-types.ts +234 -38
  4. package/bin/apifuse-perf.ts +15 -12
  5. package/bin/apifuse-record.ts +4 -0
  6. package/dist/config/loader.d.ts +8 -19
  7. package/dist/config/loader.js +28 -86
  8. package/dist/contract-types.d.ts +1 -0
  9. package/dist/contract.js +2 -0
  10. package/dist/define.d.ts +5 -1
  11. package/dist/define.js +79 -6
  12. package/dist/error-resolution.js +5 -0
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.js +2 -0
  15. package/dist/provider.d.ts +1 -1
  16. package/dist/runtime/auth-flow.d.ts +2 -1
  17. package/dist/runtime/auth-flow.js +4 -0
  18. package/dist/runtime/browser.js +78 -9
  19. package/dist/runtime/http.js +0 -1
  20. package/dist/runtime/instrumentation.js +26 -1
  21. package/dist/runtime/ocr.d.ts +29 -0
  22. package/dist/runtime/ocr.js +440 -0
  23. package/dist/runtime/resolver-vendors/bindings.d.ts +8 -0
  24. package/dist/runtime/resolver-vendors/bindings.js +15 -0
  25. package/dist/runtime/resolver-vendors/browser.d.ts +24 -0
  26. package/dist/runtime/resolver-vendors/browser.js +287 -0
  27. package/dist/runtime/resolver-vendors/types.d.ts +42 -0
  28. package/dist/runtime/resolver-vendors/types.js +57 -0
  29. package/dist/runtime/resolver.d.ts +39 -0
  30. package/dist/runtime/resolver.js +414 -0
  31. package/dist/runtime/state.d.ts +3 -0
  32. package/dist/runtime/state.js +245 -141
  33. package/dist/runtime/stealth.js +3 -6
  34. package/dist/runtime/stt.js +1 -12
  35. package/dist/runtime/timeout.d.ts +5 -0
  36. package/dist/runtime/timeout.js +12 -0
  37. package/dist/server/serve.d.ts +6 -1
  38. package/dist/server/serve.js +39 -8
  39. package/dist/testing/run.js +10 -0
  40. package/dist/types.d.ts +163 -4
  41. package/package.json +1 -1
  42. package/src/config/loader.ts +35 -111
  43. package/src/contract-types.ts +1 -0
  44. package/src/contract.ts +2 -0
  45. package/src/define.ts +121 -7
  46. package/src/error-resolution.ts +5 -0
  47. package/src/index.ts +45 -1
  48. package/src/provider.ts +1 -0
  49. package/src/runtime/auth-flow.ts +6 -0
  50. package/src/runtime/browser.ts +139 -19
  51. package/src/runtime/http.ts +0 -1
  52. package/src/runtime/instrumentation.ts +36 -2
  53. package/src/runtime/ocr.ts +523 -0
  54. package/src/runtime/resolver-vendors/bindings.ts +31 -0
  55. package/src/runtime/resolver-vendors/browser.ts +420 -0
  56. package/src/runtime/resolver-vendors/types.ts +113 -0
  57. package/src/runtime/resolver.ts +668 -0
  58. package/src/runtime/state.ts +323 -166
  59. package/src/runtime/stealth.ts +3 -6
  60. package/src/runtime/stt.ts +1 -19
  61. package/src/runtime/timeout.ts +18 -0
  62. package/src/server/serve.ts +80 -5
  63. package/src/testing/run.ts +15 -0
  64. package/src/types.ts +188 -4
@@ -1,10 +1,12 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { providerStateRedisUrlFromEnv } from "../config/loader.js";
2
3
  import { ProviderError } from "../errors.js";
3
4
  import { createProviderRedisClient, ensureRedisReady, withRedisTimeout, } from "./redis.js";
4
5
  const DEFAULT_REDIS_TIMEOUT_MS = 250;
5
- const REDIS_STATE_PREFIX = "apifuse:provider-state:v1";
6
- const LEGACY_INDEX_SCAN_COUNT = 256;
7
- const LEGACY_INDEX_SCAN_MAX_PAGES = 8;
6
+ const REDIS_STATE_PREFIX = "apifuse:provider-state:v2";
7
+ const LEGACY_REDIS_STATE_PREFIX = "apifuse:provider-state:v1";
8
+ const PROVIDER_SCOPE_DISCRIMINATOR = "scope:provider";
9
+ const MISSING_CONNECTION_SCOPE_DISCRIMINATOR = "scope:connection:missing";
8
10
  const SET_WITH_QUOTA_SCRIPT = `
9
11
  local now = tonumber(ARGV[1])
10
12
  local max_entries = tonumber(ARGV[2])
@@ -13,93 +15,169 @@ local index_ttl = tonumber(ARGV[4])
13
15
  local envelope = ARGV[5]
14
16
 
15
17
  redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
16
- local exists = redis.call("EXISTS", KEYS[1])
18
+ redis.call("ZREM", KEYS[2], KEYS[3])
17
19
  local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
18
- if exists == 0 and not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
20
+ if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
19
21
  return {0, false}
20
22
  end
21
23
 
22
24
  redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
23
25
  redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
24
26
  redis.call("PEXPIRE", KEYS[2], index_ttl)
27
+ if ARGV[6] == "1" then
28
+ redis.call("SET", KEYS[3], ARGV[7], "PX", index_ttl)
29
+ end
25
30
  return {1, envelope}
26
31
  `;
27
32
  const COMPARE_AND_SET_WITH_QUOTA_SCRIPT = `
28
33
  local current = redis.call("GET", KEYS[1])
34
+ local scoped_present = current and true or false
35
+ if current then
36
+ local ok, decoded = pcall(cjson.decode, current)
37
+ if ok and type(decoded) == "table" and decoded.deleted == true then
38
+ current = false
39
+ end
40
+ end
41
+
42
+ local now = tonumber(ARGV[2])
43
+ local max_entries = tonumber(ARGV[3])
44
+ local expires_at = tonumber(ARGV[4])
45
+ local index_ttl = tonumber(ARGV[5])
46
+ local envelope = ARGV[6]
47
+ local allow_legacy_claim = ARGV[7] == "1"
48
+ local legacy_tombstone = ARGV[8]
49
+
50
+ if not scoped_present then
51
+ local legacy = redis.call("GET", KEYS[3])
52
+ if legacy then
53
+ local ok, decoded = pcall(cjson.decode, legacy)
54
+ if ok and type(decoded) == "table" and decoded.deleted == true then
55
+ legacy = false
56
+ end
57
+ end
58
+ if legacy and not allow_legacy_claim then
59
+ return {-1, false}
60
+ end
61
+ if legacy then
62
+ local legacy_ttl = redis.call("PTTL", KEYS[3])
63
+ if legacy_ttl == -1 then
64
+ legacy_ttl = index_ttl
65
+ elseif legacy_ttl < 1 then
66
+ legacy_ttl = 1
67
+ end
68
+ if legacy_ttl > 0 then
69
+ redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
70
+ redis.call("ZREM", KEYS[2], KEYS[3])
71
+ local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
72
+ if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
73
+ return {0, false}
74
+ end
75
+ redis.call("SET", KEYS[1], legacy, "PX", legacy_ttl)
76
+ redis.call("ZADD", KEYS[2], now + legacy_ttl, KEYS[1])
77
+ redis.call("PEXPIRE", KEYS[2], index_ttl)
78
+ redis.call("SET", KEYS[3], legacy_tombstone, "PX", index_ttl)
79
+ current = legacy
80
+ end
81
+ end
82
+ end
83
+
29
84
  local current_version = 0
85
+ local current_decoded = false
30
86
  if current then
31
87
  local ok, decoded = pcall(cjson.decode, current)
32
88
  if not ok or type(decoded) ~= "table" or type(decoded.version) ~= "number" then
33
89
  return {-2, current}
34
90
  end
35
91
  current_version = decoded.version
92
+ current_decoded = decoded
36
93
  end
37
94
  if current_version ~= tonumber(ARGV[1]) then
38
95
  return {-1, current or false}
39
96
  end
40
97
 
41
- local now = tonumber(ARGV[2])
42
- local max_entries = tonumber(ARGV[3])
43
- local expires_at = tonumber(ARGV[4])
44
- local index_ttl = tonumber(ARGV[5])
45
- local envelope = ARGV[6]
46
98
  redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
47
- local exists = current and 1 or 0
99
+ redis.call("ZREM", KEYS[2], KEYS[3])
48
100
  local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
49
- if exists == 0 and not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
101
+ if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
50
102
  return {0, false}
51
103
  end
52
104
 
105
+ if current_decoded and type(current_decoded.createdAt) == "string" then
106
+ local next_decoded = cjson.decode(envelope)
107
+ next_decoded.createdAt = current_decoded.createdAt
108
+ envelope = cjson.encode(next_decoded)
109
+ end
53
110
  redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
54
111
  redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
55
112
  redis.call("PEXPIRE", KEYS[2], index_ttl)
113
+ if allow_legacy_claim then
114
+ redis.call("SET", KEYS[3], legacy_tombstone, "PX", index_ttl)
115
+ end
56
116
  return {1, envelope}
57
117
  `;
58
118
  const DELETE_WITH_INDEX_SCRIPT = `
59
- redis.call("DEL", KEYS[1])
60
- redis.call("ZREM", KEYS[2], KEYS[1])
119
+ redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2])
120
+ if ARGV[3] == "1" then
121
+ redis.call("SET", KEYS[3], ARGV[1], "PX", ARGV[2])
122
+ end
123
+ redis.call("ZREM", KEYS[2], KEYS[1], KEYS[3])
61
124
  return 1
62
125
  `;
63
- // Older SDKs wrote only the value key. Every operation that depends on the
64
- // namespace index advances a bounded SCAN cursor and lazily imports active
65
- // legacy envelopes into the new ZSET. The cursor is
66
- // deliberately cyclic rather than permanently "complete": an old pod may
67
- // still write an unindexed key during a rolling deploy. Each list/write call
68
- // does a fixed amount of migration work; Redis KEYS and unbounded scans remain
69
- // forbidden.
70
- const BACKFILL_LEGACY_INDEX_SCRIPT = `
71
- local now = tonumber(ARGV[1])
72
- local index_ttl = tonumber(ARGV[2])
73
- local next_cursor = ARGV[3]
126
+ // A v1 key has no connection discriminator, so it cannot safely be exposed to
127
+ // every v2 scope. The first concrete connection (or explicit provider scope) to
128
+ // request a key atomically adopts that value and tombstones v1. The missing-
129
+ // connection sentinel never claims legacy state.
130
+ const CLAIM_LEGACY_SCRIPT = `
131
+ local scoped = redis.call("GET", KEYS[1])
132
+ if scoped then
133
+ return {1, scoped}
134
+ end
74
135
 
75
- redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", now)
76
- for i = 4, #ARGV, 3 do
77
- local key = ARGV[i]
78
- local expected = ARGV[i + 1]
79
- local expires_at = tonumber(ARGV[i + 2])
80
- if redis.call("GET", key) == expected then
81
- redis.call("ZADD", KEYS[1], "NX", expires_at, key)
82
- end
136
+ local legacy = redis.call("GET", KEYS[3])
137
+ if not legacy then
138
+ return {1, false}
139
+ end
140
+ local ok, decoded = pcall(cjson.decode, legacy)
141
+ if ok and type(decoded) == "table" and decoded.deleted == true then
142
+ return {1, false}
83
143
  end
84
- if redis.call("EXISTS", KEYS[1]) == 1 then
85
- redis.call("PEXPIRE", KEYS[1], index_ttl)
144
+ local legacy_ttl = redis.call("PTTL", KEYS[3])
145
+ if legacy_ttl == -1 then
146
+ legacy_ttl = tonumber(ARGV[3])
147
+ elseif legacy_ttl < 1 then
148
+ legacy_ttl = 1
149
+ end
150
+
151
+ local now = tonumber(ARGV[1])
152
+ local max_entries = tonumber(ARGV[2])
153
+ local index_ttl = tonumber(ARGV[3])
154
+ redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
155
+ redis.call("ZREM", KEYS[2], KEYS[3])
156
+ local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
157
+ if not indexed and redis.call("ZCARD", KEYS[2]) >= max_entries then
158
+ return {0, false}
86
159
  end
87
- redis.call("SET", KEYS[2], next_cursor, "PX", index_ttl)
88
- return redis.call("ZCARD", KEYS[1])
160
+
161
+ redis.call("SET", KEYS[1], legacy, "PX", legacy_ttl)
162
+ redis.call("ZADD", KEYS[2], now + legacy_ttl, KEYS[1])
163
+ redis.call("PEXPIRE", KEYS[2], index_ttl)
164
+ redis.call("SET", KEYS[3], ARGV[4], "PX", index_ttl)
165
+ return {1, legacy}
89
166
  `;
90
167
  const redisBackends = new Map();
91
- function getRedisBackend(redisUrl) {
168
+ function getRedisBackend(redisUrl, injectedRedis) {
92
169
  const existing = redisBackends.get(redisUrl);
93
170
  if (existing)
94
171
  return existing;
95
- const redis = createProviderRedisClient({
96
- redisUrl,
97
- timeoutMs: DEFAULT_REDIS_TIMEOUT_MS,
98
- onError: () => {
99
- // Runtime state operations fail closed at their call sites. Avoid noisy
100
- // unhandled Redis errors from background reconnect attempts.
101
- },
102
- });
172
+ const redis = injectedRedis ??
173
+ createProviderRedisClient({
174
+ redisUrl,
175
+ timeoutMs: DEFAULT_REDIS_TIMEOUT_MS,
176
+ onError: () => {
177
+ // Runtime state operations fail closed at their call sites. Avoid noisy
178
+ // unhandled Redis errors from background reconnect attempts.
179
+ },
180
+ });
103
181
  const backend = { redis };
104
182
  redisBackends.set(redisUrl, backend);
105
183
  return backend;
@@ -120,18 +198,32 @@ async function requireRedisReady(redis) {
120
198
  return;
121
199
  throw new UnsupportedProviderStateError("Provider runtime state Redis is unavailable");
122
200
  }
123
- function providerStatePrefix(providerId, namespace) {
124
- return `${REDIS_STATE_PREFIX}:${providerId ?? "default"}:${namespace}`;
201
+ function connectionScopeDiscriminator(connectionId) {
202
+ if (connectionId === undefined)
203
+ return MISSING_CONNECTION_SCOPE_DISCRIMINATOR;
204
+ const digest = createHash("sha256").update(connectionId, "utf8").digest("hex");
205
+ return `scope:connection:sha256:${digest}`;
125
206
  }
126
- function providerStateKey(providerId, namespace, key) {
127
- return `${providerStatePrefix(providerId, namespace)}:${key}`;
207
+ function providerStatePrefix(providerId, namespace, scopeDiscriminator) {
208
+ return `${REDIS_STATE_PREFIX}:${providerId ?? "default"}:${namespace}:${scopeDiscriminator}`;
128
209
  }
129
- function publicStateKey(providerId, namespace, redisKey) {
130
- const prefix = `${providerStatePrefix(providerId, namespace)}:`;
131
- return redisKey.startsWith(prefix) ? redisKey.slice(prefix.length) : redisKey;
210
+ function providerStateKey(providerId, namespace, scopeDiscriminator, key) {
211
+ return `${providerStatePrefix(providerId, namespace, scopeDiscriminator)}:${key}`;
132
212
  }
133
- function redisGlobLiteral(value) {
134
- return value.replace(/[\\*?\[\]]/g, "\\$&");
213
+ function legacyProviderStatePrefix(providerId, namespace) {
214
+ return `${LEGACY_REDIS_STATE_PREFIX}:${providerId ?? "default"}:${namespace}`;
215
+ }
216
+ function legacyProviderStateKey(providerId, namespace, key) {
217
+ return `${legacyProviderStatePrefix(providerId, namespace)}:${key}`;
218
+ }
219
+ function publicStateKey(redisKey, prefixes) {
220
+ for (const prefix of prefixes) {
221
+ const prefixWithSeparator = `${prefix}:`;
222
+ if (redisKey.startsWith(prefixWithSeparator)) {
223
+ return redisKey.slice(prefixWithSeparator.length);
224
+ }
225
+ }
226
+ return redisKey;
135
227
  }
136
228
  function parseStateDurationMs(ttl) {
137
229
  const match = /^(\d+)(ms|s|m|h|d)$/.exec(ttl ?? "1h");
@@ -150,9 +242,6 @@ function parseStateDurationMs(ttl) {
150
242
  : 86_400_000;
151
243
  return Math.max(1, amount * multiplier);
152
244
  }
153
- function resolveExpiresAt(ttl) {
154
- return new Date(Date.now() + parseStateDurationMs(ttl)).toISOString();
155
- }
156
245
  function envelopeFromJson(key, raw) {
157
246
  if (!raw)
158
247
  return null;
@@ -188,6 +277,20 @@ function envelopeFromJson(key, raw) {
188
277
  updatedAt: record.updatedAt,
189
278
  };
190
279
  }
280
+ function isStateTombstone(raw) {
281
+ if (!raw)
282
+ return false;
283
+ try {
284
+ const parsed = JSON.parse(raw);
285
+ return (parsed !== null &&
286
+ typeof parsed === "object" &&
287
+ !Array.isArray(parsed) &&
288
+ parsed.deleted === true);
289
+ }
290
+ catch {
291
+ return false;
292
+ }
293
+ }
191
294
  function redisEnvelope(value, version, createdAt, expiresAt) {
192
295
  const updatedAt = new Date().toISOString();
193
296
  return { value, version, expiresAt, createdAt, updatedAt };
@@ -197,56 +300,32 @@ class RedisProviderStateNamespace {
197
300
  providerId;
198
301
  namespaceName;
199
302
  options;
200
- constructor(backend, providerId, namespaceName, options) {
303
+ scopeDiscriminator;
304
+ constructor(backend, providerId, namespaceName, options, scopeDiscriminator) {
201
305
  this.backend = backend;
202
306
  this.providerId = providerId;
203
307
  this.namespaceName = namespaceName;
204
308
  this.options = options;
309
+ this.scopeDiscriminator = scopeDiscriminator;
205
310
  }
206
311
  redisKey(key) {
207
- return providerStateKey(this.providerId, this.namespaceName, key);
312
+ return providerStateKey(this.providerId, this.namespaceName, this.scopeDiscriminator, key);
313
+ }
314
+ legacyRedisKey(key) {
315
+ return legacyProviderStateKey(this.providerId, this.namespaceName, key);
316
+ }
317
+ statePrefix() {
318
+ return providerStatePrefix(this.providerId, this.namespaceName, this.scopeDiscriminator);
208
319
  }
209
320
  indexKey() {
210
321
  // Keep bookkeeping outside the caller-owned keyspace. A provider may use
211
322
  // any state key (including "__index"), so a suffix inside the namespace
212
323
  // could turn the ZSET into a string and break every subsequent write.
213
- const namespaceIdentity = Buffer.from(providerStatePrefix(this.providerId, this.namespaceName), "utf8").toString("base64url");
324
+ const namespaceIdentity = Buffer.from(providerStatePrefix(this.providerId, this.namespaceName, this.scopeDiscriminator), "utf8").toString("base64url");
214
325
  return `${REDIS_STATE_PREFIX}:index:${namespaceIdentity}`;
215
326
  }
216
- legacyScanCursorKey() {
217
- return `${this.indexKey()}:legacy-scan-cursor`;
218
- }
219
- legacyPrefix() {
220
- return `${providerStatePrefix(this.providerId, this.namespaceName)}:`;
221
- }
222
- async backfillLegacyIndex() {
223
- await requireRedisReady(this.backend.redis);
224
- const cursorKey = this.legacyScanCursorKey();
225
- let cursor = (await withRequiredRedis(() => this.backend.redis.get(cursorKey))) ?? "0";
226
- const pattern = `${redisGlobLiteral(this.legacyPrefix())}*`;
227
- const indexTtlMs = parseStateDurationMs(this.options.maxTtl);
228
- for (let page = 0; page < LEGACY_INDEX_SCAN_MAX_PAGES; page += 1) {
229
- const [nextCursor, keys] = await withRequiredRedis(() => this.backend.redis.scan(cursor, "MATCH", pattern, "COUNT", LEGACY_INDEX_SCAN_COUNT));
230
- const rawValues = keys.length > 0
231
- ? await withRequiredRedis(() => this.backend.redis.mget(keys))
232
- : [];
233
- const now = Date.now();
234
- const activeLegacyArgs = [];
235
- for (const [index, raw] of rawValues.entries()) {
236
- const key = keys[index];
237
- if (!key || !raw)
238
- continue;
239
- const envelope = envelopeFromJson(publicStateKey(this.providerId, this.namespaceName, key), raw);
240
- const expiresAtMs = envelope ? Date.parse(envelope.expiresAt) : Number.NaN;
241
- if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now)
242
- continue;
243
- activeLegacyArgs.push(key, raw, String(expiresAtMs));
244
- }
245
- await withRequiredRedis(() => this.backend.redis.eval(BACKFILL_LEGACY_INDEX_SCRIPT, 2, this.indexKey(), cursorKey, String(now), String(indexTtlMs), nextCursor, ...activeLegacyArgs));
246
- cursor = nextCursor;
247
- if (cursor === "0")
248
- break;
249
- }
327
+ canClaimLegacy() {
328
+ return this.scopeDiscriminator !== MISSING_CONNECTION_SCOPE_DISCRIMINATOR;
250
329
  }
251
330
  async indexedKeys(limit) {
252
331
  await requireRedisReady(this.backend.redis);
@@ -256,6 +335,18 @@ class RedisProviderStateNamespace {
256
335
  return await this.backend.redis.zrangebyscore(this.indexKey(), now + 1, "+inf", "LIMIT", 0, limit);
257
336
  });
258
337
  }
338
+ async readRaw(key) {
339
+ await requireRedisReady(this.backend.redis);
340
+ if (!this.canClaimLegacy()) {
341
+ return await withRequiredRedis(() => this.backend.redis.get(this.redisKey(key)));
342
+ }
343
+ const tombstone = JSON.stringify({ deleted: true });
344
+ const result = await withRequiredRedis(() => this.backend.redis.eval(CLAIM_LEGACY_SCRIPT, 3, this.redisKey(key), this.indexKey(), this.legacyRedisKey(key), String(Date.now()), String(this.options.maxEntries), String(parseStateDurationMs(this.options.maxTtl)), tombstone));
345
+ if (Array.isArray(result) && Number(result[0]) === 0) {
346
+ throw this.quotaExceeded();
347
+ }
348
+ return Array.isArray(result) && typeof result[1] === "string" ? result[1] : null;
349
+ }
259
350
  enforceValueSize(value) {
260
351
  const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
261
352
  if (bytes > this.options.maxValueBytes) {
@@ -282,38 +373,40 @@ class RedisProviderStateNamespace {
282
373
  const requestedLimit = Math.max(0, options?.limit ?? this.options.maxEntries);
283
374
  if (requestedLimit === 0)
284
375
  return [];
285
- await this.backfillLegacyIndex();
286
- const keys = (await this.indexedKeys(this.options.maxEntries)).filter((key) => {
287
- const publicKey = publicStateKey(this.providerId, this.namespaceName, key);
288
- return options?.prefix ? publicKey.startsWith(options.prefix) : true;
289
- });
290
- const limited = keys.slice(0, requestedLimit);
291
- if (limited.length === 0)
376
+ const keys = await this.indexedKeys(this.options.maxEntries);
377
+ if (keys.length === 0)
292
378
  return [];
293
- const values = await withRequiredRedis(() => this.backend.redis.mget(limited));
294
- return values.flatMap((raw, index) => {
295
- const key = limited[index];
296
- if (!key)
297
- return [];
298
- const value = envelopeFromJson(publicStateKey(this.providerId, this.namespaceName, key), raw);
299
- return value ? [value] : [];
300
- });
379
+ const values = await withRequiredRedis(() => this.backend.redis.mget(keys));
380
+ const rows = [];
381
+ for (const [index, raw] of values.entries()) {
382
+ const redisKey = keys[index];
383
+ if (!redisKey)
384
+ continue;
385
+ const publicKey = publicStateKey(redisKey, [this.statePrefix()]);
386
+ if (options?.prefix && !publicKey.startsWith(options.prefix))
387
+ continue;
388
+ const value = envelopeFromJson(publicKey, raw);
389
+ if (!value)
390
+ continue;
391
+ rows.push(value);
392
+ }
393
+ return rows.slice(0, requestedLimit);
301
394
  }
302
395
  async get(key) {
303
- await requireRedisReady(this.backend.redis);
304
- const raw = await withRequiredRedis(() => this.backend.redis.get(this.redisKey(key)));
396
+ const raw = await this.readRaw(key);
397
+ if (isStateTombstone(raw))
398
+ return null;
305
399
  return envelopeFromJson(key, raw);
306
400
  }
307
401
  async set(key, value, options) {
308
402
  this.enforceValueSize(value);
309
- await this.backfillLegacyIndex();
310
403
  const current = await this.get(key);
311
404
  const createdAt = current?.createdAt ?? new Date().toISOString();
312
405
  const version = (current?.version ?? 0) + 1;
313
406
  const timing = this.writeTiming(options?.ttl);
314
407
  const envelope = redisEnvelope(value, version, createdAt, timing.expiresAt);
315
408
  await requireRedisReady(this.backend.redis);
316
- const result = await withRequiredRedis(() => this.backend.redis.eval(SET_WITH_QUOTA_SCRIPT, 2, this.redisKey(key), this.indexKey(), String(Date.now()), String(this.options.maxEntries), String(timing.expiresAtMs), String(timing.indexTtlMs), JSON.stringify(envelope)));
409
+ const result = await withRequiredRedis(() => this.backend.redis.eval(SET_WITH_QUOTA_SCRIPT, 3, this.redisKey(key), this.indexKey(), this.legacyRedisKey(key), String(Date.now()), String(this.options.maxEntries), String(timing.expiresAtMs), String(timing.indexTtlMs), JSON.stringify(envelope), this.canClaimLegacy() ? "1" : "0", JSON.stringify({ deleted: true })));
317
410
  if (!Array.isArray(result) || Number(result[0]) !== 1) {
318
411
  throw this.quotaExceeded();
319
412
  }
@@ -334,16 +427,11 @@ class RedisProviderStateNamespace {
334
427
  }
335
428
  async compareAndSet(key, expectedVersion, value, options) {
336
429
  this.enforceValueSize(value);
337
- await this.backfillLegacyIndex();
338
- const current = await this.get(key);
339
- if ((current?.version ?? 0) !== expectedVersion) {
340
- return { ok: false, current };
341
- }
342
- const createdAt = current?.createdAt ?? new Date().toISOString();
430
+ const createdAt = new Date().toISOString();
343
431
  const timing = this.writeTiming(options?.ttl);
344
432
  const envelope = redisEnvelope(value, expectedVersion + 1, createdAt, timing.expiresAt);
345
433
  await requireRedisReady(this.backend.redis);
346
- const result = await withRequiredRedis(() => this.backend.redis.eval(COMPARE_AND_SET_WITH_QUOTA_SCRIPT, 2, this.redisKey(key), this.indexKey(), String(expectedVersion), String(Date.now()), String(this.options.maxEntries), String(timing.expiresAtMs), String(timing.indexTtlMs), JSON.stringify(envelope)));
434
+ const result = await withRequiredRedis(() => this.backend.redis.eval(COMPARE_AND_SET_WITH_QUOTA_SCRIPT, 3, this.redisKey(key), this.indexKey(), this.legacyRedisKey(key), String(expectedVersion), String(Date.now()), String(this.options.maxEntries), String(timing.expiresAtMs), String(timing.indexTtlMs), JSON.stringify(envelope), this.canClaimLegacy() ? "1" : "0", JSON.stringify({ deleted: true })));
347
435
  if (Array.isArray(result) && Number(result[0]) === 0) {
348
436
  throw this.quotaExceeded();
349
437
  }
@@ -351,21 +439,17 @@ class RedisProviderStateNamespace {
351
439
  const rawCurrent = Array.isArray(result) && typeof result[1] === "string" ? result[1] : null;
352
440
  return { ok: false, current: envelopeFromJson(key, rawCurrent) };
353
441
  }
354
- return {
355
- ok: true,
356
- value: {
357
- key,
358
- value,
359
- version: envelope.version,
360
- expiresAt: timing.expiresAt,
361
- createdAt,
362
- updatedAt: envelope.updatedAt,
363
- },
364
- };
442
+ const rawWritten = typeof result[1] === "string" ? result[1] : null;
443
+ const written = envelopeFromJson(key, rawWritten);
444
+ if (!written) {
445
+ throw new UnsupportedProviderStateError("Provider runtime state CAS returned an invalid value");
446
+ }
447
+ return { ok: true, value: written };
365
448
  }
366
449
  async delete(key) {
367
450
  await requireRedisReady(this.backend.redis);
368
- await withRequiredRedis(() => this.backend.redis.eval(DELETE_WITH_INDEX_SCRIPT, 2, this.redisKey(key), this.indexKey()));
451
+ const tombstone = JSON.stringify({ deleted: true });
452
+ await withRequiredRedis(() => this.backend.redis.eval(DELETE_WITH_INDEX_SCRIPT, 3, this.redisKey(key), this.indexKey(), this.legacyRedisKey(key), tombstone, String(parseStateDurationMs(this.options.maxTtl)), this.canClaimLegacy() ? "1" : "0"));
369
453
  }
370
454
  async increment(key, field, delta = 1, options) {
371
455
  const current = (await this.get(key))?.value ?? {};
@@ -376,12 +460,19 @@ class RedisProviderStateNamespace {
376
460
  class RedisProviderRuntimeState {
377
461
  backend;
378
462
  providerId;
379
- constructor(options) {
380
- this.backend = getRedisBackend(options.redisUrl);
463
+ redisUrl;
464
+ scopeDiscriminator;
465
+ constructor(options, scopeDiscriminator = MISSING_CONNECTION_SCOPE_DISCRIMINATOR) {
466
+ this.backend = getRedisBackend(options.redisUrl, options.__redisClient);
381
467
  this.providerId = options.providerId;
468
+ this.redisUrl = options.redisUrl;
469
+ this.scopeDiscriminator = scopeDiscriminator;
470
+ }
471
+ forConnection(connectionId) {
472
+ return new RedisProviderRuntimeState({ redisUrl: this.redisUrl, providerId: this.providerId }, connectionScopeDiscriminator(connectionId));
382
473
  }
383
474
  namespace(name, options) {
384
- return new RedisProviderStateNamespace(this.backend, this.providerId, name, options);
475
+ return new RedisProviderStateNamespace(this.backend, this.providerId, name, options, options.scope === "provider" ? PROVIDER_SCOPE_DISCRIMINATOR : this.scopeDiscriminator);
385
476
  }
386
477
  }
387
478
  export class UnsupportedProviderStateError extends ProviderError {
@@ -414,6 +505,9 @@ class UnsupportedProviderStateNamespace {
414
505
  }
415
506
  }
416
507
  class UnsupportedProviderRuntimeState {
508
+ forConnection(_connectionId) {
509
+ return new UnsupportedProviderRuntimeState();
510
+ }
417
511
  namespace(_name, _options) {
418
512
  return new UnsupportedProviderStateNamespace();
419
513
  }
@@ -505,13 +599,23 @@ class MemoryProviderStateNamespace {
505
599
  }
506
600
  }
507
601
  class MemoryProviderRuntimeState {
508
- namespaces = new Map();
509
- namespace(name, _options) {
510
- const existing = this.namespaces.get(name);
602
+ backend;
603
+ scopeDiscriminator;
604
+ constructor(backend = { namespaces: new Map() }, scopeDiscriminator = MISSING_CONNECTION_SCOPE_DISCRIMINATOR) {
605
+ this.backend = backend;
606
+ this.scopeDiscriminator = scopeDiscriminator;
607
+ }
608
+ forConnection(connectionId) {
609
+ return new MemoryProviderRuntimeState(this.backend, connectionScopeDiscriminator(connectionId));
610
+ }
611
+ namespace(name, options) {
612
+ const scopeDiscriminator = options.scope === "provider" ? PROVIDER_SCOPE_DISCRIMINATOR : this.scopeDiscriminator;
613
+ const namespaceIdentity = `${scopeDiscriminator}\0${name}`;
614
+ const existing = this.backend.namespaces.get(namespaceIdentity);
511
615
  if (existing)
512
616
  return existing;
513
- const created = new MemoryProviderStateNamespace(_options);
514
- this.namespaces.set(name, created);
617
+ const created = new MemoryProviderStateNamespace(options);
618
+ this.backend.namespaces.set(namespaceIdentity, created);
515
619
  return created;
516
620
  }
517
621
  }
@@ -596,7 +596,6 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
596
596
  const resolvedProxy = await resolveProxyConfigAsync({
597
597
  proxy: options?.proxy ?? clientOptions.proxy,
598
598
  upstream: clientOptions.upstream,
599
- apifuseConfig: clientOptions.apifuseConfig,
600
599
  affinityKey: clientOptions.affinityKey,
601
600
  proxyAttempt: computeProxyAttemptIndex({
602
601
  baseProxyAttempt: clientOptions.proxyAttempt,
@@ -662,11 +661,9 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
662
661
  // A registry vendor chain (smartproxy/nodemaven) is the only policy whose
663
662
  // successive attempts resolve a *different* endpoint, so it is the only one
664
663
  // that may widen the attempt cap to the pool span, de-duplicate endpoints,
665
- // and drive allocator stale-pool refresh. A static custom/decodo policy
666
- // resolves the same URL every attempt: widening/refreshing it would resend
667
- // the request dozens of times (up to maxAttempts × refreshes) and bypass
668
- // retry:false and unsafe-method controls. Static policies therefore follow
669
- // the ordinary transport-retry budget instead.
664
+ // and drive allocator stale-pool refresh. Deprecated custom/decodo policies
665
+ // have no managed endpoint to rotate or refresh, so they follow the ordinary
666
+ // transport-retry budget instead.
670
667
  const rotatesRegistryChain = usesPolicyAllocator && policyResolvesRegistryVendorChain(policyProxy);
671
668
  const maxAttempts = rotatesRegistryChain ? policyProxyAttemptCap : retryAttemptCap;
672
669
  const dedupeAllocatorEndpoints = rotatesRegistryChain;
@@ -1,4 +1,5 @@
1
1
  import { ProviderError, TransportError, ValidationError } from "../errors.js";
2
+ import { createTimeoutController, isTimeoutLikeError } from "./timeout.js";
2
3
  export const APIFUSE__STT__BACKEND_ENV = "APIFUSE__STT__BACKEND";
3
4
  export const APIFUSE__STT__MODEL_ENV = "APIFUSE__STT__MODEL";
4
5
  export const CLOUDFLARE_ACCOUNT_ID_ENV = "APIFUSE__CLOUDFLARE__ACCOUNT_ID";
@@ -112,12 +113,6 @@ function warnOrThrowUnsupportedOption(request, message) {
112
113
  function normalizeCloudflareLanguage(language) {
113
114
  return language?.split("-")[0]?.toLowerCase();
114
115
  }
115
- function isTimeoutLikeError(error) {
116
- return (error instanceof Error &&
117
- (error.name === "AbortError" ||
118
- error.name === "TimeoutError" ||
119
- /\b(timed out|timeout|deadline exceeded)\b/i.test(error.message)));
120
- }
121
116
  function toSttTransportError(error) {
122
117
  if (error instanceof TransportError)
123
118
  return error;
@@ -134,12 +129,6 @@ function toSttTransportError(error) {
134
129
  cause: error instanceof Error ? error : undefined,
135
130
  });
136
131
  }
137
- function createTimeoutController(signalTimeoutMs) {
138
- const controller = new AbortController();
139
- const timeout = setTimeout(() => controller.abort(), signalTimeoutMs);
140
- timeout.unref?.();
141
- return { controller, clear: () => clearTimeout(timeout) };
142
- }
143
132
  function toCloudflareInput(request) {
144
133
  const prompt = resolveSttPrompt(request);
145
134
  const input = {
@@ -0,0 +1,5 @@
1
+ export declare function isTimeoutLikeError(error: unknown): error is Error;
2
+ export declare function createTimeoutController(signalTimeoutMs: number): {
3
+ controller: AbortController;
4
+ clear: () => void;
5
+ };
@@ -0,0 +1,12 @@
1
+ export function isTimeoutLikeError(error) {
2
+ return (error instanceof Error &&
3
+ (error.name === "AbortError" ||
4
+ error.name === "TimeoutError" ||
5
+ /\b(timed out|timeout|deadline exceeded)\b/i.test(error.message)));
6
+ }
7
+ export function createTimeoutController(signalTimeoutMs) {
8
+ const controller = new AbortController();
9
+ const timeout = setTimeout(() => controller.abort(), signalTimeoutMs);
10
+ timeout.unref?.();
11
+ return { controller, clear: () => clearTimeout(timeout) };
12
+ }