@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.
@@ -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 trusted
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 read limiting without a deploy.
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: NO_READ_RATE_LIMITS,
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. Higher read throughput for genuinely trusted accounts/locations
817
- // is granted instead via the server-written trust cache (see resolveProxyIdentity
818
- // + getCachedTrustMultiplier), which cannot be forged.
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 trust for reads only. Account trust (session key) and location
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 genuinely trusted SESSION (cache entry keyed on the real cookie's
1185
- // hash — unforgeable) gets its OWN per-session bucket, so trusted
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 → no uplift,
1191
- // no per-session escape.
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 sessionTrusted = false;
1194
- if (isRead && edgeTrustEnabled()) {
1279
+ let sessionScoped = false;
1280
+ if (edgeTrustEnabled()) {
1195
1281
  const lookupKeys = [
1196
1282
  identity.sessionKey,
1197
- identity.cidrKey,
1198
- identity.ipKey,
1199
- identity.workspaceKey,
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
- // Location/workspace trust applies to the shared per-IP bucket for the
1207
- // whole team; session trust applies to the per-session bucket.
1208
- const locationEntry = combineTrustEntries([
1209
- identity.cidrKey ? entries.get(identity.cidrKey) : undefined,
1210
- identity.ipKey ? entries.get(identity.ipKey) : undefined,
1211
- identity.workspaceKey ? entries.get(identity.workspaceKey) : undefined,
1212
- ]);
1213
-
1214
- trustEntry = combineTrustEntries([sessionEntry, locationEntry]);
1215
- sessionTrusted = !!identity.sessionKey && !!sessionEntry;
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
- // Trusted sessions key per-session; everyone else keys per-IP.
1309
+ // Server-verified sessions key per-session; everyone else keys per-IP.
1219
1310
  const limiterId =
1220
- sessionTrusted && identity.sessionKey
1311
+ sessionScoped && identity.sessionKey
1221
1312
  ? identity.sessionKey
1222
1313
  : identity.ipKey;
1223
1314
 
1224
1315
  if (limiterId) {
1225
- const baseRateLimits = getEffectiveRateLimits(routePolicy, callerClass);
1226
- const { profile: rateLimits, suffix: trustSuffix } =
1227
- applyTrustEntryToReads(baseRateLimits, trustEntry);
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
- callerClass,
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': callerClass,
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 found = SUPPORTED_CURRENCIES.find(
70
- (c) => c.code === currency.toUpperCase()
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
- return SUPPORTED_CURRENCIES.some((c) => c.code === currency.toUpperCase());
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
- return SUPPORTED_CURRENCIES.find((c) => c.code === currency.toUpperCase());
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
+ });