@tuturuuu/utils 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/biome.json +1 -1
- package/package.json +17 -15
- package/src/__tests__/api-proxy-guard.test.ts +513 -2
- package/src/__tests__/app-url.test.ts +2 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +10 -45
- package/src/abuse-protection/__tests__/edge-trust.test.ts +58 -0
- package/src/abuse-protection/__tests__/index.test.ts +136 -0
- package/src/abuse-protection/backend-rate-limit.ts +5 -4
- package/src/abuse-protection/edge-trust.ts +74 -6
- package/src/abuse-protection/index.ts +73 -40
- package/src/api-proxy-guard.ts +131 -33
- package/src/currencies.test.ts +33 -0
- package/src/currencies.ts +28 -5
- package/src/effect.test.ts +247 -0
- package/src/effect.ts +259 -0
- package/src/internal-domains.ts +24 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/portless.ts +2 -0
- package/src/task-helper/task-operations.ts +3 -0
package/src/api-proxy-guard.ts
CHANGED
|
@@ -31,10 +31,10 @@ function isEnvFlagEnabled(name: string, defaultOn = true): boolean {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* Whether trusted accounts/locations get scaled read limits (and
|
|
34
|
+
* Whether trusted accounts/locations get scaled read limits (and verified
|
|
35
35
|
* sessions get their own per-session bucket) at the edge. Default on; set
|
|
36
36
|
* `API_PROXY_EDGE_TRUST_ENABLED=0` to disable trust uplift and fall back to
|
|
37
|
-
* legacy per-IP
|
|
37
|
+
* legacy per-IP keying without a deploy.
|
|
38
38
|
*/
|
|
39
39
|
function edgeTrustEnabled(): boolean {
|
|
40
40
|
return isEnvFlagEnabled('API_PROXY_EDGE_TRUST_ENABLED');
|
|
@@ -187,18 +187,39 @@ const DEFAULT_ANONYMOUS_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
|
|
|
187
187
|
createConfig('day', '1 d', 600, 'API_PROXY_ANON_MUTATE_LIMIT_DAY'),
|
|
188
188
|
];
|
|
189
189
|
|
|
190
|
+
const DEFAULT_AUTHENTICATED_READ_RATE_LIMITS: RateLimitConfig[] = [
|
|
191
|
+
createConfig('minute', '1 m', 600, 'API_PROXY_AUTH_READ_LIMIT_MINUTE'),
|
|
192
|
+
createConfig('hour', '1 h', 6000, 'API_PROXY_AUTH_READ_LIMIT_HOUR'),
|
|
193
|
+
createConfig('day', '1 d', 40_000, 'API_PROXY_AUTH_READ_LIMIT_DAY'),
|
|
194
|
+
];
|
|
195
|
+
|
|
190
196
|
const DEFAULT_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
|
|
191
197
|
{ window: 'minute', limit: 60, duration: '1 m' },
|
|
192
198
|
{ window: 'hour', limit: 300, duration: '1 h' },
|
|
193
199
|
{ window: 'day', limit: 2000, duration: '1 d' },
|
|
194
200
|
];
|
|
195
201
|
|
|
202
|
+
const USERS_ME_READ_RATE_LIMITS: RateLimitConfig[] = [
|
|
203
|
+
createConfig('minute', '1 m', 600, 'API_PROXY_USERS_ME_READ_LIMIT_MINUTE'),
|
|
204
|
+
createConfig('hour', '1 h', 6000, 'API_PROXY_USERS_ME_READ_LIMIT_HOUR'),
|
|
205
|
+
createConfig('day', '1 d', 40_000, 'API_PROXY_USERS_ME_READ_LIMIT_DAY'),
|
|
206
|
+
];
|
|
207
|
+
|
|
196
208
|
const USERS_ME_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
|
|
197
209
|
{ window: 'minute', limit: 60, duration: '1 m' },
|
|
198
210
|
{ window: 'hour', limit: 200, duration: '1 h' },
|
|
199
211
|
{ window: 'day', limit: 1200, duration: '1 d' },
|
|
200
212
|
];
|
|
201
213
|
|
|
214
|
+
const WORKSPACE_DASHBOARD_READ_RATE_LIMITS: RateLimitProfile = {
|
|
215
|
+
get: [
|
|
216
|
+
createConfig('minute', '1 m', 600, 'API_PROXY_WORKSPACE_READ_LIMIT_MINUTE'),
|
|
217
|
+
createConfig('hour', '1 h', 6000, 'API_PROXY_WORKSPACE_READ_LIMIT_HOUR'),
|
|
218
|
+
createConfig('day', '1 d', 40_000, 'API_PROXY_WORKSPACE_READ_LIMIT_DAY'),
|
|
219
|
+
],
|
|
220
|
+
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
221
|
+
};
|
|
222
|
+
|
|
202
223
|
const AUTH_RATE_LIMITS: RateLimitProfile = {
|
|
203
224
|
get: NO_READ_RATE_LIMITS,
|
|
204
225
|
mutate: [
|
|
@@ -312,6 +333,25 @@ const USERS_DATABASE_READ_OVER_POST_RATE_LIMITS: RateLimitProfile = {
|
|
|
312
333
|
],
|
|
313
334
|
};
|
|
314
335
|
|
|
336
|
+
const USERS_ADMIN_READ_RATE_LIMITS: RateLimitProfile = {
|
|
337
|
+
get: [
|
|
338
|
+
createConfig(
|
|
339
|
+
'minute',
|
|
340
|
+
'1 m',
|
|
341
|
+
1200,
|
|
342
|
+
'API_PROXY_USERS_ADMIN_READ_LIMIT_MINUTE'
|
|
343
|
+
),
|
|
344
|
+
createConfig(
|
|
345
|
+
'hour',
|
|
346
|
+
'1 h',
|
|
347
|
+
12_000,
|
|
348
|
+
'API_PROXY_USERS_ADMIN_READ_LIMIT_HOUR'
|
|
349
|
+
),
|
|
350
|
+
createConfig('day', '1 d', 80_000, 'API_PROXY_USERS_ADMIN_READ_LIMIT_DAY'),
|
|
351
|
+
],
|
|
352
|
+
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
353
|
+
};
|
|
354
|
+
|
|
315
355
|
const FINANCE_READ_RATE_LIMITS: RateLimitProfile = {
|
|
316
356
|
get: [
|
|
317
357
|
createConfig('minute', '1 m', 1200, 'API_PROXY_FINANCE_READ_LIMIT_MINUTE', [
|
|
@@ -362,6 +402,33 @@ function isUsersDatabaseReadOverPost(req: NextRequest) {
|
|
|
362
402
|
);
|
|
363
403
|
}
|
|
364
404
|
|
|
405
|
+
function isUsersAdminRead(req: NextRequest) {
|
|
406
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const pathname = req.nextUrl.pathname;
|
|
411
|
+
|
|
412
|
+
return (
|
|
413
|
+
/^\/api\/v1\/workspaces\/[^/]+\/users(?:\/(?:database|groups|feedbacks|attendance\/export|[^/]+\/(?:attendance|emails|referrals|linked-promotions|referral-discounts|user-groups)))?\/?$/u.test(
|
|
414
|
+
pathname
|
|
415
|
+
) ||
|
|
416
|
+
/^\/api\/v1\/workspaces\/[^/]+\/user-groups\/[^/]+(?:\/(?:attendance|members(?:\/[^/]+(?:\/feedbacks)?)?|posts(?:\/[^/]+(?:\/status)?)?|linked-products|storage|indicators(?:\/.*)?))?\/?$/u.test(
|
|
417
|
+
pathname
|
|
418
|
+
)
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function isWorkspaceDashboardRead(req: NextRequest) {
|
|
423
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return /^\/api(?:\/v1)?\/workspaces\/[^/]+(?:\/|$)/u.test(
|
|
428
|
+
req.nextUrl.pathname
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
365
432
|
const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
|
|
366
433
|
{
|
|
367
434
|
key: 'cron',
|
|
@@ -441,6 +508,16 @@ const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
|
|
|
441
508
|
matches: isFinanceInvoiceCreateSupportRead,
|
|
442
509
|
rateLimits: FINANCE_READ_RATE_LIMITS,
|
|
443
510
|
},
|
|
511
|
+
{
|
|
512
|
+
key: 'users-admin-read',
|
|
513
|
+
matches: isUsersAdminRead,
|
|
514
|
+
rateLimits: USERS_ADMIN_READ_RATE_LIMITS,
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
key: 'workspace-dashboard-read',
|
|
518
|
+
matches: isWorkspaceDashboardRead,
|
|
519
|
+
rateLimits: WORKSPACE_DASHBOARD_READ_RATE_LIMITS,
|
|
520
|
+
},
|
|
444
521
|
{
|
|
445
522
|
key: 'high-fanout',
|
|
446
523
|
matches: (req) =>
|
|
@@ -467,7 +544,7 @@ const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
|
|
|
467
544
|
key: 'users-me',
|
|
468
545
|
matches: (req) => req.nextUrl.pathname.startsWith('/api/v1/users/me'),
|
|
469
546
|
rateLimits: {
|
|
470
|
-
get:
|
|
547
|
+
get: USERS_ME_READ_RATE_LIMITS,
|
|
471
548
|
mutate: USERS_ME_MUTATE_RATE_LIMITS,
|
|
472
549
|
},
|
|
473
550
|
},
|
|
@@ -813,9 +890,10 @@ function getCallerClass(req: NextRequest): CallerClass {
|
|
|
813
890
|
// Intentionally always 'anonymous'. Auth headers/cookies are forgeable at the
|
|
814
891
|
// edge (we can't verify signatures cheaply here), so presence alone must never
|
|
815
892
|
// grant an elevated proxy budget — real per-user enforcement happens
|
|
816
|
-
// downstream.
|
|
817
|
-
//
|
|
818
|
-
//
|
|
893
|
+
// downstream. Per-session proxy buckets and higher read throughput for
|
|
894
|
+
// genuinely trusted accounts/locations are granted instead via the
|
|
895
|
+
// server-written trust cache (see resolveProxyIdentity +
|
|
896
|
+
// getCachedTrustEntries), which cannot be forged.
|
|
819
897
|
void req;
|
|
820
898
|
return 'anonymous';
|
|
821
899
|
}
|
|
@@ -1050,6 +1128,13 @@ function getEffectiveRateLimits(
|
|
|
1050
1128
|
callerClass: CallerClass
|
|
1051
1129
|
): RateLimitProfile {
|
|
1052
1130
|
if (callerClass === 'authenticated') {
|
|
1131
|
+
if (routePolicy.key === 'default') {
|
|
1132
|
+
return {
|
|
1133
|
+
get: DEFAULT_AUTHENTICATED_READ_RATE_LIMITS,
|
|
1134
|
+
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1053
1138
|
return routePolicy.rateLimits;
|
|
1054
1139
|
}
|
|
1055
1140
|
|
|
@@ -1179,57 +1264,70 @@ export async function guardApiProxyRequest(
|
|
|
1179
1264
|
const routePolicy = getRoutePolicy(req, routePolicies);
|
|
1180
1265
|
const isRead = req.method === 'GET' || req.method === 'HEAD';
|
|
1181
1266
|
|
|
1182
|
-
// Resolve
|
|
1267
|
+
// Resolve server-verified session keying for reads and mutations, but apply
|
|
1268
|
+
// trust uplift to read limits only. Account trust (session key) and location
|
|
1183
1269
|
// trust (cidr/ip keys) are distinguished so that:
|
|
1184
|
-
// * a
|
|
1185
|
-
// hash — unforgeable) gets its OWN per-session bucket, so
|
|
1270
|
+
// * a server-verified SESSION (cache entry keyed on the real cookie's
|
|
1271
|
+
// hash — unforgeable) gets its OWN per-session bucket, so signed-in
|
|
1186
1272
|
// teammates behind one NAT no longer collide; and
|
|
1187
1273
|
// * a trusted LOCATION (office cidr/ip) uplifts the shared per-IP bucket
|
|
1188
1274
|
// for the whole team without any per-request credential.
|
|
1189
1275
|
// Untrusted traffic keeps legacy per-IP keying, preserving single-IP abuse
|
|
1190
|
-
// caps. A forged cookie hashes to a key with no cache entry
|
|
1191
|
-
//
|
|
1276
|
+
// caps. A forged cookie hashes to a key with no cache entry, so it cannot
|
|
1277
|
+
// escape the shared IP bucket.
|
|
1192
1278
|
let trustEntry: CachedTrustEntry | null = null;
|
|
1193
|
-
let
|
|
1194
|
-
if (
|
|
1279
|
+
let sessionScoped = false;
|
|
1280
|
+
if (edgeTrustEnabled()) {
|
|
1195
1281
|
const lookupKeys = [
|
|
1196
1282
|
identity.sessionKey,
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1283
|
+
...(isRead
|
|
1284
|
+
? [identity.cidrKey, identity.ipKey, identity.workspaceKey]
|
|
1285
|
+
: []),
|
|
1200
1286
|
].filter((key): key is string => key != null);
|
|
1201
1287
|
const entries = await getCachedTrustEntries(lookupKeys);
|
|
1202
1288
|
|
|
1203
1289
|
const sessionEntry = identity.sessionKey
|
|
1204
1290
|
? entries.get(identity.sessionKey)
|
|
1205
1291
|
: undefined;
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1292
|
+
sessionScoped = !!identity.sessionKey && !!sessionEntry;
|
|
1293
|
+
|
|
1294
|
+
if (isRead) {
|
|
1295
|
+
// Location/workspace trust applies to the shared per-IP bucket for the
|
|
1296
|
+
// whole team; session trust applies to the per-session bucket.
|
|
1297
|
+
const locationEntry = combineTrustEntries([
|
|
1298
|
+
identity.cidrKey ? entries.get(identity.cidrKey) : undefined,
|
|
1299
|
+
identity.ipKey ? entries.get(identity.ipKey) : undefined,
|
|
1300
|
+
identity.workspaceKey
|
|
1301
|
+
? entries.get(identity.workspaceKey)
|
|
1302
|
+
: undefined,
|
|
1303
|
+
]);
|
|
1304
|
+
|
|
1305
|
+
trustEntry = combineTrustEntries([sessionEntry, locationEntry]);
|
|
1306
|
+
}
|
|
1216
1307
|
}
|
|
1217
1308
|
|
|
1218
|
-
//
|
|
1309
|
+
// Server-verified sessions key per-session; everyone else keys per-IP.
|
|
1219
1310
|
const limiterId =
|
|
1220
|
-
|
|
1311
|
+
sessionScoped && identity.sessionKey
|
|
1221
1312
|
? identity.sessionKey
|
|
1222
1313
|
: identity.ipKey;
|
|
1223
1314
|
|
|
1224
1315
|
if (limiterId) {
|
|
1225
|
-
const
|
|
1226
|
-
|
|
1227
|
-
|
|
1316
|
+
const effectiveCallerClass: CallerClass = sessionScoped
|
|
1317
|
+
? 'authenticated'
|
|
1318
|
+
: callerClass;
|
|
1319
|
+
const baseRateLimits = getEffectiveRateLimits(
|
|
1320
|
+
routePolicy,
|
|
1321
|
+
effectiveCallerClass
|
|
1322
|
+
);
|
|
1323
|
+
const { profile: rateLimits, suffix: trustSuffix } = isRead
|
|
1324
|
+
? applyTrustEntryToReads(baseRateLimits, trustEntry)
|
|
1325
|
+
: { profile: baseRateLimits, suffix: '' };
|
|
1228
1326
|
|
|
1229
1327
|
const limiterPrefix = getRateLimitPrefix(
|
|
1230
1328
|
options.prefixBase,
|
|
1231
1329
|
routePolicy,
|
|
1232
|
-
|
|
1330
|
+
effectiveCallerClass,
|
|
1233
1331
|
trustSuffix
|
|
1234
1332
|
);
|
|
1235
1333
|
const limiterCacheKey = `${limiterPrefix}:${JSON.stringify(rateLimits)}`;
|
|
@@ -1283,7 +1381,7 @@ export async function guardApiProxyRequest(
|
|
|
1283
1381
|
'X-RateLimit-Limit': `${limit}`,
|
|
1284
1382
|
'X-RateLimit-Remaining': `${remaining}`,
|
|
1285
1383
|
'X-RateLimit-Reset': `${Math.ceil(reset / 1000)}`,
|
|
1286
|
-
'X-RateLimit-Caller-Class':
|
|
1384
|
+
'X-RateLimit-Caller-Class': effectiveCallerClass,
|
|
1287
1385
|
'X-RateLimit-Window': window,
|
|
1288
1386
|
'X-RateLimit-Policy': routePolicy.key,
|
|
1289
1387
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getCurrencyConfig,
|
|
4
|
+
getCurrencyLocale,
|
|
5
|
+
isSupportedCurrency,
|
|
6
|
+
resolveSupportedCurrency,
|
|
7
|
+
} from './currencies';
|
|
8
|
+
|
|
9
|
+
describe('resolveSupportedCurrency', () => {
|
|
10
|
+
it('normalizes supported currency codes', () => {
|
|
11
|
+
expect(resolveSupportedCurrency(' sgd ')).toBe('SGD');
|
|
12
|
+
expect(resolveSupportedCurrency('eur')).toBe('EUR');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('uses a supported fallback for missing or unsupported values', () => {
|
|
16
|
+
expect(resolveSupportedCurrency(undefined, 'VND')).toBe('VND');
|
|
17
|
+
expect(resolveSupportedCurrency(null, 'EUR')).toBe('EUR');
|
|
18
|
+
expect(resolveSupportedCurrency('XYZ', 'SGD')).toBe('SGD');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('falls back to USD when the fallback is unsupported', () => {
|
|
22
|
+
expect(resolveSupportedCurrency('', 'XYZ')).toBe('USD');
|
|
23
|
+
expect(resolveSupportedCurrency('ZZZ', 'XYZ')).toBe('USD');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('currency lookup normalization', () => {
|
|
28
|
+
it('trims currency codes before support and locale checks', () => {
|
|
29
|
+
expect(isSupportedCurrency(' vnd ')).toBe(true);
|
|
30
|
+
expect(getCurrencyLocale(' php ')).toBe('en-PH');
|
|
31
|
+
expect(getCurrencyConfig(' eur ')?.code).toBe('EUR');
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/currencies.ts
CHANGED
|
@@ -66,9 +66,8 @@ export type CurrencyConfig = (typeof SUPPORTED_CURRENCIES)[number];
|
|
|
66
66
|
* @returns The BCP 47 locale string for the currency
|
|
67
67
|
*/
|
|
68
68
|
export function getCurrencyLocale(currency = 'VND'): string {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
);
|
|
69
|
+
const code = currency.trim().toUpperCase();
|
|
70
|
+
const found = SUPPORTED_CURRENCIES.find((c) => c.code === code);
|
|
72
71
|
return found?.locale ?? 'en-US';
|
|
73
72
|
}
|
|
74
73
|
|
|
@@ -81,7 +80,30 @@ export function getCurrencyLocale(currency = 'VND'): string {
|
|
|
81
80
|
export function isSupportedCurrency(
|
|
82
81
|
currency: string
|
|
83
82
|
): currency is SupportedCurrency {
|
|
84
|
-
|
|
83
|
+
const code = currency.trim().toUpperCase();
|
|
84
|
+
return SUPPORTED_CURRENCIES.some((c) => c.code === code);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Normalize a currency code to the supported uppercase code set.
|
|
89
|
+
*
|
|
90
|
+
* @param value - The candidate currency code
|
|
91
|
+
* @param fallback - The fallback currency code when value is missing or unsupported
|
|
92
|
+
* @returns A supported currency code
|
|
93
|
+
*/
|
|
94
|
+
export function resolveSupportedCurrency(
|
|
95
|
+
value: string | null | undefined,
|
|
96
|
+
fallback: string = 'USD'
|
|
97
|
+
): SupportedCurrency {
|
|
98
|
+
const normalizedFallback = fallback.trim().toUpperCase();
|
|
99
|
+
const safeFallback = isSupportedCurrency(normalizedFallback)
|
|
100
|
+
? normalizedFallback
|
|
101
|
+
: 'USD';
|
|
102
|
+
const normalizedValue = value?.trim().toUpperCase();
|
|
103
|
+
|
|
104
|
+
return normalizedValue && isSupportedCurrency(normalizedValue)
|
|
105
|
+
? normalizedValue
|
|
106
|
+
: safeFallback;
|
|
85
107
|
}
|
|
86
108
|
|
|
87
109
|
/**
|
|
@@ -93,5 +115,6 @@ export function isSupportedCurrency(
|
|
|
93
115
|
export function getCurrencyConfig(
|
|
94
116
|
currency: string
|
|
95
117
|
): CurrencyConfig | undefined {
|
|
96
|
-
|
|
118
|
+
const code = currency.trim().toUpperCase();
|
|
119
|
+
return SUPPORTED_CURRENCIES.find((c) => c.code === code);
|
|
97
120
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
Effect,
|
|
4
|
+
forEachConcurrently,
|
|
5
|
+
fromDataError,
|
|
6
|
+
fromPromise,
|
|
7
|
+
runEffectAsResult,
|
|
8
|
+
serializeTuturuuuEffectError,
|
|
9
|
+
TuturuuuEffectError,
|
|
10
|
+
toTuturuuuEffectError,
|
|
11
|
+
withTuturuuuRetry,
|
|
12
|
+
withTuturuuuTimeout,
|
|
13
|
+
} from './effect';
|
|
14
|
+
|
|
15
|
+
describe('@tuturuuu/utils/effect', () => {
|
|
16
|
+
it('creates tagged Tuturuuu errors with metadata', () => {
|
|
17
|
+
const error = new TuturuuuEffectError({
|
|
18
|
+
code: 'TEST_ERROR',
|
|
19
|
+
message: 'Test failed',
|
|
20
|
+
status: 418,
|
|
21
|
+
context: { feature: 'effect' },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(error._tag).toBe('TuturuuuEffectError');
|
|
25
|
+
expect(error.message).toBe('Test failed');
|
|
26
|
+
expect(serializeTuturuuuEffectError(error)).toEqual({
|
|
27
|
+
_tag: 'TuturuuuEffectError',
|
|
28
|
+
code: 'TEST_ERROR',
|
|
29
|
+
message: 'Test failed',
|
|
30
|
+
status: 418,
|
|
31
|
+
context: { feature: 'effect' },
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('normalizes unknown errors with fallback metadata', () => {
|
|
36
|
+
const error = toTuturuuuEffectError(new Error('Boom'), {
|
|
37
|
+
code: 'FALLBACK',
|
|
38
|
+
message: 'Fallback message',
|
|
39
|
+
status: 500,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(error).toBeInstanceOf(TuturuuuEffectError);
|
|
43
|
+
expect(error.code).toBe('FALLBACK');
|
|
44
|
+
expect(error.message).toBe('Boom');
|
|
45
|
+
expect(error.status).toBe(500);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('converts data/error style operations into successful effects', async () => {
|
|
49
|
+
const result = await Effect.runPromise(
|
|
50
|
+
fromDataError(
|
|
51
|
+
() => Promise.resolve({ data: { id: 'row-1' }, error: null }),
|
|
52
|
+
{
|
|
53
|
+
code: 'DATA_ERROR',
|
|
54
|
+
message: 'Data read failed',
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(result).toEqual({ id: 'row-1' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('converts data/error style failures into typed errors', async () => {
|
|
63
|
+
const result = await runEffectAsResult(
|
|
64
|
+
fromDataError(
|
|
65
|
+
() =>
|
|
66
|
+
Promise.resolve({
|
|
67
|
+
data: null,
|
|
68
|
+
error: {
|
|
69
|
+
code: 'PGRST116',
|
|
70
|
+
message: 'No row found',
|
|
71
|
+
details: 'Expected one row',
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
{
|
|
75
|
+
code: 'DATA_ERROR',
|
|
76
|
+
message: 'Data read failed',
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(result).toEqual({
|
|
82
|
+
ok: false,
|
|
83
|
+
error: {
|
|
84
|
+
_tag: 'TuturuuuEffectError',
|
|
85
|
+
code: 'PGRST116',
|
|
86
|
+
message: 'No row found',
|
|
87
|
+
context: { details: 'Expected one row' },
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('converts rejected promises into typed errors', async () => {
|
|
93
|
+
const result = await runEffectAsResult(
|
|
94
|
+
fromPromise(() => Promise.reject(new Error('Network failed')), {
|
|
95
|
+
code: 'NETWORK_FAILED',
|
|
96
|
+
message: 'Request failed',
|
|
97
|
+
})
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(result.ok).toBe(false);
|
|
101
|
+
if (!result.ok) {
|
|
102
|
+
expect(result.error).toMatchObject({
|
|
103
|
+
_tag: 'TuturuuuEffectError',
|
|
104
|
+
code: 'NETWORK_FAILED',
|
|
105
|
+
message: 'Network failed',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns success and failure result shapes', async () => {
|
|
111
|
+
await expect(runEffectAsResult(Effect.succeed('ok'))).resolves.toEqual({
|
|
112
|
+
ok: true,
|
|
113
|
+
data: 'ok',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await expect(
|
|
117
|
+
runEffectAsResult(
|
|
118
|
+
Effect.fail(
|
|
119
|
+
new TuturuuuEffectError({
|
|
120
|
+
code: 'EXPECTED_FAILURE',
|
|
121
|
+
message: 'Expected failure',
|
|
122
|
+
})
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
).resolves.toEqual({
|
|
126
|
+
ok: false,
|
|
127
|
+
error: {
|
|
128
|
+
_tag: 'TuturuuuEffectError',
|
|
129
|
+
code: 'EXPECTED_FAILURE',
|
|
130
|
+
message: 'Expected failure',
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('retries expected failures with a typed retry predicate', async () => {
|
|
136
|
+
let attempts = 0;
|
|
137
|
+
const program = Effect.flatMap(
|
|
138
|
+
Effect.sync(() => {
|
|
139
|
+
attempts += 1;
|
|
140
|
+
return attempts;
|
|
141
|
+
}),
|
|
142
|
+
(attempt) =>
|
|
143
|
+
attempt < 3
|
|
144
|
+
? Effect.fail(
|
|
145
|
+
new TuturuuuEffectError({
|
|
146
|
+
code: 'TRANSIENT_FAILURE',
|
|
147
|
+
message: 'Try again',
|
|
148
|
+
})
|
|
149
|
+
)
|
|
150
|
+
: Effect.succeed('ready')
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
await expect(
|
|
154
|
+
Effect.runPromise(
|
|
155
|
+
withTuturuuuRetry(program, {
|
|
156
|
+
times: 2,
|
|
157
|
+
while: (error) => error.code === 'TRANSIENT_FAILURE',
|
|
158
|
+
})
|
|
159
|
+
)
|
|
160
|
+
).resolves.toBe('ready');
|
|
161
|
+
expect(attempts).toBe(3);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('does not retry when the retry predicate rejects the error', async () => {
|
|
165
|
+
let attempts = 0;
|
|
166
|
+
|
|
167
|
+
const result = await runEffectAsResult(
|
|
168
|
+
withTuturuuuRetry(
|
|
169
|
+
Effect.flatMap(
|
|
170
|
+
Effect.sync(() => {
|
|
171
|
+
attempts += 1;
|
|
172
|
+
}),
|
|
173
|
+
() =>
|
|
174
|
+
Effect.fail(
|
|
175
|
+
new TuturuuuEffectError({
|
|
176
|
+
code: 'PERMANENT_FAILURE',
|
|
177
|
+
message: 'Do not retry',
|
|
178
|
+
})
|
|
179
|
+
)
|
|
180
|
+
),
|
|
181
|
+
{
|
|
182
|
+
times: 3,
|
|
183
|
+
while: (error) => error.code === 'TRANSIENT_FAILURE',
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect(attempts).toBe(1);
|
|
189
|
+
expect(result).toEqual({
|
|
190
|
+
ok: false,
|
|
191
|
+
error: {
|
|
192
|
+
_tag: 'TuturuuuEffectError',
|
|
193
|
+
code: 'PERMANENT_FAILURE',
|
|
194
|
+
message: 'Do not retry',
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('converts timeouts into Tuturuuu effect errors', async () => {
|
|
200
|
+
const result = await runEffectAsResult(
|
|
201
|
+
withTuturuuuTimeout(Effect.never, {
|
|
202
|
+
code: 'TIMED_OUT',
|
|
203
|
+
duration: 0,
|
|
204
|
+
message: 'Operation timed out',
|
|
205
|
+
context: { operation: 'test' },
|
|
206
|
+
})
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(result).toEqual({
|
|
210
|
+
ok: false,
|
|
211
|
+
error: {
|
|
212
|
+
_tag: 'TuturuuuEffectError',
|
|
213
|
+
code: 'TIMED_OUT',
|
|
214
|
+
message: 'Operation timed out',
|
|
215
|
+
context: { operation: 'test' },
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('runs effectful collections with bounded concurrency', async () => {
|
|
221
|
+
let active = 0;
|
|
222
|
+
let maxActive = 0;
|
|
223
|
+
|
|
224
|
+
const result = await Effect.runPromise(
|
|
225
|
+
forEachConcurrently(
|
|
226
|
+
[1, 2, 3, 4],
|
|
227
|
+
(item) =>
|
|
228
|
+
Effect.promise(
|
|
229
|
+
() =>
|
|
230
|
+
new Promise<number>((resolve) => {
|
|
231
|
+
active += 1;
|
|
232
|
+
maxActive = Math.max(maxActive, active);
|
|
233
|
+
|
|
234
|
+
setTimeout(() => {
|
|
235
|
+
active -= 1;
|
|
236
|
+
resolve(item * 2);
|
|
237
|
+
}, 5);
|
|
238
|
+
})
|
|
239
|
+
),
|
|
240
|
+
{ concurrency: 2 }
|
|
241
|
+
)
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(result).toEqual([2, 4, 6, 8]);
|
|
245
|
+
expect(maxActive).toBeLessThanOrEqual(2);
|
|
246
|
+
});
|
|
247
|
+
});
|