@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.
- package/CHANGELOG.md +4 -0
- package/bin/apifuse-dev.ts +2 -0
- package/bin/apifuse-pack-types.ts +234 -38
- package/bin/apifuse-perf.ts +15 -12
- package/bin/apifuse-record.ts +2 -0
- package/dist/config/loader.d.ts +8 -19
- package/dist/config/loader.js +28 -86
- package/dist/define.d.ts +4 -1
- package/dist/define.js +64 -6
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -0
- package/dist/provider.d.ts +1 -1
- package/dist/runtime/auth-flow.js +2 -0
- package/dist/runtime/browser.js +50 -0
- package/dist/runtime/http.js +0 -1
- package/dist/runtime/instrumentation.js +26 -1
- package/dist/runtime/resolver-vendors/bindings.d.ts +8 -0
- package/dist/runtime/resolver-vendors/bindings.js +15 -0
- package/dist/runtime/resolver-vendors/browser.d.ts +24 -0
- package/dist/runtime/resolver-vendors/browser.js +287 -0
- package/dist/runtime/resolver-vendors/types.d.ts +42 -0
- package/dist/runtime/resolver-vendors/types.js +57 -0
- package/dist/runtime/resolver.d.ts +39 -0
- package/dist/runtime/resolver.js +414 -0
- package/dist/runtime/state.d.ts +3 -0
- package/dist/runtime/state.js +245 -141
- package/dist/runtime/stealth.js +3 -6
- package/dist/server/serve.d.ts +4 -1
- package/dist/server/serve.js +35 -8
- package/dist/testing/run.js +7 -0
- package/dist/types.d.ts +112 -4
- package/package.json +1 -1
- package/src/config/loader.ts +35 -111
- package/src/define.ts +105 -8
- package/src/index.ts +20 -1
- package/src/provider.ts +1 -0
- package/src/runtime/auth-flow.ts +2 -0
- package/src/runtime/browser.ts +69 -0
- package/src/runtime/http.ts +0 -1
- package/src/runtime/instrumentation.ts +36 -2
- package/src/runtime/resolver-vendors/bindings.ts +31 -0
- package/src/runtime/resolver-vendors/browser.ts +420 -0
- package/src/runtime/resolver-vendors/types.ts +113 -0
- package/src/runtime/resolver.ts +668 -0
- package/src/runtime/state.ts +323 -166
- package/src/runtime/stealth.ts +3 -6
- package/src/server/serve.ts +73 -5
- package/src/testing/run.ts +8 -0
- package/src/types.ts +130 -4
package/src/runtime/state.ts
CHANGED
|
@@ -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:
|
|
20
|
-
const
|
|
21
|
-
const
|
|
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
|
-
|
|
32
|
+
redis.call("ZREM", KEYS[2], KEYS[3])
|
|
31
33
|
local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
|
|
32
|
-
if
|
|
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
|
-
|
|
114
|
+
redis.call("ZREM", KEYS[2], KEYS[3])
|
|
63
115
|
local indexed = redis.call("ZSCORE", KEYS[2], KEYS[1])
|
|
64
|
-
if
|
|
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("
|
|
76
|
-
|
|
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
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
local
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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(
|
|
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 =
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
161
|
-
|
|
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
|
|
165
|
-
|
|
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
|
|
262
|
+
function providerStateKey(
|
|
169
263
|
providerId: string | undefined,
|
|
170
264
|
namespace: string,
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
return redisKey.startsWith(prefix) ? redisKey.slice(prefix.length) : redisKey;
|
|
280
|
+
return `${legacyProviderStatePrefix(providerId, namespace)}:${key}`;
|
|
175
281
|
}
|
|
176
282
|
|
|
177
|
-
function
|
|
178
|
-
|
|
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(
|
|
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(
|
|
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
|
|
277
|
-
return
|
|
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.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
if (!
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
value,
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
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(
|
|
542
|
-
|
|
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(
|
|
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
|
-
|
|
870
|
+
constructor(
|
|
871
|
+
private readonly backend: MemoryProviderStateBackend = { namespaces: new Map() },
|
|
872
|
+
private readonly scopeDiscriminator = MISSING_CONNECTION_SCOPE_DISCRIMINATOR,
|
|
873
|
+
) {}
|
|
727
874
|
|
|
728
|
-
|
|
729
|
-
|
|
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(
|
|
732
|
-
this.namespaces.set(
|
|
888
|
+
const created = new MemoryProviderStateNamespace(options);
|
|
889
|
+
this.backend.namespaces.set(namespaceIdentity, created);
|
|
733
890
|
return created;
|
|
734
891
|
}
|
|
735
892
|
}
|