@apifuse/provider-sdk 2.2.0-beta.20 → 2.2.0-beta.22
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 +13 -0
- package/dist/ceremonies/index.d.ts +8 -0
- package/dist/ceremonies/index.js +57 -0
- package/dist/define.js +103 -1
- package/dist/error-resolution.d.ts +2 -0
- package/dist/error-resolution.js +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/lint.d.ts +6 -1
- package/dist/lint.js +277 -0
- package/dist/provider.d.ts +1 -1
- package/dist/runtime/auth-flow.d.ts +1 -0
- package/dist/runtime/auth-flow.js +1 -0
- package/dist/runtime/state.js +223 -22
- package/dist/server/serve.d.ts +9 -8
- package/dist/server/serve.js +15 -30
- package/dist/server/types.d.ts +4 -0
- package/dist/server/types.js +7 -1
- package/dist/testing/run.js +8 -2
- package/dist/types.d.ts +28 -12
- package/package.json +1 -1
- package/src/ceremonies/index.ts +79 -0
- package/src/define.ts +138 -1
- package/src/error-resolution.ts +31 -0
- package/src/index.ts +1 -0
- package/src/lint.ts +310 -1
- package/src/provider.ts +1 -0
- package/src/runtime/auth-flow.ts +2 -0
- package/src/runtime/state.ts +317 -28
- package/src/server/serve.ts +28 -42
- package/src/server/types.ts +7 -1
- package/src/testing/run.ts +8 -2
- package/src/types.ts +48 -26
package/src/runtime/state.ts
CHANGED
|
@@ -17,6 +17,93 @@ import {
|
|
|
17
17
|
|
|
18
18
|
const DEFAULT_REDIS_TIMEOUT_MS = 250;
|
|
19
19
|
const REDIS_STATE_PREFIX = "apifuse:provider-state:v1";
|
|
20
|
+
const LEGACY_INDEX_SCAN_COUNT = 256;
|
|
21
|
+
const LEGACY_INDEX_SCAN_MAX_PAGES = 8;
|
|
22
|
+
const SET_WITH_QUOTA_SCRIPT = `
|
|
23
|
+
local now = tonumber(ARGV[1])
|
|
24
|
+
local max_entries = tonumber(ARGV[2])
|
|
25
|
+
local expires_at = tonumber(ARGV[3])
|
|
26
|
+
local index_ttl = tonumber(ARGV[4])
|
|
27
|
+
local envelope = ARGV[5]
|
|
28
|
+
|
|
29
|
+
redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
|
|
30
|
+
local exists = redis.call("EXISTS", KEYS[1])
|
|
31
|
+
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
|
|
33
|
+
return {0, false}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
|
|
37
|
+
redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
|
|
38
|
+
redis.call("PEXPIRE", KEYS[2], index_ttl)
|
|
39
|
+
return {1, envelope}
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
const COMPARE_AND_SET_WITH_QUOTA_SCRIPT = `
|
|
43
|
+
local current = redis.call("GET", KEYS[1])
|
|
44
|
+
local current_version = 0
|
|
45
|
+
if current then
|
|
46
|
+
local ok, decoded = pcall(cjson.decode, current)
|
|
47
|
+
if not ok or type(decoded) ~= "table" or type(decoded.version) ~= "number" then
|
|
48
|
+
return {-2, current}
|
|
49
|
+
end
|
|
50
|
+
current_version = decoded.version
|
|
51
|
+
end
|
|
52
|
+
if current_version ~= tonumber(ARGV[1]) then
|
|
53
|
+
return {-1, current or false}
|
|
54
|
+
end
|
|
55
|
+
|
|
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
|
+
redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now)
|
|
62
|
+
local exists = current and 1 or 0
|
|
63
|
+
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
|
|
65
|
+
return {0, false}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
redis.call("SET", KEYS[1], envelope, "PXAT", expires_at)
|
|
69
|
+
redis.call("ZADD", KEYS[2], expires_at, KEYS[1])
|
|
70
|
+
redis.call("PEXPIRE", KEYS[2], index_ttl)
|
|
71
|
+
return {1, envelope}
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
const DELETE_WITH_INDEX_SCRIPT = `
|
|
75
|
+
redis.call("DEL", KEYS[1])
|
|
76
|
+
redis.call("ZREM", KEYS[2], KEYS[1])
|
|
77
|
+
return 1
|
|
78
|
+
`;
|
|
79
|
+
|
|
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
|
|
100
|
+
end
|
|
101
|
+
if redis.call("EXISTS", KEYS[1]) == 1 then
|
|
102
|
+
redis.call("PEXPIRE", KEYS[1], index_ttl)
|
|
103
|
+
end
|
|
104
|
+
redis.call("SET", KEYS[2], next_cursor, "PX", index_ttl)
|
|
105
|
+
return redis.call("ZCARD", KEYS[1])
|
|
106
|
+
`;
|
|
20
107
|
|
|
21
108
|
type RedisProviderRuntimeStateOptions = {
|
|
22
109
|
readonly redisUrl: string;
|
|
@@ -87,6 +174,10 @@ function publicStateKey(
|
|
|
87
174
|
return redisKey.startsWith(prefix) ? redisKey.slice(prefix.length) : redisKey;
|
|
88
175
|
}
|
|
89
176
|
|
|
177
|
+
function redisGlobLiteral(value: string): string {
|
|
178
|
+
return value.replace(/[\\*?\[\]]/g, "\\$&");
|
|
179
|
+
}
|
|
180
|
+
|
|
90
181
|
function parseStateDurationMs(ttl: StateWriteOptions["ttl"]): number {
|
|
91
182
|
const match = /^(\d+)(ms|s|m|h|d)$/.exec(ttl ?? "1h");
|
|
92
183
|
if (!match) return 3_600_000;
|
|
@@ -171,13 +262,91 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
|
|
|
171
262
|
return providerStateKey(this.providerId, this.namespaceName, key);
|
|
172
263
|
}
|
|
173
264
|
|
|
174
|
-
private
|
|
265
|
+
private indexKey(): string {
|
|
266
|
+
// Keep bookkeeping outside the caller-owned keyspace. A provider may use
|
|
267
|
+
// any state key (including "__index"), so a suffix inside the namespace
|
|
268
|
+
// could turn the ZSET into a string and break every subsequent write.
|
|
269
|
+
const namespaceIdentity = Buffer.from(
|
|
270
|
+
providerStatePrefix(this.providerId, this.namespaceName),
|
|
271
|
+
"utf8",
|
|
272
|
+
).toString("base64url");
|
|
273
|
+
return `${REDIS_STATE_PREFIX}:index:${namespaceIdentity}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private legacyScanCursorKey(): string {
|
|
277
|
+
return `${this.indexKey()}:legacy-scan-cursor`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private legacyPrefix(): string {
|
|
175
281
|
return `${providerStatePrefix(this.providerId, this.namespaceName)}:`;
|
|
176
282
|
}
|
|
177
283
|
|
|
178
|
-
private async
|
|
284
|
+
private async backfillLegacyIndex(): Promise<void> {
|
|
179
285
|
await requireRedisReady(this.backend.redis);
|
|
180
|
-
|
|
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
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private async indexedKeys(limit: number): Promise<string[]> {
|
|
337
|
+
await requireRedisReady(this.backend.redis);
|
|
338
|
+
const now = Date.now();
|
|
339
|
+
return await withRequiredRedis(async () => {
|
|
340
|
+
await this.backend.redis.zremrangebyscore(this.indexKey(), "-inf", now);
|
|
341
|
+
return await this.backend.redis.zrangebyscore(
|
|
342
|
+
this.indexKey(),
|
|
343
|
+
now + 1,
|
|
344
|
+
"+inf",
|
|
345
|
+
"LIMIT",
|
|
346
|
+
0,
|
|
347
|
+
limit,
|
|
348
|
+
);
|
|
349
|
+
});
|
|
181
350
|
}
|
|
182
351
|
|
|
183
352
|
private enforceValueSize(value: unknown): void {
|
|
@@ -189,23 +358,41 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
|
|
|
189
358
|
}
|
|
190
359
|
}
|
|
191
360
|
|
|
192
|
-
private
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
361
|
+
private quotaExceeded(): UnsupportedProviderStateError {
|
|
362
|
+
return new UnsupportedProviderStateError(
|
|
363
|
+
`Provider runtime state namespace quota exceeded (${this.options.maxEntries + 1} > ${this.options.maxEntries})`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private writeTiming(ttl: StateWriteOptions["ttl"]): {
|
|
368
|
+
expiresAt: string;
|
|
369
|
+
expiresAtMs: number;
|
|
370
|
+
indexTtlMs: number;
|
|
371
|
+
} {
|
|
372
|
+
const ttlMs = parseStateDurationMs(ttl ?? this.options.defaultTtl);
|
|
373
|
+
const maxTtlMs = parseStateDurationMs(this.options.maxTtl);
|
|
374
|
+
if (ttlMs > maxTtlMs) {
|
|
197
375
|
throw new UnsupportedProviderStateError(
|
|
198
|
-
`Provider runtime state
|
|
376
|
+
`Provider runtime state ttl exceeds maxTtl (${ttlMs} > ${maxTtlMs})`,
|
|
199
377
|
);
|
|
200
378
|
}
|
|
379
|
+
const expiresAtMs = Date.now() + ttlMs;
|
|
380
|
+
return {
|
|
381
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
382
|
+
expiresAtMs,
|
|
383
|
+
indexTtlMs: maxTtlMs,
|
|
384
|
+
};
|
|
201
385
|
}
|
|
202
386
|
|
|
203
387
|
async list<T>(options?: { limit?: number; prefix?: string }): Promise<StateValue<T>[]> {
|
|
204
|
-
const
|
|
388
|
+
const requestedLimit = Math.max(0, options?.limit ?? this.options.maxEntries);
|
|
389
|
+
if (requestedLimit === 0) return [];
|
|
390
|
+
await this.backfillLegacyIndex();
|
|
391
|
+
const keys = (await this.indexedKeys(this.options.maxEntries)).filter((key) => {
|
|
205
392
|
const publicKey = publicStateKey(this.providerId, this.namespaceName, key);
|
|
206
393
|
return options?.prefix ? publicKey.startsWith(options.prefix) : true;
|
|
207
394
|
});
|
|
208
|
-
const limited = keys.slice(0,
|
|
395
|
+
const limited = keys.slice(0, requestedLimit);
|
|
209
396
|
if (limited.length === 0) return [];
|
|
210
397
|
const values = await withRequiredRedis(() => this.backend.redis.mget(limited));
|
|
211
398
|
return values.flatMap((raw, index) => {
|
|
@@ -224,22 +411,34 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
|
|
|
224
411
|
|
|
225
412
|
async set<T>(key: string, value: T, options?: StateWriteOptions): Promise<StateValue<T>> {
|
|
226
413
|
this.enforceValueSize(value);
|
|
227
|
-
await this.
|
|
414
|
+
await this.backfillLegacyIndex();
|
|
228
415
|
const current = await this.get<T>(key);
|
|
229
416
|
const createdAt = current?.createdAt ?? new Date().toISOString();
|
|
230
417
|
const version = (current?.version ?? 0) + 1;
|
|
231
|
-
const
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
418
|
+
const timing = this.writeTiming(options?.ttl);
|
|
419
|
+
const envelope = redisEnvelope(value, version, createdAt, timing.expiresAt);
|
|
420
|
+
await requireRedisReady(this.backend.redis);
|
|
421
|
+
const result = await withRequiredRedis(() =>
|
|
422
|
+
this.backend.redis.eval(
|
|
423
|
+
SET_WITH_QUOTA_SCRIPT,
|
|
424
|
+
2,
|
|
425
|
+
this.redisKey(key),
|
|
426
|
+
this.indexKey(),
|
|
427
|
+
String(Date.now()),
|
|
428
|
+
String(this.options.maxEntries),
|
|
429
|
+
String(timing.expiresAtMs),
|
|
430
|
+
String(timing.indexTtlMs),
|
|
431
|
+
JSON.stringify(envelope),
|
|
432
|
+
),
|
|
237
433
|
);
|
|
434
|
+
if (!Array.isArray(result) || Number(result[0]) !== 1) {
|
|
435
|
+
throw this.quotaExceeded();
|
|
436
|
+
}
|
|
238
437
|
return {
|
|
239
438
|
key,
|
|
240
439
|
value,
|
|
241
440
|
version,
|
|
242
|
-
expiresAt,
|
|
441
|
+
expiresAt: timing.expiresAt,
|
|
243
442
|
createdAt,
|
|
244
443
|
updatedAt: envelope.updatedAt,
|
|
245
444
|
};
|
|
@@ -263,16 +462,64 @@ class RedisProviderStateNamespace implements ProviderStateNamespace {
|
|
|
263
462
|
options?: StateWriteOptions,
|
|
264
463
|
): Promise<StateCasResult<T>> {
|
|
265
464
|
this.enforceValueSize(value);
|
|
465
|
+
await this.backfillLegacyIndex();
|
|
266
466
|
const current = await this.get<T>(key);
|
|
267
467
|
if ((current?.version ?? 0) !== expectedVersion) {
|
|
268
468
|
return { ok: false, current };
|
|
269
469
|
}
|
|
270
|
-
|
|
470
|
+
const createdAt = current?.createdAt ?? new Date().toISOString();
|
|
471
|
+
const timing = this.writeTiming(options?.ttl);
|
|
472
|
+
const envelope = redisEnvelope(
|
|
473
|
+
value,
|
|
474
|
+
expectedVersion + 1,
|
|
475
|
+
createdAt,
|
|
476
|
+
timing.expiresAt,
|
|
477
|
+
);
|
|
478
|
+
await requireRedisReady(this.backend.redis);
|
|
479
|
+
const result = await withRequiredRedis(() =>
|
|
480
|
+
this.backend.redis.eval(
|
|
481
|
+
COMPARE_AND_SET_WITH_QUOTA_SCRIPT,
|
|
482
|
+
2,
|
|
483
|
+
this.redisKey(key),
|
|
484
|
+
this.indexKey(),
|
|
485
|
+
String(expectedVersion),
|
|
486
|
+
String(Date.now()),
|
|
487
|
+
String(this.options.maxEntries),
|
|
488
|
+
String(timing.expiresAtMs),
|
|
489
|
+
String(timing.indexTtlMs),
|
|
490
|
+
JSON.stringify(envelope),
|
|
491
|
+
),
|
|
492
|
+
);
|
|
493
|
+
if (Array.isArray(result) && Number(result[0]) === 0) {
|
|
494
|
+
throw this.quotaExceeded();
|
|
495
|
+
}
|
|
496
|
+
if (!Array.isArray(result) || Number(result[0]) !== 1) {
|
|
497
|
+
const rawCurrent = Array.isArray(result) && typeof result[1] === "string" ? result[1] : null;
|
|
498
|
+
return { ok: false, current: envelopeFromJson(key, rawCurrent) };
|
|
499
|
+
}
|
|
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
|
+
};
|
|
271
511
|
}
|
|
272
512
|
|
|
273
513
|
async delete(key: string): Promise<void> {
|
|
274
514
|
await requireRedisReady(this.backend.redis);
|
|
275
|
-
await withRequiredRedis(() =>
|
|
515
|
+
await withRequiredRedis(() =>
|
|
516
|
+
this.backend.redis.eval(
|
|
517
|
+
DELETE_WITH_INDEX_SCRIPT,
|
|
518
|
+
2,
|
|
519
|
+
this.redisKey(key),
|
|
520
|
+
this.indexKey(),
|
|
521
|
+
),
|
|
522
|
+
);
|
|
276
523
|
}
|
|
277
524
|
|
|
278
525
|
async increment(
|
|
@@ -358,6 +605,31 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
|
|
|
358
605
|
|
|
359
606
|
constructor(private readonly options: StateNamespaceOptions) {}
|
|
360
607
|
|
|
608
|
+
private enforceValueSize(value: unknown): void {
|
|
609
|
+
const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
610
|
+
if (bytes > this.options.maxValueBytes) {
|
|
611
|
+
throw new UnsupportedProviderStateError(
|
|
612
|
+
`Provider runtime state value exceeds maxValueBytes (${bytes} > ${this.options.maxValueBytes})`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private enforceWritePolicy(key: string, value: unknown, ttl: StateWriteOptions["ttl"]): void {
|
|
618
|
+
this.enforceValueSize(value);
|
|
619
|
+
const ttlMs = parseStateDurationMs(ttl ?? this.options.defaultTtl);
|
|
620
|
+
const maxTtlMs = parseStateDurationMs(this.options.maxTtl);
|
|
621
|
+
if (ttlMs > maxTtlMs) {
|
|
622
|
+
throw new UnsupportedProviderStateError(
|
|
623
|
+
`Provider runtime state ttl exceeds maxTtl (${ttlMs} > ${maxTtlMs})`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
if (!this.values.has(key) && this.values.size >= this.options.maxEntries) {
|
|
627
|
+
throw new UnsupportedProviderStateError(
|
|
628
|
+
`Provider runtime state namespace quota exceeded (${this.options.maxEntries + 1} > ${this.options.maxEntries})`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
361
633
|
private pruneExpired(nowMs = Date.now()): void {
|
|
362
634
|
for (const [key, row] of this.values.entries()) {
|
|
363
635
|
if (row.expiresAt && Date.parse(row.expiresAt) <= nowMs) {
|
|
@@ -381,6 +653,7 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
|
|
|
381
653
|
|
|
382
654
|
async set<T>(key: string, value: T, options?: StateWriteOptions): Promise<StateValue<T>> {
|
|
383
655
|
this.pruneExpired();
|
|
656
|
+
this.enforceWritePolicy(key, value, options?.ttl);
|
|
384
657
|
const now = new Date().toISOString();
|
|
385
658
|
const current = this.values.get(key);
|
|
386
659
|
const expiresAt = resolveMemoryStateExpiresAt(options?.ttl ?? this.options.defaultTtl);
|
|
@@ -407,14 +680,30 @@ class MemoryProviderStateNamespace implements ProviderStateNamespace {
|
|
|
407
680
|
}
|
|
408
681
|
|
|
409
682
|
async compareAndSet<T>(
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
683
|
+
key: string,
|
|
684
|
+
expectedVersion: number,
|
|
685
|
+
value: T,
|
|
686
|
+
options?: StateWriteOptions,
|
|
414
687
|
): Promise<StateCasResult<T>> {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
)
|
|
688
|
+
this.pruneExpired();
|
|
689
|
+
const current = this.values.get(key) as StateValue<T> | undefined;
|
|
690
|
+
if ((current?.version ?? 0) !== expectedVersion) {
|
|
691
|
+
return { ok: false, current: current ?? null };
|
|
692
|
+
}
|
|
693
|
+
this.enforceWritePolicy(key, value, options?.ttl);
|
|
694
|
+
const now = new Date().toISOString();
|
|
695
|
+
const stored = {
|
|
696
|
+
key,
|
|
697
|
+
value,
|
|
698
|
+
version: expectedVersion + 1,
|
|
699
|
+
expiresAt: resolveMemoryStateExpiresAt(
|
|
700
|
+
options?.ttl ?? this.options.defaultTtl,
|
|
701
|
+
),
|
|
702
|
+
createdAt: current?.createdAt ?? now,
|
|
703
|
+
updatedAt: now,
|
|
704
|
+
} satisfies StateValue<T>;
|
|
705
|
+
this.values.set(key, stored);
|
|
706
|
+
return { ok: true, value: stored };
|
|
418
707
|
}
|
|
419
708
|
|
|
420
709
|
async delete(key: string): Promise<void> {
|
package/src/server/serve.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { AuthAbortError, createAuthFlowHelpers } from "../auth.js";
|
|
|
7
7
|
import {
|
|
8
8
|
SDK_OWNED_PROVIDER_ERROR_CODES,
|
|
9
9
|
SDK_RUNTIME_OWNED_ERROR_CODES,
|
|
10
|
+
SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES,
|
|
10
11
|
} from "../error-resolution.js";
|
|
11
12
|
import {
|
|
12
13
|
AuthError,
|
|
@@ -478,6 +479,7 @@ function createAuthFlowContext(
|
|
|
478
479
|
|
|
479
480
|
return {
|
|
480
481
|
context: {
|
|
482
|
+
flowId: request.flowId,
|
|
481
483
|
connectionId: request.connectionId,
|
|
482
484
|
externalRef: request.externalRef,
|
|
483
485
|
tenantId: request.tenantId ?? "",
|
|
@@ -502,7 +504,12 @@ function createAuthFlowContext(
|
|
|
502
504
|
},
|
|
503
505
|
}
|
|
504
506
|
: {}),
|
|
505
|
-
env: createEnvContext(
|
|
507
|
+
env: createEnvContext([
|
|
508
|
+
...(provider.secrets?.map((secret) => secret.name) ?? []),
|
|
509
|
+
...(provider.auth?.mode === "oauth2_proxied"
|
|
510
|
+
? ["APIFUSE__AUTH_PROXY__URL"]
|
|
511
|
+
: []),
|
|
512
|
+
]),
|
|
506
513
|
credential,
|
|
507
514
|
context: flowContextStore.context,
|
|
508
515
|
stt: options.stt ?? createSttClientFromEnv(provider.stt),
|
|
@@ -531,7 +538,7 @@ export type ProviderServerLogEvent =
|
|
|
531
538
|
| (ProviderServerLogEventBase & {
|
|
532
539
|
level: "info";
|
|
533
540
|
event: "provider_request_completed";
|
|
534
|
-
|
|
541
|
+
})
|
|
535
542
|
| (ProviderServerLogEventBase & {
|
|
536
543
|
level: "warn" | "error";
|
|
537
544
|
event: "provider_request_failed";
|
|
@@ -545,13 +552,13 @@ export type ProviderServerLogEvent =
|
|
|
545
552
|
signal?: "unregistered_provider_error_code";
|
|
546
553
|
signalFix?: string;
|
|
547
554
|
issues?: Array<{ path: string; code: string; message: string }>;
|
|
548
|
-
|
|
555
|
+
})
|
|
549
556
|
| {
|
|
550
557
|
level: "warn";
|
|
551
558
|
event: "provider_secrets_missing";
|
|
552
559
|
providerId: string;
|
|
553
560
|
missingSecrets: string[];
|
|
554
|
-
|
|
561
|
+
}
|
|
555
562
|
| {
|
|
556
563
|
level: "warn";
|
|
557
564
|
event: "provider_cleanup_failed";
|
|
@@ -562,7 +569,7 @@ export type ProviderServerLogEvent =
|
|
|
562
569
|
resource: "browser" | "stealth";
|
|
563
570
|
errorClass: string;
|
|
564
571
|
message: string;
|
|
565
|
-
|
|
572
|
+
}
|
|
566
573
|
| {
|
|
567
574
|
level: "error";
|
|
568
575
|
event: "provider_shutdown_hook_failed";
|
|
@@ -570,7 +577,7 @@ export type ProviderServerLogEvent =
|
|
|
570
577
|
hookIndex: number;
|
|
571
578
|
errorClass: string;
|
|
572
579
|
message: string;
|
|
573
|
-
|
|
580
|
+
};
|
|
574
581
|
|
|
575
582
|
export type ProviderServerLogger = (event: ProviderServerLogEvent) => void;
|
|
576
583
|
|
|
@@ -599,14 +606,14 @@ export type ProviderServerOptions = {
|
|
|
599
606
|
* @example
|
|
600
607
|
* ```ts
|
|
601
608
|
* await serve(provider, {
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
609
|
+
* shutdown: {
|
|
610
|
+
* hooks: [
|
|
611
|
+
* async () => { await emitter.flush(); },
|
|
612
|
+
* async () => { await sessionManager.closeAll("server-shutdown"); },
|
|
613
|
+
* async () => { await lease.release(); },
|
|
614
|
+
* async () => { await router.close(); },
|
|
615
|
+
* ],
|
|
616
|
+
* },
|
|
610
617
|
* });
|
|
611
618
|
* ```
|
|
612
619
|
*/
|
|
@@ -942,34 +949,13 @@ function toStatusCode(error: unknown, declaredErrorCode?: OperationErrorCode): P
|
|
|
942
949
|
) {
|
|
943
950
|
return declaredErrorCode.status;
|
|
944
951
|
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
return 400;
|
|
953
|
-
case "NOT_FOUND":
|
|
954
|
-
case "not_found":
|
|
955
|
-
case "NO_DATA":
|
|
956
|
-
return 404;
|
|
957
|
-
case "RATE_LIMITED":
|
|
958
|
-
case "UPSTREAM_RATE_LIMIT":
|
|
959
|
-
case "LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR":
|
|
960
|
-
return 429;
|
|
961
|
-
// Deterministic upstream business refusal (honest-provider-error-
|
|
962
|
-
// contract): the upstream evaluated the request and said no under
|
|
963
|
-
// its own rules — a conflict with upstream state, never a 5xx.
|
|
964
|
-
case "UPSTREAM_REJECTED":
|
|
965
|
-
return 409;
|
|
966
|
-
case "UPSTREAM_ERROR":
|
|
967
|
-
case "BLOCKED":
|
|
968
|
-
return 502;
|
|
969
|
-
case "STT_UNAVAILABLE":
|
|
970
|
-
case "UNSUPPORTED_STT_BACKEND":
|
|
971
|
-
case "STATEFUL_FORWARDING_REPLAY_CACHE_FULL":
|
|
972
|
-
return 503;
|
|
952
|
+
// Canonical SDK code → status mapping lives in error-resolution.ts so
|
|
953
|
+
// the authoring lint and this runtime path share one source of truth.
|
|
954
|
+
if (typeof error.code === "string") {
|
|
955
|
+
const mappedStatus = SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.get(error.code);
|
|
956
|
+
if (mappedStatus !== undefined) {
|
|
957
|
+
return mappedStatus;
|
|
958
|
+
}
|
|
973
959
|
}
|
|
974
960
|
if (isTransportError(error)) {
|
|
975
961
|
return error.code === "transport_timeout" ? 504 : 502;
|
package/src/server/types.ts
CHANGED
|
@@ -3,7 +3,13 @@ import { PROVIDER_ERROR_SOURCES } from "../observability.js";
|
|
|
3
3
|
|
|
4
4
|
import { HttpRetryPreset } from "../types.js";
|
|
5
5
|
|
|
6
|
-
export const ConnectionModeSchema = z.enum([
|
|
6
|
+
export const ConnectionModeSchema = z.enum([
|
|
7
|
+
"oauth2",
|
|
8
|
+
"oauth2_proxied",
|
|
9
|
+
"credentials",
|
|
10
|
+
"platform-managed",
|
|
11
|
+
"none",
|
|
12
|
+
]);
|
|
7
13
|
|
|
8
14
|
export const OperationConnectionSchema = z.object({
|
|
9
15
|
id: z.string(),
|
package/src/testing/run.ts
CHANGED
|
@@ -29,7 +29,13 @@ import type {
|
|
|
29
29
|
// A single lowercase segment (no hyphen) is a valid id, so the trailing group
|
|
30
30
|
// is optional (`*`), matching providers like `kakaomap`, `kstartup`, `triple`.
|
|
31
31
|
const CONNECTOR_ID_REGEX = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/;
|
|
32
|
-
const VALID_AUTH_MODES = [
|
|
32
|
+
const VALID_AUTH_MODES = [
|
|
33
|
+
"none",
|
|
34
|
+
"platform-managed",
|
|
35
|
+
"credentials",
|
|
36
|
+
"oauth2",
|
|
37
|
+
"oauth2_proxied",
|
|
38
|
+
] as const;
|
|
33
39
|
const UPDATE_SNAPSHOT_ARGS = new Set(["-u", "--update-snapshots"]);
|
|
34
40
|
const snapshotCaptureStates = new WeakMap<
|
|
35
41
|
ProviderContext,
|
|
@@ -754,7 +760,7 @@ function formatJsonDiff(current: unknown, expected: unknown): string {
|
|
|
754
760
|
|
|
755
761
|
if (currentLine === expectedLine) {
|
|
756
762
|
if (currentLine !== undefined) {
|
|
757
|
-
lines.push(`
|
|
763
|
+
lines.push(` ${currentLine}`);
|
|
758
764
|
}
|
|
759
765
|
continue;
|
|
760
766
|
}
|