@tuturuuu/utils 0.8.0 → 0.10.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.
@@ -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
+ });
package/src/effect.ts ADDED
@@ -0,0 +1,259 @@
1
+ import {
2
+ Cause,
3
+ Context,
4
+ Data,
5
+ Duration,
6
+ Effect,
7
+ Either,
8
+ Exit,
9
+ Layer,
10
+ Option,
11
+ Schedule,
12
+ } from 'effect';
13
+
14
+ export {
15
+ Cause,
16
+ Context,
17
+ Data,
18
+ Duration,
19
+ Effect,
20
+ Either,
21
+ Layer,
22
+ Option,
23
+ Schedule,
24
+ };
25
+
26
+ export type TuturuuuEffectErrorContext = Record<string, unknown>;
27
+
28
+ export interface TuturuuuEffectErrorOptions {
29
+ readonly code: string;
30
+ readonly message: string;
31
+ readonly status?: number;
32
+ readonly cause?: unknown;
33
+ readonly context?: TuturuuuEffectErrorContext;
34
+ }
35
+
36
+ export class TuturuuuEffectError extends Data.TaggedError(
37
+ 'TuturuuuEffectError'
38
+ )<TuturuuuEffectErrorOptions> {}
39
+
40
+ export interface SerializedTuturuuuEffectError {
41
+ readonly _tag: 'TuturuuuEffectError';
42
+ readonly code: string;
43
+ readonly message: string;
44
+ readonly status?: number;
45
+ readonly context?: TuturuuuEffectErrorContext;
46
+ readonly cause?: {
47
+ readonly name?: string;
48
+ readonly message: string;
49
+ };
50
+ }
51
+
52
+ export type TuturuuuEffectResult<T> =
53
+ | {
54
+ readonly ok: true;
55
+ readonly data: T;
56
+ }
57
+ | {
58
+ readonly ok: false;
59
+ readonly error: SerializedTuturuuuEffectError;
60
+ };
61
+
62
+ export type DataErrorResult<TData, TError = unknown> = {
63
+ readonly data: TData;
64
+ readonly error: TError | null;
65
+ };
66
+
67
+ export interface TuturuuuEffectRetryOptions<E = unknown> {
68
+ readonly times?: number;
69
+ readonly delay?: Duration.DurationInput;
70
+ readonly while?: (error: E) => boolean;
71
+ }
72
+
73
+ export interface TuturuuuEffectTimeoutOptions
74
+ extends TuturuuuEffectErrorOptions {
75
+ readonly duration: Duration.DurationInput;
76
+ }
77
+
78
+ export interface TuturuuuEffectConcurrencyOptions {
79
+ readonly concurrency?: number | 'unbounded';
80
+ }
81
+
82
+ export const DEFAULT_TUTURUUU_EFFECT_CONCURRENCY = 4;
83
+
84
+ function isRecord(value: unknown): value is Record<string, unknown> {
85
+ return typeof value === 'object' && value !== null;
86
+ }
87
+
88
+ function readStringProperty(
89
+ source: Record<string, unknown> | undefined,
90
+ key: string
91
+ ) {
92
+ const value = source?.[key];
93
+ return typeof value === 'string' && value.trim() ? value : undefined;
94
+ }
95
+
96
+ function readNumberProperty(
97
+ source: Record<string, unknown> | undefined,
98
+ key: string
99
+ ) {
100
+ const value = source?.[key];
101
+ return typeof value === 'number' && Number.isFinite(value)
102
+ ? value
103
+ : undefined;
104
+ }
105
+
106
+ function buildContext(
107
+ source: Record<string, unknown> | undefined,
108
+ fallbackContext: TuturuuuEffectErrorContext | undefined
109
+ ) {
110
+ const context: TuturuuuEffectErrorContext = { ...(fallbackContext ?? {}) };
111
+ const details = readStringProperty(source, 'details');
112
+ const hint = readStringProperty(source, 'hint');
113
+
114
+ if (details) context.details = details;
115
+ if (hint) context.hint = hint;
116
+
117
+ return Object.keys(context).length > 0 ? context : undefined;
118
+ }
119
+
120
+ export function toTuturuuuEffectError(
121
+ error: unknown,
122
+ fallback: TuturuuuEffectErrorOptions
123
+ ) {
124
+ if (error instanceof TuturuuuEffectError) {
125
+ return error;
126
+ }
127
+
128
+ const source = isRecord(error) ? error : undefined;
129
+ const message =
130
+ error instanceof Error && error.message
131
+ ? error.message
132
+ : readStringProperty(source, 'message') || fallback.message;
133
+
134
+ return new TuturuuuEffectError({
135
+ code: readStringProperty(source, 'code') || fallback.code,
136
+ message,
137
+ status: readNumberProperty(source, 'status') ?? fallback.status,
138
+ cause: error,
139
+ context: buildContext(source, fallback.context),
140
+ });
141
+ }
142
+
143
+ export function serializeTuturuuuEffectError(
144
+ error: TuturuuuEffectError
145
+ ): SerializedTuturuuuEffectError {
146
+ return {
147
+ _tag: error._tag,
148
+ code: error.code,
149
+ message: error.message,
150
+ ...(error.status !== undefined ? { status: error.status } : {}),
151
+ ...(error.context ? { context: error.context } : {}),
152
+ ...(error.cause instanceof Error
153
+ ? {
154
+ cause: {
155
+ name: error.cause.name,
156
+ message: error.cause.message,
157
+ },
158
+ }
159
+ : {}),
160
+ };
161
+ }
162
+
163
+ export function fromPromise<T>(
164
+ evaluate: (signal: AbortSignal) => PromiseLike<T>,
165
+ fallback: TuturuuuEffectErrorOptions
166
+ ): Effect.Effect<T, TuturuuuEffectError> {
167
+ return Effect.tryPromise({
168
+ try: evaluate,
169
+ catch: (error) => toTuturuuuEffectError(error, fallback),
170
+ });
171
+ }
172
+
173
+ export function fromDataError<TData, TError = unknown>(
174
+ operation: (
175
+ signal: AbortSignal
176
+ ) => PromiseLike<DataErrorResult<TData, TError>>,
177
+ fallback: TuturuuuEffectErrorOptions
178
+ ): Effect.Effect<TData, TuturuuuEffectError> {
179
+ return Effect.flatMap(fromPromise(operation, fallback), (result) =>
180
+ result.error
181
+ ? Effect.fail(toTuturuuuEffectError(result.error, fallback))
182
+ : Effect.succeed(result.data)
183
+ );
184
+ }
185
+
186
+ export function withTuturuuuRetry<A, E, R>(
187
+ program: Effect.Effect<A, E, R>,
188
+ options: TuturuuuEffectRetryOptions<E> = {}
189
+ ): Effect.Effect<A, E, R> {
190
+ const times = Math.max(0, options.times ?? 2);
191
+
192
+ if (times === 0) {
193
+ return program;
194
+ }
195
+
196
+ return program.pipe(
197
+ Effect.retry({
198
+ ...(options.delay ? { schedule: Schedule.spaced(options.delay) } : {}),
199
+ times,
200
+ ...(options.while ? { while: options.while } : {}),
201
+ })
202
+ );
203
+ }
204
+
205
+ export function withTuturuuuTimeout<A, E, R>(
206
+ program: Effect.Effect<A, E, R>,
207
+ options: TuturuuuEffectTimeoutOptions
208
+ ): Effect.Effect<A, E | TuturuuuEffectError, R> {
209
+ return program.pipe(
210
+ Effect.timeoutFail({
211
+ duration: options.duration,
212
+ onTimeout: () =>
213
+ new TuturuuuEffectError({
214
+ code: options.code,
215
+ message: options.message,
216
+ status: options.status,
217
+ cause: options.cause,
218
+ context: options.context,
219
+ }),
220
+ })
221
+ );
222
+ }
223
+
224
+ export function forEachConcurrently<A, B, E, R>(
225
+ items: Iterable<A>,
226
+ evaluate: (item: A, index: number) => Effect.Effect<B, E, R>,
227
+ options: TuturuuuEffectConcurrencyOptions = {}
228
+ ): Effect.Effect<readonly B[], E, R> {
229
+ return Effect.forEach(items, evaluate, {
230
+ concurrency: options.concurrency ?? DEFAULT_TUTURUUU_EFFECT_CONCURRENCY,
231
+ });
232
+ }
233
+
234
+ export async function runEffectAsResult<T>(
235
+ program: Effect.Effect<T, TuturuuuEffectError, never>
236
+ ): Promise<TuturuuuEffectResult<T>> {
237
+ const exit = await Effect.runPromiseExit(program);
238
+
239
+ if (Exit.isSuccess(exit)) {
240
+ return {
241
+ ok: true,
242
+ data: exit.value,
243
+ };
244
+ }
245
+
246
+ const failure = Cause.failureOption(exit.cause);
247
+ const error = Option.isSome(failure)
248
+ ? failure.value
249
+ : new TuturuuuEffectError({
250
+ code: 'EFFECT_DEFECT',
251
+ message: Cause.pretty(exit.cause),
252
+ cause: exit.cause,
253
+ });
254
+
255
+ return {
256
+ ok: false,
257
+ error: serializeTuturuuuEffectError(error),
258
+ };
259
+ }
@@ -60,6 +60,10 @@ export const PRODUCTION_INTERNAL_APP_DOMAINS = [
60
60
  name: 'finance',
61
61
  url: 'https://finance.tuturuuu.com',
62
62
  },
63
+ {
64
+ name: 'infra',
65
+ url: 'https://infra.tuturuuu.com',
66
+ },
63
67
  {
64
68
  name: 'inventory',
65
69
  url: 'https://inventory.tuturuuu.com',
@@ -68,6 +72,10 @@ export const PRODUCTION_INTERNAL_APP_DOMAINS = [
68
72
  name: 'storefront',
69
73
  url: 'https://storefront.tuturuuu.com',
70
74
  },
75
+ {
76
+ name: 'tanstack-web',
77
+ url: 'https://tanstack.tuturuuu.com',
78
+ },
71
79
  {
72
80
  name: 'track',
73
81
  url: 'https://track.tuturuuu.com',
@@ -143,6 +151,10 @@ export const PORTLESS_INTERNAL_APP_DOMAINS = [
143
151
  name: 'finance',
144
152
  url: getTuturuuuPortlessAppOrigin('finance'),
145
153
  },
154
+ {
155
+ name: 'infra',
156
+ url: getTuturuuuPortlessAppOrigin('infra'),
157
+ },
146
158
  {
147
159
  name: 'inventory',
148
160
  url: getTuturuuuPortlessAppOrigin('inventory'),
@@ -151,6 +163,10 @@ export const PORTLESS_INTERNAL_APP_DOMAINS = [
151
163
  name: 'storefront',
152
164
  url: getTuturuuuPortlessAppOrigin('storefront'),
153
165
  },
166
+ {
167
+ name: 'tanstack-web',
168
+ url: getTuturuuuPortlessAppOrigin('tanstack-web'),
169
+ },
154
170
  {
155
171
  name: 'track',
156
172
  url: getTuturuuuPortlessAppOrigin('track'),
@@ -226,6 +242,10 @@ export const LOCALHOST_INTERNAL_APP_DOMAINS = [
226
242
  name: 'finance',
227
243
  url: 'http://localhost:7808',
228
244
  },
245
+ {
246
+ name: 'infra',
247
+ url: 'http://localhost:7823',
248
+ },
229
249
  {
230
250
  name: 'inventory',
231
251
  url: 'http://localhost:7815',
@@ -234,6 +254,10 @@ export const LOCALHOST_INTERNAL_APP_DOMAINS = [
234
254
  name: 'storefront',
235
255
  url: 'http://localhost:7822',
236
256
  },
257
+ {
258
+ name: 'tanstack-web',
259
+ url: 'http://localhost:7824',
260
+ },
237
261
  {
238
262
  name: 'track',
239
263
  url: 'http://localhost:7810',
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  describe('platform release metadata', () => {
9
9
  it('uses the centralized shared browser app version', () => {
10
- expect(TUTURUUU_PLATFORM_VERSION).toBe('0.12.0'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.14.0'); // x-release-please-version
11
11
  });
12
12
 
13
13
  it('normalizes generated metadata and derives a short hash', () => {
@@ -1,6 +1,6 @@
1
1
  import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
2
2
 
3
- export const TUTURUUU_PLATFORM_VERSION = '0.12.0'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.14.0'; // x-release-please-version
4
4
 
5
5
  export type PlatformBuildMetadataInput = {
6
6
  builtAt?: string | null;
package/src/portless.ts CHANGED
@@ -15,6 +15,7 @@ export const TUTURUUU_PORTLESS_APP_ORIGINS = {
15
15
  finance: `https://finance.${TUTURUUU_PORTLESS_ROOT_HOST}`,
16
16
  hive: `https://hive.${TUTURUUU_PORTLESS_ROOT_HOST}`,
17
17
  'hive-realtime': `https://realtime.hive.${TUTURUUU_PORTLESS_ROOT_HOST}`,
18
+ infra: `https://infra.${TUTURUUU_PORTLESS_ROOT_HOST}`,
18
19
  inventory: `https://inventory.${TUTURUUU_PORTLESS_ROOT_HOST}`,
19
20
  learn: `https://learn.${TUTURUUU_PORTLESS_ROOT_HOST}`,
20
21
  mail: `https://mail.${TUTURUUU_PORTLESS_ROOT_HOST}`,
@@ -27,6 +28,7 @@ export const TUTURUUU_PORTLESS_APP_ORIGINS = {
27
28
  rewise: `https://rewise.${TUTURUUU_PORTLESS_ROOT_HOST}`,
28
29
  shortener: `https://shortener.${TUTURUUU_PORTLESS_ROOT_HOST}`,
29
30
  storefront: `https://storefront.${TUTURUUU_PORTLESS_ROOT_HOST}`,
31
+ 'tanstack-web': `https://tanstack.${TUTURUUU_PORTLESS_ROOT_HOST}`,
30
32
  tasks: `https://tasks.${TUTURUUU_PORTLESS_ROOT_HOST}`,
31
33
  teach: `https://teach.${TUTURUUU_PORTLESS_ROOT_HOST}`,
32
34
  track: `https://track.${TUTURUUU_PORTLESS_ROOT_HOST}`,