@dxos/effect 0.8.4-main.7ace549 → 0.8.4-main.937b3ca
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/dist/lib/browser/chunk-CGS2ULMK.mjs +11 -0
- package/dist/lib/browser/chunk-CGS2ULMK.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +330 -221
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing.mjs +38 -0
- package/dist/lib/browser/testing.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +11 -0
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +330 -221
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/testing.mjs +38 -0
- package/dist/lib/node-esm/testing.mjs.map +7 -0
- package/dist/types/src/RuntimeProvider.d.ts +21 -0
- package/dist/types/src/RuntimeProvider.d.ts.map +1 -0
- package/dist/types/src/ast.d.ts +33 -20
- package/dist/types/src/ast.d.ts.map +1 -1
- package/dist/types/src/atom-kvs.d.ts +19 -0
- package/dist/types/src/atom-kvs.d.ts.map +1 -0
- package/dist/types/src/dynamic-runtime.d.ts +56 -0
- package/dist/types/src/dynamic-runtime.d.ts.map +1 -0
- package/dist/types/src/dynamic-runtime.test.d.ts +2 -0
- package/dist/types/src/dynamic-runtime.test.d.ts.map +1 -0
- package/dist/types/src/errors.d.ts +4 -0
- package/dist/types/src/errors.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +5 -3
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/json-path.d.ts +2 -2
- package/dist/types/src/json-path.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +26 -10
- package/src/RuntimeProvider.ts +35 -0
- package/src/ast.test.ts +9 -7
- package/src/ast.ts +78 -86
- package/src/atom-kvs.ts +35 -0
- package/src/dynamic-runtime.test.ts +465 -0
- package/src/dynamic-runtime.ts +195 -0
- package/src/errors.ts +17 -2
- package/src/index.ts +5 -3
- package/src/interrupt.test.ts +3 -1
- package/src/json-path.test.ts +7 -7
- package/src/json-path.ts +9 -12
- package/src/layers.test.ts +4 -2
- package/src/resource.ts +2 -2
- package/src/sanity.test.ts +6 -4
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Context from 'effect/Context';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Exit from 'effect/Exit';
|
|
8
|
+
import * as Layer from 'effect/Layer';
|
|
9
|
+
import * as ManagedRuntime from 'effect/ManagedRuntime';
|
|
10
|
+
import * as Runtime from 'effect/Runtime';
|
|
11
|
+
import { describe, expect, test } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import * as DynamicRuntime from './dynamic-runtime';
|
|
14
|
+
import { runAndForwardErrors } from './errors';
|
|
15
|
+
|
|
16
|
+
// Test service tags
|
|
17
|
+
class Database extends Context.Tag('Database')<Database, { query: (sql: string) => Effect.Effect<string[]> }>() {}
|
|
18
|
+
|
|
19
|
+
class Logger extends Context.Tag('Logger')<Logger, { log: (msg: string) => Effect.Effect<void> }>() {}
|
|
20
|
+
|
|
21
|
+
class Cache extends Context.Tag('Cache')<Cache, { get: (key: string) => Effect.Effect<string | undefined> }>() {}
|
|
22
|
+
|
|
23
|
+
describe('DynamicRuntime', () => {
|
|
24
|
+
describe('Success Cases', () => {
|
|
25
|
+
test('single tag validation success with runPromise', async () => {
|
|
26
|
+
const layer = Layer.succeed(Database, {
|
|
27
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
28
|
+
});
|
|
29
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
30
|
+
|
|
31
|
+
const program = Effect.gen(function* () {
|
|
32
|
+
const db = yield* Database;
|
|
33
|
+
return yield* db.query('SELECT * FROM users');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const result = await runtime.runPromise(program);
|
|
37
|
+
expect(result).toEqual(['result: SELECT * FROM users']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('single tag validation success with runSync', () => {
|
|
41
|
+
const layer = Layer.succeed(Database, {
|
|
42
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
43
|
+
});
|
|
44
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
45
|
+
|
|
46
|
+
const program = Effect.gen(function* () {
|
|
47
|
+
const db = yield* Database;
|
|
48
|
+
return yield* db.query('SELECT * FROM users');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const result = runtime.runSync(program);
|
|
52
|
+
expect(result).toEqual(['result: SELECT * FROM users']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('single tag validation success with runSyncExit', () => {
|
|
56
|
+
const layer = Layer.succeed(Database, {
|
|
57
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
58
|
+
});
|
|
59
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
60
|
+
|
|
61
|
+
const program = Effect.gen(function* () {
|
|
62
|
+
const db = yield* Database;
|
|
63
|
+
return yield* db.query('SELECT * FROM users');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const exit = runtime.runSyncExit(program);
|
|
67
|
+
expect(Exit.isSuccess(exit)).toBe(true);
|
|
68
|
+
if (Exit.isSuccess(exit)) {
|
|
69
|
+
expect(exit.value).toEqual(['result: SELECT * FROM users']);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('single tag validation success with runPromiseExit', async () => {
|
|
74
|
+
const layer = Layer.succeed(Database, {
|
|
75
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
76
|
+
});
|
|
77
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
78
|
+
|
|
79
|
+
const program = Effect.gen(function* () {
|
|
80
|
+
const db = yield* Database;
|
|
81
|
+
return yield* db.query('SELECT * FROM users');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const exit = await runtime.runPromiseExit(program);
|
|
85
|
+
expect(Exit.isSuccess(exit)).toBe(true);
|
|
86
|
+
if (Exit.isSuccess(exit)) {
|
|
87
|
+
expect(exit.value).toEqual(['result: SELECT * FROM users']);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('multiple tags validation success', async () => {
|
|
92
|
+
const layer = Layer.mergeAll(
|
|
93
|
+
Layer.succeed(Database, {
|
|
94
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
95
|
+
}),
|
|
96
|
+
Layer.succeed(Logger, {
|
|
97
|
+
log: (msg: string) => Effect.sync(() => console.log(msg)),
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database, Logger]);
|
|
101
|
+
|
|
102
|
+
const program = Effect.gen(function* () {
|
|
103
|
+
const db = yield* Database;
|
|
104
|
+
const logger = yield* Logger;
|
|
105
|
+
yield* logger.log('Querying database');
|
|
106
|
+
return yield* db.query('SELECT * FROM users');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const result = await runtime.runPromise(program);
|
|
110
|
+
expect(result).toEqual(['result: SELECT * FROM users']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('effect requiring subset of tags executes successfully', async () => {
|
|
114
|
+
const layer = Layer.mergeAll(
|
|
115
|
+
Layer.succeed(Database, {
|
|
116
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
117
|
+
}),
|
|
118
|
+
Layer.succeed(Logger, {
|
|
119
|
+
log: (msg: string) => Effect.sync(() => console.log(msg)),
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database, Logger]);
|
|
123
|
+
|
|
124
|
+
// Effect only requires Database, not Logger
|
|
125
|
+
const program = Effect.gen(function* () {
|
|
126
|
+
const db = yield* Database;
|
|
127
|
+
return yield* db.query('SELECT * FROM users');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const result = await runtime.runPromise(program);
|
|
131
|
+
expect(result).toEqual(['result: SELECT * FROM users']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('runFork with valid tags', async () => {
|
|
135
|
+
const layer = Layer.succeed(Database, {
|
|
136
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
137
|
+
});
|
|
138
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
139
|
+
|
|
140
|
+
const program = Effect.gen(function* () {
|
|
141
|
+
const db = yield* Database;
|
|
142
|
+
return yield* db.query('SELECT * FROM users');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const fiber = runtime.runFork(program);
|
|
146
|
+
const exit = await runAndForwardErrors(fiber.await);
|
|
147
|
+
expect(Exit.isSuccess(exit)).toBe(true);
|
|
148
|
+
if (Exit.isSuccess(exit)) {
|
|
149
|
+
expect(exit.value).toEqual(['result: SELECT * FROM users']);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('runtimeEffect returns runtime with correct context', async () => {
|
|
154
|
+
const layer = Layer.succeed(Database, {
|
|
155
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
156
|
+
});
|
|
157
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
158
|
+
|
|
159
|
+
const rt = await runtime.runPromise(runtime.runtimeEffect);
|
|
160
|
+
expect(rt).toBeDefined();
|
|
161
|
+
// Verify we can use the runtime directly
|
|
162
|
+
const program = Effect.gen(function* () {
|
|
163
|
+
const db = yield* Database;
|
|
164
|
+
return yield* db.query('test');
|
|
165
|
+
});
|
|
166
|
+
const result = await Runtime.runPromise(rt)(program);
|
|
167
|
+
expect(result).toEqual(['result: test']);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('complex effect composition with multiple tags', async () => {
|
|
171
|
+
const layer = Layer.mergeAll(
|
|
172
|
+
Layer.succeed(Database, {
|
|
173
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
174
|
+
}),
|
|
175
|
+
Layer.succeed(Logger, {
|
|
176
|
+
log: (msg: string) => Effect.sync(() => console.log(msg)),
|
|
177
|
+
}),
|
|
178
|
+
Layer.succeed(Cache, {
|
|
179
|
+
get: (key: string) => Effect.succeed(key === 'key1' ? 'cached' : undefined),
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database, Logger, Cache]);
|
|
183
|
+
|
|
184
|
+
const program = Effect.gen(function* () {
|
|
185
|
+
const db = yield* Database;
|
|
186
|
+
const logger = yield* Logger;
|
|
187
|
+
const cache = yield* Cache;
|
|
188
|
+
|
|
189
|
+
yield* logger.log('Checking cache');
|
|
190
|
+
const cached = yield* cache.get('key1');
|
|
191
|
+
if (cached) {
|
|
192
|
+
yield* logger.log('Cache hit');
|
|
193
|
+
return [cached];
|
|
194
|
+
}
|
|
195
|
+
yield* logger.log('Cache miss, querying database');
|
|
196
|
+
return yield* db.query('SELECT * FROM users');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const result = await runtime.runPromise(program);
|
|
200
|
+
expect(result).toEqual(['cached']);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('empty tag array edge case', async () => {
|
|
204
|
+
const layer = Layer.empty;
|
|
205
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), []);
|
|
206
|
+
|
|
207
|
+
const program = Effect.succeed('no dependencies');
|
|
208
|
+
|
|
209
|
+
const result = await runtime.runPromise(program);
|
|
210
|
+
expect(result).toBe('no dependencies');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('Failure Cases', () => {
|
|
215
|
+
test('missing single tag throws error with runPromise', async () => {
|
|
216
|
+
const layer = Layer.empty; // No tags provided
|
|
217
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
218
|
+
|
|
219
|
+
const program = Effect.gen(function* () {
|
|
220
|
+
const db = yield* Database;
|
|
221
|
+
return yield* db.query('SELECT * FROM users');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await expect(runtime.runPromise(program)).rejects.toThrow(/Missing required tags in runtime: Database/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('missing single tag throws error with runSync', () => {
|
|
228
|
+
const layer = Layer.empty; // No tags provided
|
|
229
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
230
|
+
|
|
231
|
+
const program = Effect.gen(function* () {
|
|
232
|
+
const db = yield* Database;
|
|
233
|
+
return yield* db.query('SELECT * FROM users');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(() => runtime.runSync(program)).toThrow(/Missing required tags in runtime: Database/);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('missing single tag throws error with runSyncExit', () => {
|
|
240
|
+
const layer = Layer.empty; // No tags provided
|
|
241
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
242
|
+
|
|
243
|
+
const program = Effect.gen(function* () {
|
|
244
|
+
const db = yield* Database;
|
|
245
|
+
return yield* db.query('SELECT * FROM users');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const exit = runtime.runSyncExit(program);
|
|
249
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
250
|
+
if (Exit.isFailure(exit)) {
|
|
251
|
+
// Exit.cause is available directly when Exit.isFailure is true
|
|
252
|
+
expect(exit.cause).toBeDefined();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('missing single tag throws error with runPromiseExit', async () => {
|
|
257
|
+
const layer = Layer.empty; // No tags provided
|
|
258
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
259
|
+
|
|
260
|
+
const program = Effect.gen(function* () {
|
|
261
|
+
const db = yield* Database;
|
|
262
|
+
return yield* db.query('SELECT * FROM users');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const exit = await runtime.runPromiseExit(program);
|
|
266
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('missing multiple tags lists all missing tags', async () => {
|
|
270
|
+
const layer = Layer.empty; // No tags provided
|
|
271
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database, Logger, Cache]);
|
|
272
|
+
|
|
273
|
+
const program = Effect.gen(function* () {
|
|
274
|
+
const db = yield* Database;
|
|
275
|
+
const logger = yield* Logger;
|
|
276
|
+
yield* logger.log('test');
|
|
277
|
+
return yield* db.query('SELECT * FROM users');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await expect(runtime.runPromise(program)).rejects.toThrow(/Missing required tags in runtime/);
|
|
281
|
+
try {
|
|
282
|
+
await runtime.runPromise(program);
|
|
283
|
+
} catch (error: any) {
|
|
284
|
+
expect(error.message).toContain('Database');
|
|
285
|
+
expect(error.message).toContain('Logger');
|
|
286
|
+
expect(error.message).toContain('Cache');
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('partial tag availability - only missing tags listed', async () => {
|
|
291
|
+
const layer = Layer.succeed(Database, {
|
|
292
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
293
|
+
});
|
|
294
|
+
// Only Database is provided, Logger and Cache are missing
|
|
295
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database, Logger, Cache]);
|
|
296
|
+
|
|
297
|
+
const program = Effect.gen(function* () {
|
|
298
|
+
const db = yield* Database;
|
|
299
|
+
const logger = yield* Logger;
|
|
300
|
+
yield* logger.log('test');
|
|
301
|
+
return yield* db.query('SELECT * FROM users');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await expect(runtime.runPromise(program)).rejects.toThrow(/Missing required tags in runtime/);
|
|
305
|
+
try {
|
|
306
|
+
await runtime.runPromise(program);
|
|
307
|
+
} catch (error: any) {
|
|
308
|
+
// Should only list missing tags, not Database
|
|
309
|
+
expect(error.message).toContain('Logger');
|
|
310
|
+
expect(error.message).toContain('Cache');
|
|
311
|
+
expect(error.message).not.toContain('Database');
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('runFork with missing tags throws error', async () => {
|
|
316
|
+
const layer = Layer.empty; // No tags provided
|
|
317
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
318
|
+
|
|
319
|
+
const program = Effect.gen(function* () {
|
|
320
|
+
const db = yield* Database;
|
|
321
|
+
return yield* db.query('SELECT * FROM users');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(() => runtime.runFork(program)).toThrow(/Missing required tags in runtime: Database/);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('runtimeEffect with missing tags throws error', async () => {
|
|
328
|
+
const layer = Layer.empty; // No tags provided
|
|
329
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
330
|
+
|
|
331
|
+
await expect(runtime.runPromise(runtime.runtimeEffect)).rejects.toThrow(
|
|
332
|
+
/Missing required tags in runtime: Database/,
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe('Integration Tests', () => {
|
|
338
|
+
test('real-world layer composition with dependencies', async () => {
|
|
339
|
+
// Simulate a real-world scenario with layer dependencies
|
|
340
|
+
const DatabaseLayer = Layer.effect(
|
|
341
|
+
Database,
|
|
342
|
+
Effect.gen(function* () {
|
|
343
|
+
return {
|
|
344
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
345
|
+
};
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const LoggerLayer = Layer.effect(
|
|
350
|
+
Logger,
|
|
351
|
+
Effect.gen(function* () {
|
|
352
|
+
return {
|
|
353
|
+
log: (msg: string) => Effect.sync(() => console.log(msg)),
|
|
354
|
+
};
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const layer = Layer.mergeAll(DatabaseLayer, LoggerLayer);
|
|
359
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database, Logger]);
|
|
360
|
+
|
|
361
|
+
const program = Effect.gen(function* () {
|
|
362
|
+
const logger = yield* Logger;
|
|
363
|
+
yield* logger.log('Starting query');
|
|
364
|
+
const db = yield* Database;
|
|
365
|
+
const result = yield* db.query('SELECT * FROM users');
|
|
366
|
+
yield* logger.log('Query completed');
|
|
367
|
+
return result;
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const result = await runtime.runPromise(program);
|
|
371
|
+
expect(result).toEqual(['result: SELECT * FROM users']);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('runtime lifecycle - dispose properly cleans up', async () => {
|
|
375
|
+
const layer = Layer.succeed(Database, {
|
|
376
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
377
|
+
});
|
|
378
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
379
|
+
|
|
380
|
+
const program = Effect.gen(function* () {
|
|
381
|
+
const db = yield* Database;
|
|
382
|
+
return yield* db.query('test');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const result = await runtime.runPromise(program);
|
|
386
|
+
expect(result).toEqual(['result: test']);
|
|
387
|
+
|
|
388
|
+
// Dispose should not throw
|
|
389
|
+
await expect(runtime.dispose()).resolves.toBeUndefined();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('validation happens lazily on first use', async () => {
|
|
393
|
+
const layer = Layer.empty; // No tags provided
|
|
394
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
395
|
+
|
|
396
|
+
// Creating the runtime should not throw
|
|
397
|
+
expect(runtime).toBeDefined();
|
|
398
|
+
|
|
399
|
+
// First use should trigger validation and throw
|
|
400
|
+
const program = Effect.gen(function* () {
|
|
401
|
+
const db = yield* Database;
|
|
402
|
+
return yield* db.query('test');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
await expect(runtime.runPromise(program)).rejects.toThrow();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('validation is cached for async operations', async () => {
|
|
409
|
+
const layer = Layer.empty; // No tags provided
|
|
410
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
411
|
+
|
|
412
|
+
const program = Effect.gen(function* () {
|
|
413
|
+
const db = yield* Database;
|
|
414
|
+
return yield* db.query('test');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// First call should trigger validation
|
|
418
|
+
const promise1 = runtime.runPromise(program);
|
|
419
|
+
// Second call should use cached validation
|
|
420
|
+
const promise2 = runtime.runPromise(program);
|
|
421
|
+
|
|
422
|
+
// Both should fail with the same error
|
|
423
|
+
await expect(promise1).rejects.toThrow(/Missing required tags in runtime: Database/);
|
|
424
|
+
await expect(promise2).rejects.toThrow(/Missing required tags in runtime: Database/);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
describe('Type Safety', () => {
|
|
429
|
+
test('effects with correct context types are accepted', () => {
|
|
430
|
+
const layer = Layer.succeed(Database, {
|
|
431
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
432
|
+
});
|
|
433
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
434
|
+
|
|
435
|
+
// This should compile without errors
|
|
436
|
+
const program: Effect.Effect<string[], never, Database> = Effect.gen(function* () {
|
|
437
|
+
const db = yield* Database;
|
|
438
|
+
return yield* db.query('test');
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// TypeScript should accept this
|
|
442
|
+
expect(() => runtime.runSync(program)).not.toThrow();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('type safety prevents effects requiring wrong tags', () => {
|
|
446
|
+
const layer = Layer.succeed(Database, {
|
|
447
|
+
query: (sql: string) => Effect.succeed([`result: ${sql}`]),
|
|
448
|
+
});
|
|
449
|
+
const runtime = DynamicRuntime.make(ManagedRuntime.make(layer), [Database]);
|
|
450
|
+
|
|
451
|
+
// This effect requires Logger but runtime only provides Database
|
|
452
|
+
// TypeScript correctly prevents this at compile time - the type system enforces
|
|
453
|
+
// that effects must only require tags that are in the runtime's tag list.
|
|
454
|
+
// If you uncomment the lines below, TypeScript will error:
|
|
455
|
+
// const program = Effect.gen(function* () {
|
|
456
|
+
// const logger = yield* Logger; // Error: Logger is not assignable to Database
|
|
457
|
+
// yield* logger.log('test');
|
|
458
|
+
// });
|
|
459
|
+
// runtime.runSync(program); // Error: Effect<void, never, Logger> is not assignable to Effect<void, never, Database>
|
|
460
|
+
|
|
461
|
+
// The type system successfully prevents this, which is the desired behavior
|
|
462
|
+
expect(runtime).toBeDefined();
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Context from 'effect/Context';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Exit from 'effect/Exit';
|
|
8
|
+
import type * as Fiber from 'effect/Fiber';
|
|
9
|
+
import type * as ManagedRuntime from 'effect/ManagedRuntime';
|
|
10
|
+
import * as Option from 'effect/Option';
|
|
11
|
+
import * as Runtime from 'effect/Runtime';
|
|
12
|
+
|
|
13
|
+
import { unwrapExit } from './errors';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Helper type to construct a union of tag identifiers from an array of tags.
|
|
17
|
+
*/
|
|
18
|
+
export type TagsToContext<Tags extends ReadonlyArray<Context.Tag<any, any>>> = Tags extends readonly [
|
|
19
|
+
infer Head,
|
|
20
|
+
...infer Tail,
|
|
21
|
+
]
|
|
22
|
+
? Head extends Context.Tag<infer Id, any>
|
|
23
|
+
? Tail extends ReadonlyArray<Context.Tag<any, any>>
|
|
24
|
+
? Id | TagsToContext<Tail>
|
|
25
|
+
: Id
|
|
26
|
+
: never
|
|
27
|
+
: never;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A runtime wrapper that validates required tags are available at runtime
|
|
31
|
+
* while providing type-level guarantees that effects require those tags.
|
|
32
|
+
*/
|
|
33
|
+
export interface DynamicRuntime<Tags extends ReadonlyArray<Context.Tag<any, any>>> {
|
|
34
|
+
/**
|
|
35
|
+
* Run an effect as a promise that requires the specified tags.
|
|
36
|
+
*/
|
|
37
|
+
readonly runPromise: <A, E>(effect: Effect.Effect<A, E, TagsToContext<Tags>>) => Promise<A>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run an effect synchronously that requires the specified tags.
|
|
41
|
+
*/
|
|
42
|
+
readonly runSync: <A, E>(effect: Effect.Effect<A, E, TagsToContext<Tags>>) => A;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run an effect synchronously returning exit that requires the specified tags.
|
|
46
|
+
*/
|
|
47
|
+
readonly runSyncExit: <A, E>(effect: Effect.Effect<A, E, TagsToContext<Tags>>) => Exit.Exit<A, E>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Run an effect as a promise returning exit that requires the specified tags.
|
|
51
|
+
*/
|
|
52
|
+
readonly runPromiseExit: <A, E>(effect: Effect.Effect<A, E, TagsToContext<Tags>>) => Promise<Exit.Exit<A, E>>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fork an effect that requires the specified tags.
|
|
56
|
+
*/
|
|
57
|
+
readonly runFork: <A, E>(effect: Effect.Effect<A, E, TagsToContext<Tags>>) => Fiber.RuntimeFiber<A, E>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get the runtime as an effect that requires the specified tags.
|
|
61
|
+
*/
|
|
62
|
+
readonly runtimeEffect: Effect.Effect<Runtime.Runtime<TagsToContext<Tags>>, never, never>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Dispose the underlying managed runtime.
|
|
66
|
+
*/
|
|
67
|
+
readonly dispose: () => Promise<void>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get the underlying managed runtime.
|
|
71
|
+
*/
|
|
72
|
+
readonly managedRuntime: ManagedRuntime.ManagedRuntime<any, any>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate that all required tags are present in the runtime context.
|
|
77
|
+
*/
|
|
78
|
+
const validateTags = <Tags extends ReadonlyArray<Context.Tag<any, any>>>(
|
|
79
|
+
context: Context.Context<any>,
|
|
80
|
+
tags: Tags,
|
|
81
|
+
): Effect.Effect<void> =>
|
|
82
|
+
Effect.gen(function* () {
|
|
83
|
+
const missingTags: string[] = [];
|
|
84
|
+
for (const tag of tags) {
|
|
85
|
+
const option = Context.getOption(context, tag);
|
|
86
|
+
if (Option.isNone(option)) {
|
|
87
|
+
missingTags.push(tag.key);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (missingTags.length > 0) {
|
|
92
|
+
return yield* Effect.die(new Error(`Missing required tags in runtime: ${missingTags.join(', ')}`));
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Create a dynamic runtime from a managed runtime and validate required tags.
|
|
98
|
+
*/
|
|
99
|
+
export function make<const Tags extends ReadonlyArray<Context.Tag<any, any>>>(
|
|
100
|
+
managedRuntime: ManagedRuntime.ManagedRuntime<any, any> | ManagedRuntime.ManagedRuntime<never, never>,
|
|
101
|
+
tags: Tags,
|
|
102
|
+
): DynamicRuntime<Tags> {
|
|
103
|
+
type RequiredContext = TagsToContext<Tags>;
|
|
104
|
+
const managedRuntimeAny = managedRuntime as ManagedRuntime.ManagedRuntime<any, any>;
|
|
105
|
+
|
|
106
|
+
// Cache for the validated runtime - once resolved, can be used synchronously.
|
|
107
|
+
let cachedRuntime: Runtime.Runtime<RequiredContext> | undefined;
|
|
108
|
+
|
|
109
|
+
// Cache validated runtime for async operations.
|
|
110
|
+
let validatedRuntimePromise: Promise<Runtime.Runtime<RequiredContext>> | undefined;
|
|
111
|
+
|
|
112
|
+
const getValidatedRuntimeAsync = async (): Promise<Runtime.Runtime<RequiredContext>> => {
|
|
113
|
+
if (!validatedRuntimePromise) {
|
|
114
|
+
validatedRuntimePromise = managedRuntimeAny.runPromise(
|
|
115
|
+
Effect.gen(function* () {
|
|
116
|
+
const rt = yield* managedRuntimeAny.runtimeEffect;
|
|
117
|
+
yield* validateTags(rt.context, tags);
|
|
118
|
+
return rt as Runtime.Runtime<RequiredContext>;
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return validatedRuntimePromise;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Get validated runtime for sync operations.
|
|
126
|
+
const getValidatedRuntime = (): Runtime.Runtime<RequiredContext> => {
|
|
127
|
+
const validationExit = managedRuntimeAny.runSyncExit(
|
|
128
|
+
Effect.gen(function* () {
|
|
129
|
+
const rt = yield* managedRuntimeAny.runtimeEffect;
|
|
130
|
+
yield* validateTags(rt.context, tags);
|
|
131
|
+
return rt as Runtime.Runtime<RequiredContext>;
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
return unwrapExit(validationExit);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
managedRuntime: managedRuntimeAny,
|
|
139
|
+
runPromise: async <A, E>(effect: Effect.Effect<A, E, RequiredContext>): Promise<A> => {
|
|
140
|
+
const runtime = await getValidatedRuntimeAsync();
|
|
141
|
+
return Runtime.runPromise(runtime)(effect);
|
|
142
|
+
},
|
|
143
|
+
runSync: <A, E>(effect: Effect.Effect<A, E, RequiredContext>): A => {
|
|
144
|
+
const runtime = getValidatedRuntime();
|
|
145
|
+
return Runtime.runSync(runtime)(effect);
|
|
146
|
+
},
|
|
147
|
+
runSyncExit: <A, E>(effect: Effect.Effect<A, E, RequiredContext>): Exit.Exit<A, E> => {
|
|
148
|
+
const validationExit = managedRuntimeAny.runSyncExit(
|
|
149
|
+
Effect.gen(function* () {
|
|
150
|
+
const rt = yield* managedRuntimeAny.runtimeEffect;
|
|
151
|
+
yield* validateTags(rt.context, tags);
|
|
152
|
+
return rt as Runtime.Runtime<RequiredContext>;
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
if (Exit.isSuccess(validationExit)) {
|
|
156
|
+
const runtime = validationExit.value;
|
|
157
|
+
return Runtime.runSyncExit(runtime)(effect);
|
|
158
|
+
}
|
|
159
|
+
return validationExit as Exit.Exit<A, E>;
|
|
160
|
+
},
|
|
161
|
+
runPromiseExit: async <A, E>(effect: Effect.Effect<A, E, RequiredContext>): Promise<Exit.Exit<A, E>> => {
|
|
162
|
+
try {
|
|
163
|
+
const runtime = await getValidatedRuntimeAsync();
|
|
164
|
+
return Runtime.runPromiseExit(runtime)(effect);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
// If validation failed, return a failure exit
|
|
167
|
+
return Exit.die(error);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
runFork: <A, E>(effect: Effect.Effect<A, E, RequiredContext>): Fiber.RuntimeFiber<A, E> => {
|
|
171
|
+
const runtime = getValidatedRuntime();
|
|
172
|
+
return Runtime.runFork(runtime)(effect);
|
|
173
|
+
},
|
|
174
|
+
runtimeEffect: Effect.gen(function* () {
|
|
175
|
+
// Return cached runtime if available.
|
|
176
|
+
if (cachedRuntime) {
|
|
177
|
+
return cachedRuntime;
|
|
178
|
+
}
|
|
179
|
+
const rt = yield* managedRuntimeAny.runtimeEffect;
|
|
180
|
+
yield* validateTags(rt.context, tags);
|
|
181
|
+
const runtime = rt as Runtime.Runtime<RequiredContext>;
|
|
182
|
+
// Cache for future sync calls.
|
|
183
|
+
cachedRuntime = runtime;
|
|
184
|
+
return runtime;
|
|
185
|
+
}).pipe(
|
|
186
|
+
Effect.catchAll(() =>
|
|
187
|
+
// This should never happen since validateTags uses Effect.die
|
|
188
|
+
Effect.die(new Error('Unexpected error in runtimeEffect validation')),
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
dispose: async (): Promise<void> => {
|
|
192
|
+
await managedRuntimeAny.dispose();
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|