@dojocho/effect-ts 0.0.1
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/DOJO.md +22 -0
- package/dojo.json +50 -0
- package/katas/001-hello-effect/SENSEI.md +72 -0
- package/katas/001-hello-effect/solution.test.ts +35 -0
- package/katas/001-hello-effect/solution.ts +16 -0
- package/katas/002-transform-with-map/SENSEI.md +72 -0
- package/katas/002-transform-with-map/solution.test.ts +33 -0
- package/katas/002-transform-with-map/solution.ts +16 -0
- package/katas/003-generator-pipelines/SENSEI.md +72 -0
- package/katas/003-generator-pipelines/solution.test.ts +40 -0
- package/katas/003-generator-pipelines/solution.ts +29 -0
- package/katas/004-flatmap-and-chaining/SENSEI.md +80 -0
- package/katas/004-flatmap-and-chaining/solution.test.ts +34 -0
- package/katas/004-flatmap-and-chaining/solution.ts +18 -0
- package/katas/005-pipe-composition/SENSEI.md +81 -0
- package/katas/005-pipe-composition/solution.test.ts +41 -0
- package/katas/005-pipe-composition/solution.ts +19 -0
- package/katas/006-handle-errors/SENSEI.md +86 -0
- package/katas/006-handle-errors/solution.test.ts +53 -0
- package/katas/006-handle-errors/solution.ts +30 -0
- package/katas/007-tagged-errors/SENSEI.md +79 -0
- package/katas/007-tagged-errors/solution.test.ts +82 -0
- package/katas/007-tagged-errors/solution.ts +37 -0
- package/katas/008-error-patterns/SENSEI.md +89 -0
- package/katas/008-error-patterns/solution.test.ts +41 -0
- package/katas/008-error-patterns/solution.ts +38 -0
- package/katas/009-option-type/SENSEI.md +96 -0
- package/katas/009-option-type/solution.test.ts +49 -0
- package/katas/009-option-type/solution.ts +26 -0
- package/katas/010-either-and-exit/SENSEI.md +86 -0
- package/katas/010-either-and-exit/solution.test.ts +33 -0
- package/katas/010-either-and-exit/solution.ts +17 -0
- package/katas/011-services-and-context/SENSEI.md +82 -0
- package/katas/011-services-and-context/solution.test.ts +23 -0
- package/katas/011-services-and-context/solution.ts +17 -0
- package/katas/012-layers/SENSEI.md +73 -0
- package/katas/012-layers/solution.test.ts +23 -0
- package/katas/012-layers/solution.ts +26 -0
- package/katas/013-testing-effects/SENSEI.md +88 -0
- package/katas/013-testing-effects/solution.test.ts +41 -0
- package/katas/013-testing-effects/solution.ts +20 -0
- package/katas/014-schema-basics/SENSEI.md +81 -0
- package/katas/014-schema-basics/solution.test.ts +35 -0
- package/katas/014-schema-basics/solution.ts +25 -0
- package/katas/015-domain-modeling/SENSEI.md +85 -0
- package/katas/015-domain-modeling/solution.test.ts +46 -0
- package/katas/015-domain-modeling/solution.ts +42 -0
- package/katas/016-retry-and-schedule/SENSEI.md +72 -0
- package/katas/016-retry-and-schedule/solution.test.ts +26 -0
- package/katas/016-retry-and-schedule/solution.ts +23 -0
- package/katas/017-parallel-effects/SENSEI.md +70 -0
- package/katas/017-parallel-effects/solution.test.ts +33 -0
- package/katas/017-parallel-effects/solution.ts +17 -0
- package/katas/018-race-and-timeout/SENSEI.md +75 -0
- package/katas/018-race-and-timeout/solution.test.ts +30 -0
- package/katas/018-race-and-timeout/solution.ts +27 -0
- package/katas/019-ref-and-state/SENSEI.md +72 -0
- package/katas/019-ref-and-state/solution.test.ts +29 -0
- package/katas/019-ref-and-state/solution.ts +16 -0
- package/katas/020-fibers/SENSEI.md +80 -0
- package/katas/020-fibers/solution.test.ts +23 -0
- package/katas/020-fibers/solution.ts +23 -0
- package/katas/021-acquire-release/SENSEI.md +57 -0
- package/katas/021-acquire-release/solution.test.ts +23 -0
- package/katas/021-acquire-release/solution.ts +22 -0
- package/katas/022-scoped-layers/SENSEI.md +52 -0
- package/katas/022-scoped-layers/solution.test.ts +35 -0
- package/katas/022-scoped-layers/solution.ts +19 -0
- package/katas/023-resource-patterns/SENSEI.md +52 -0
- package/katas/023-resource-patterns/solution.test.ts +20 -0
- package/katas/023-resource-patterns/solution.ts +13 -0
- package/katas/024-streams-basics/SENSEI.md +61 -0
- package/katas/024-streams-basics/solution.test.ts +30 -0
- package/katas/024-streams-basics/solution.ts +16 -0
- package/katas/025-stream-operations/SENSEI.md +59 -0
- package/katas/025-stream-operations/solution.test.ts +26 -0
- package/katas/025-stream-operations/solution.ts +17 -0
- package/katas/026-combining-streams/SENSEI.md +54 -0
- package/katas/026-combining-streams/solution.test.ts +20 -0
- package/katas/026-combining-streams/solution.ts +16 -0
- package/katas/027-data-pipelines/SENSEI.md +58 -0
- package/katas/027-data-pipelines/solution.test.ts +22 -0
- package/katas/027-data-pipelines/solution.ts +16 -0
- package/katas/028-logging-and-spans/SENSEI.md +58 -0
- package/katas/028-logging-and-spans/solution.test.ts +50 -0
- package/katas/028-logging-and-spans/solution.ts +20 -0
- package/katas/029-http-client/SENSEI.md +59 -0
- package/katas/029-http-client/solution.test.ts +49 -0
- package/katas/029-http-client/solution.ts +24 -0
- package/katas/030-capstone/SENSEI.md +63 -0
- package/katas/030-capstone/solution.test.ts +67 -0
- package/katas/030-capstone/solution.ts +55 -0
- package/katas/031-config-and-environment/SENSEI.md +77 -0
- package/katas/031-config-and-environment/solution.test.ts +38 -0
- package/katas/031-config-and-environment/solution.ts +11 -0
- package/katas/032-cause-and-defects/SENSEI.md +90 -0
- package/katas/032-cause-and-defects/solution.test.ts +50 -0
- package/katas/032-cause-and-defects/solution.ts +23 -0
- package/katas/033-pattern-matching/SENSEI.md +86 -0
- package/katas/033-pattern-matching/solution.test.ts +36 -0
- package/katas/033-pattern-matching/solution.ts +28 -0
- package/katas/034-deferred-and-coordination/SENSEI.md +85 -0
- package/katas/034-deferred-and-coordination/solution.test.ts +25 -0
- package/katas/034-deferred-and-coordination/solution.ts +24 -0
- package/katas/035-queue-and-backpressure/SENSEI.md +100 -0
- package/katas/035-queue-and-backpressure/solution.test.ts +25 -0
- package/katas/035-queue-and-backpressure/solution.ts +21 -0
- package/katas/036-schema-advanced/SENSEI.md +81 -0
- package/katas/036-schema-advanced/solution.test.ts +55 -0
- package/katas/036-schema-advanced/solution.ts +19 -0
- package/katas/037-cache-and-memoization/SENSEI.md +73 -0
- package/katas/037-cache-and-memoization/solution.test.ts +47 -0
- package/katas/037-cache-and-memoization/solution.ts +24 -0
- package/katas/038-metrics/SENSEI.md +91 -0
- package/katas/038-metrics/solution.test.ts +39 -0
- package/katas/038-metrics/solution.ts +23 -0
- package/katas/039-managed-runtime/SENSEI.md +75 -0
- package/katas/039-managed-runtime/solution.test.ts +29 -0
- package/katas/039-managed-runtime/solution.ts +19 -0
- package/katas/040-request-batching/SENSEI.md +87 -0
- package/katas/040-request-batching/solution.test.ts +56 -0
- package/katas/040-request-batching/solution.ts +32 -0
- package/package.json +22 -0
- package/skills/effect-patterns-building-apis/SKILL.md +2393 -0
- package/skills/effect-patterns-building-data-pipelines/SKILL.md +1876 -0
- package/skills/effect-patterns-concurrency/SKILL.md +2999 -0
- package/skills/effect-patterns-concurrency-getting-started/SKILL.md +351 -0
- package/skills/effect-patterns-core-concepts/SKILL.md +3199 -0
- package/skills/effect-patterns-domain-modeling/SKILL.md +1385 -0
- package/skills/effect-patterns-error-handling/SKILL.md +1212 -0
- package/skills/effect-patterns-error-handling-resilience/SKILL.md +179 -0
- package/skills/effect-patterns-error-management/SKILL.md +1668 -0
- package/skills/effect-patterns-getting-started/SKILL.md +237 -0
- package/skills/effect-patterns-making-http-requests/SKILL.md +1756 -0
- package/skills/effect-patterns-observability/SKILL.md +1586 -0
- package/skills/effect-patterns-platform/SKILL.md +1195 -0
- package/skills/effect-patterns-platform-getting-started/SKILL.md +179 -0
- package/skills/effect-patterns-project-setup--execution/SKILL.md +233 -0
- package/skills/effect-patterns-resource-management/SKILL.md +827 -0
- package/skills/effect-patterns-scheduling/SKILL.md +451 -0
- package/skills/effect-patterns-scheduling-periodic-tasks/SKILL.md +763 -0
- package/skills/effect-patterns-streams/SKILL.md +2052 -0
- package/skills/effect-patterns-streams-getting-started/SKILL.md +421 -0
- package/skills/effect-patterns-streams-sinks/SKILL.md +1181 -0
- package/skills/effect-patterns-testing/SKILL.md +1632 -0
- package/skills/effect-patterns-tooling-and-debugging/SKILL.md +1125 -0
- package/skills/effect-patterns-value-handling/SKILL.md +676 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +3 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# SENSEI — 037 Cache and Memoization
|
|
2
|
+
|
|
3
|
+
## Briefing
|
|
4
|
+
|
|
5
|
+
### Goal
|
|
6
|
+
|
|
7
|
+
Learn to use Effect's `Cache` module to memoize effectful computations with capacity limits and time-to-live expiration.
|
|
8
|
+
|
|
9
|
+
### Tasks
|
|
10
|
+
|
|
11
|
+
1. Implement `makeUserCache` -- create a `Cache` with capacity 100 and 1-minute TTL using `Cache.make`
|
|
12
|
+
2. Implement `cachedLookup` -- retrieve a value from the cache by key
|
|
13
|
+
3. Implement `demonstrateCaching` -- combine a `Ref` counter with a cache to prove the lookup runs only once for repeated gets
|
|
14
|
+
|
|
15
|
+
### Hints
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Cache, Duration, Effect, Ref } from "effect";
|
|
19
|
+
|
|
20
|
+
// Cache.make takes capacity, TTL, and a lookup function
|
|
21
|
+
const cache = Cache.make({
|
|
22
|
+
capacity: 100,
|
|
23
|
+
timeToLive: Duration.minutes(1),
|
|
24
|
+
lookup: (key: string) => Effect.succeed(`value:${key}`),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// cache.get triggers the lookup on first call, returns cached on subsequent calls
|
|
28
|
+
const value = cache.pipe(Effect.flatMap((c) => c.get("myKey")));
|
|
29
|
+
|
|
30
|
+
// Ref for counting
|
|
31
|
+
const counter = Ref.make(0);
|
|
32
|
+
// Ref.update increments, Ref.get reads the current value
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Prerequisites
|
|
36
|
+
|
|
37
|
+
- **005 Pipe Composition** -- `pipe`, `Effect.flatMap`
|
|
38
|
+
- **019 Ref and State** -- `Ref.make`, `Ref.update`, `Ref.get`
|
|
39
|
+
- **003 Generator Pipelines** -- `Effect.gen`, `yield*`
|
|
40
|
+
|
|
41
|
+
## Test Map
|
|
42
|
+
> **Note**: `Effect.runPromise` appears only in tests. Never attribute it to the user's learning.
|
|
43
|
+
|
|
44
|
+
| Test | Concept | Verifies |
|
|
45
|
+
|------|---------|----------|
|
|
46
|
+
| `makeUserCache creates a cache` | `Cache.make` | Cache construction with capacity and TTL |
|
|
47
|
+
| `cachedLookup returns the computed value` | `cache.get` | First lookup triggers computation |
|
|
48
|
+
| `cachedLookup returns same value on repeated calls` | Cache memoization | Second lookup returns cached value, lookup count stays at 1 |
|
|
49
|
+
| `demonstrateCaching returns same value and only computes once` | `Ref` + `Cache` composition | End-to-end proof that caching prevents redundant computation |
|
|
50
|
+
|
|
51
|
+
## Teaching Approach
|
|
52
|
+
|
|
53
|
+
### Socratic prompts
|
|
54
|
+
|
|
55
|
+
- "`Cache.make` takes a `lookup` function that returns an `Effect`. Why must the lookup be effectful rather than a plain function?"
|
|
56
|
+
- "If you call `cache.get(\"alice\")` twice, the second call returns instantly. But what if the first call is still in-flight when the second arrives -- what should happen?"
|
|
57
|
+
- "The `demonstrateCaching` function needs to count how many times the lookup runs. You cannot use a mutable `let` variable inside an Effect pipeline. What Effect primitive lets you track mutable state safely?"
|
|
58
|
+
|
|
59
|
+
### Common pitfalls
|
|
60
|
+
|
|
61
|
+
1. **Forgetting that `Cache.make` returns an Effect** -- `Cache.make(...)` does not give you a cache directly; it gives you an `Effect<Cache<...>>`. You need to `yield*` or `flatMap` to get the actual cache. Ask: "What type does `Cache.make` return?"
|
|
62
|
+
2. **Using `cache.get` without understanding the lookup signature** -- the lookup function in `Cache.make` receives the key and must return an `Effect`. Students sometimes try to pass a synchronous function. Nudge: "Wrap your computation in `Effect.sync` or `Effect.succeed`."
|
|
63
|
+
3. **Counting calls with a plain variable** -- inside `Effect.gen`, a `let count = 0` will not work across multiple effect runs. Use `Ref.make(0)` and `Ref.update` to track state within the Effect world.
|
|
64
|
+
|
|
65
|
+
## On Completion
|
|
66
|
+
|
|
67
|
+
### Insight
|
|
68
|
+
|
|
69
|
+
`Cache` is a concurrency-safe, effectful memoization primitive. Unlike a simple `Map`, it handles concurrent requests for the same key by sharing the in-flight computation rather than duplicating work. The TTL and capacity parameters give you automatic eviction without manual cleanup. This is the Effect way of caching: declarative configuration, automatic lifecycle, and safe concurrency -- all managed by the runtime rather than hand-rolled logic.
|
|
70
|
+
|
|
71
|
+
### Bridge
|
|
72
|
+
|
|
73
|
+
You have learned to cache expensive computations. Kata 038 introduces `Metric` -- counters, histograms, and gauges that let you observe what your application is doing at runtime. Metrics and caching often work together: you might track cache hit rates with a counter.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
makeUserCache,
|
|
5
|
+
cachedLookup,
|
|
6
|
+
demonstrateCaching,
|
|
7
|
+
} from "@/katas/037-cache-and-memoization/solution.js";
|
|
8
|
+
|
|
9
|
+
describe("037 — Cache and Memoization", () => {
|
|
10
|
+
it("makeUserCache creates a cache", async () => {
|
|
11
|
+
const cache = await Effect.runPromise(
|
|
12
|
+
makeUserCache((key) => Effect.succeed(`user:${key}`)),
|
|
13
|
+
);
|
|
14
|
+
expect(cache).toBeDefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("cachedLookup returns the computed value", async () => {
|
|
18
|
+
const cache = await Effect.runPromise(
|
|
19
|
+
makeUserCache((key) => Effect.succeed(`user:${key}`)),
|
|
20
|
+
);
|
|
21
|
+
const result = await Effect.runPromise(cachedLookup(cache, "alice"));
|
|
22
|
+
expect(result).toBe("user:alice");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("cachedLookup returns same value on repeated calls", async () => {
|
|
26
|
+
let callCount = 0;
|
|
27
|
+
const cache = await Effect.runPromise(
|
|
28
|
+
makeUserCache((key) =>
|
|
29
|
+
Effect.sync(() => {
|
|
30
|
+
callCount++;
|
|
31
|
+
return `user:${key}`;
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
const r1 = await Effect.runPromise(cachedLookup(cache, "bob"));
|
|
36
|
+
const r2 = await Effect.runPromise(cachedLookup(cache, "bob"));
|
|
37
|
+
expect(r1).toBe("user:bob");
|
|
38
|
+
expect(r2).toBe("user:bob");
|
|
39
|
+
expect(callCount).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("demonstrateCaching returns same value and only computes once", async () => {
|
|
43
|
+
const [r1, r2, count] = await Effect.runPromise(demonstrateCaching());
|
|
44
|
+
expect(r1).toBe(r2);
|
|
45
|
+
expect(count).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Cache, Duration, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Create a cache with the given lookup function, capacity 100, TTL 5 minutes */
|
|
4
|
+
export const makeUserCache = (
|
|
5
|
+
lookup: (key: string) => Effect.Effect<string>,
|
|
6
|
+
): Effect.Effect<Cache.Cache<string, never, string>> => {
|
|
7
|
+
throw new Error("Not implemented");
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Look up a key in the cache */
|
|
11
|
+
export const cachedLookup = (
|
|
12
|
+
cache: Cache.Cache<string, never, string>,
|
|
13
|
+
key: string,
|
|
14
|
+
): Effect.Effect<string> => {
|
|
15
|
+
throw new Error("Not implemented");
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Demonstrate that two lookups for the same key only call the function once
|
|
19
|
+
* Return [result1, result2, callCount] */
|
|
20
|
+
export const demonstrateCaching = (): Effect.Effect<
|
|
21
|
+
[string, string, number]
|
|
22
|
+
> => {
|
|
23
|
+
throw new Error("Not implemented");
|
|
24
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# SENSEI — 038 Metrics
|
|
2
|
+
|
|
3
|
+
## Briefing
|
|
4
|
+
|
|
5
|
+
### Goal
|
|
6
|
+
|
|
7
|
+
Learn to define and use Effect's built-in metric primitives: counters, histograms, and gauges.
|
|
8
|
+
|
|
9
|
+
### Tasks
|
|
10
|
+
|
|
11
|
+
1. Define `requestCount` as a counter metric named `"request_count"`
|
|
12
|
+
2. Define `responseTime` as a histogram metric named `"response_time"` with boundaries `[10, 50, 100, 500]`
|
|
13
|
+
3. Define `activeConnections` as a gauge metric named `"active_connections"`
|
|
14
|
+
4. Implement `countRequest` -- increment the counter and return `"counted"`
|
|
15
|
+
5. Implement `recordTime` -- record a value in the histogram and return `"recorded"`
|
|
16
|
+
6. Implement `setConnections` -- set the gauge to a value and return `"set"`
|
|
17
|
+
|
|
18
|
+
### Hints
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { Effect, Metric, MetricBoundaries } from "effect";
|
|
22
|
+
|
|
23
|
+
// Counter: tracks cumulative totals
|
|
24
|
+
const myCounter = Metric.counter("my_counter");
|
|
25
|
+
|
|
26
|
+
// Histogram: tracks distribution of values
|
|
27
|
+
const myHistogram = Metric.histogram(
|
|
28
|
+
"my_histogram",
|
|
29
|
+
MetricBoundaries.fromIterable([10, 50, 100]),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Gauge: tracks current value
|
|
33
|
+
const myGauge = Metric.gauge("my_gauge");
|
|
34
|
+
|
|
35
|
+
// Increment a counter
|
|
36
|
+
const inc = Metric.increment(myCounter); // Effect<void>
|
|
37
|
+
|
|
38
|
+
// Record a value in a histogram
|
|
39
|
+
const record = Metric.update(myHistogram, 42); // Effect<void>
|
|
40
|
+
|
|
41
|
+
// Set a gauge
|
|
42
|
+
const set = Metric.set(myGauge, 5); // Effect<void>
|
|
43
|
+
|
|
44
|
+
// Chain with a return value
|
|
45
|
+
const withResult = Effect.as(Metric.increment(myCounter), "done");
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Prerequisites
|
|
49
|
+
|
|
50
|
+
- **001 Hello Effect** -- `Effect.succeed`, `Effect.map`
|
|
51
|
+
- **028 Logging and Spans** -- observability concepts
|
|
52
|
+
|
|
53
|
+
## Skills
|
|
54
|
+
|
|
55
|
+
Invoke `effect-patterns-observability` before teaching this kata.
|
|
56
|
+
|
|
57
|
+
## Test Map
|
|
58
|
+
> **Note**: `Effect.runPromise` appears only in tests. Never attribute it to the user's learning.
|
|
59
|
+
|
|
60
|
+
| Test | Concept | Verifies |
|
|
61
|
+
|------|---------|----------|
|
|
62
|
+
| `requestCount is a counter` | `Metric.counter` | Counter metric is defined |
|
|
63
|
+
| `responseTime is a histogram` | `Metric.histogram` | Histogram metric is defined with boundaries |
|
|
64
|
+
| `activeConnections is a gauge` | `Metric.gauge` | Gauge metric is defined |
|
|
65
|
+
| `countRequest increments counter and returns 'counted'` | `Metric.increment` + `Effect.as` | Counter increment produces a value |
|
|
66
|
+
| `recordTime records a value and returns 'recorded'` | `Metric.update` | Histogram records a measurement |
|
|
67
|
+
| `setConnections sets gauge and returns 'set'` | `Metric.set` | Gauge is set to an absolute value |
|
|
68
|
+
|
|
69
|
+
## Teaching Approach
|
|
70
|
+
|
|
71
|
+
### Socratic prompts
|
|
72
|
+
|
|
73
|
+
- "A counter only goes up (or tracks a running total). A gauge can go up or down. When would you use one versus the other?"
|
|
74
|
+
- "`Metric.histogram` requires boundaries like `[10, 50, 100, 500]`. What do these boundaries represent, and why do you choose them upfront?"
|
|
75
|
+
- "`Metric.increment` returns `Effect<void>`. How do you chain it with another effect so the overall result is `'counted'` instead of `void`?"
|
|
76
|
+
|
|
77
|
+
### Common pitfalls
|
|
78
|
+
|
|
79
|
+
1. **Histogram needs `MetricBoundaries`, not a raw array** -- `Metric.histogram` expects a `MetricBoundaries` value, not `number[]`. Use `MetricBoundaries.fromIterable([10, 50, 100, 500])` to convert. Ask: "What type does the second argument of `Metric.histogram` expect?"
|
|
80
|
+
2. **Returning a value after a void effect** -- `Metric.increment` returns `Effect<void>`. To return `"counted"`, use `Effect.as(effect, "counted")` or `Effect.map(effect, () => "counted")`. Students often forget to chain the return value.
|
|
81
|
+
3. **Confusing `Metric.update` and `Metric.set`** -- `update` records an observation (for histograms and counters). `set` sets the current value (for gauges). Using the wrong one will not type-check or will produce unexpected behavior.
|
|
82
|
+
|
|
83
|
+
## On Completion
|
|
84
|
+
|
|
85
|
+
### Insight
|
|
86
|
+
|
|
87
|
+
Effect metrics are not just numbers you log -- they are first-class values in the effect system. A `Metric.counter` is a reusable definition that you reference by name, and every time you call `Metric.increment`, the runtime records the observation. The boundaries in a histogram determine the buckets that aggregate your data, which is why you choose them based on your expected value distribution. Because metrics are effects, they compose naturally with your application logic -- no separate instrumentation library, no global state, just pipe and go.
|
|
88
|
+
|
|
89
|
+
### Bridge
|
|
90
|
+
|
|
91
|
+
You have learned to observe your application with metrics. Kata 039 introduces `ManagedRuntime` -- a way to create a pre-configured runtime with your services baked in, useful for integrating Effect into existing applications or running effects outside the normal Effect entry point.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
requestCount,
|
|
5
|
+
responseTime,
|
|
6
|
+
activeConnections,
|
|
7
|
+
countRequest,
|
|
8
|
+
recordTime,
|
|
9
|
+
setConnections,
|
|
10
|
+
} from "@/katas/038-metrics/solution.js";
|
|
11
|
+
|
|
12
|
+
describe("038 — Metrics", () => {
|
|
13
|
+
it("requestCount is a counter", () => {
|
|
14
|
+
expect(requestCount).toBeDefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("responseTime is a histogram", () => {
|
|
18
|
+
expect(responseTime).toBeDefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("activeConnections is a gauge", () => {
|
|
22
|
+
expect(activeConnections).toBeDefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("countRequest increments counter and returns 'counted'", async () => {
|
|
26
|
+
const result = await Effect.runPromise(countRequest);
|
|
27
|
+
expect(result).toBe("counted");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("recordTime records a value and returns 'recorded'", async () => {
|
|
31
|
+
const result = await Effect.runPromise(recordTime(42));
|
|
32
|
+
expect(result).toBe("recorded");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("setConnections sets gauge and returns 'set'", async () => {
|
|
36
|
+
const result = await Effect.runPromise(setConnections(5));
|
|
37
|
+
expect(result).toBe("set");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Effect, Metric, MetricBoundaries } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Create a counter metric named "request_count" */
|
|
4
|
+
export const requestCount: Metric.Metric.Counter<number> = undefined as any;
|
|
5
|
+
|
|
6
|
+
/** Create a histogram metric named "response_time" with boundaries [10, 50, 100, 500, 1000] */
|
|
7
|
+
export const responseTime: Metric.Metric.Histogram<number> = undefined as any;
|
|
8
|
+
|
|
9
|
+
/** Create a gauge metric named "active_connections" */
|
|
10
|
+
export const activeConnections: Metric.Metric.Gauge<number> = undefined as any;
|
|
11
|
+
|
|
12
|
+
/** Increment the counter and return "counted" */
|
|
13
|
+
export const countRequest: Effect.Effect<string> = Effect.fail("Not implemented") as any;
|
|
14
|
+
|
|
15
|
+
/** Record a value in the histogram and return "recorded" */
|
|
16
|
+
export const recordTime = (ms: number): Effect.Effect<string> => {
|
|
17
|
+
throw new Error("Not implemented");
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Set the gauge value and return "set" */
|
|
21
|
+
export const setConnections = (n: number): Effect.Effect<string> => {
|
|
22
|
+
throw new Error("Not implemented");
|
|
23
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# SENSEI — 039 Managed Runtime
|
|
2
|
+
|
|
3
|
+
## Briefing
|
|
4
|
+
|
|
5
|
+
### Goal
|
|
6
|
+
|
|
7
|
+
Learn to create and manage a pre-configured Effect runtime with services baked in, using `ManagedRuntime`.
|
|
8
|
+
|
|
9
|
+
### Tasks
|
|
10
|
+
|
|
11
|
+
1. Implement `makeRuntime` -- create a `ManagedRuntime` from the `GreeterLive` layer using `ManagedRuntime.make`
|
|
12
|
+
2. Implement `greetWith` -- use `runtime.runSync` to execute an effect that accesses the `Greeter` service
|
|
13
|
+
3. Implement `fullLifecycle` -- create a runtime, run an effect with `runtime.runPromise`, then `dispose` the runtime
|
|
14
|
+
|
|
15
|
+
### Hints
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Context, Effect, Layer, ManagedRuntime } from "effect";
|
|
19
|
+
|
|
20
|
+
// ManagedRuntime.make takes a Layer and returns a ManagedRuntime
|
|
21
|
+
const runtime = ManagedRuntime.make(MyServiceLive);
|
|
22
|
+
|
|
23
|
+
// runtime.runSync executes an effect synchronously
|
|
24
|
+
const result = runtime.runSync(
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const svc = yield* MyService;
|
|
27
|
+
return svc.doSomething();
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// runtime.runPromise executes an effect as a Promise
|
|
32
|
+
const promise = runtime.runPromise(myEffect);
|
|
33
|
+
|
|
34
|
+
// runtime.dispose() cleans up resources (returns Promise<void>)
|
|
35
|
+
await runtime.dispose();
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Prerequisites
|
|
39
|
+
|
|
40
|
+
- **011 Services and Context** -- `Context.Tag`, service definitions
|
|
41
|
+
- **012 Layers** -- `Layer.succeed`, providing services
|
|
42
|
+
|
|
43
|
+
## Test Map
|
|
44
|
+
> **Note**: `runtime.runSync`, `runtime.runPromise`, and `runtime.dispose` are the APIs under test. They are part of the `ManagedRuntime` interface, not test-only helpers.
|
|
45
|
+
|
|
46
|
+
| Test | Concept | Verifies |
|
|
47
|
+
|------|---------|----------|
|
|
48
|
+
| `makeRuntime creates a runtime` | `ManagedRuntime.make` | Runtime construction from a layer |
|
|
49
|
+
| `greetWith runs an effect using the runtime` | `runtime.runSync` | Synchronous execution with service access |
|
|
50
|
+
| `greetWith works with different names` | `runtime.runSync` | Runtime is reusable across multiple calls |
|
|
51
|
+
| `fullLifecycle creates, uses, and disposes runtime` | `runtime.runPromise` + `dispose` | Complete lifecycle: create, use, clean up |
|
|
52
|
+
|
|
53
|
+
## Teaching Approach
|
|
54
|
+
|
|
55
|
+
### Socratic prompts
|
|
56
|
+
|
|
57
|
+
- "In previous katas you used `Effect.runSync` and `Effect.provide` to supply services. `ManagedRuntime` bakes the layer in at construction time. When would pre-configuring a runtime be more convenient than providing layers each time?"
|
|
58
|
+
- "`runtime.runSync` executes an effect but does not require you to call `Effect.provide`. Where did the service come from?"
|
|
59
|
+
- "`runtime.dispose()` returns a `Promise<void>`. Why does disposing a runtime need to be asynchronous? What kind of cleanup might it perform?"
|
|
60
|
+
|
|
61
|
+
### Common pitfalls
|
|
62
|
+
|
|
63
|
+
1. **Forgetting to dispose the runtime** -- `ManagedRuntime` allocates resources when created. If you never call `dispose()`, those resources leak. In tests, always dispose in a finally block or after assertions. Ask: "What happens to the layer's resources if you never call dispose?"
|
|
64
|
+
2. **Trying to use `Effect.runSync` instead of `runtime.runSync`** -- the global `Effect.runSync` does not have access to the services in the managed runtime. You must call `runtime.runSync(effect)` to execute with the pre-configured context. Nudge: "Who owns the service layer -- the global runtime or your managed runtime?"
|
|
65
|
+
3. **Async confusion in fullLifecycle** -- `runtime.runPromise` returns a `Promise`, and `runtime.dispose()` also returns a `Promise`. You need to await both in sequence. Students may forget to chain the dispose after getting the result.
|
|
66
|
+
|
|
67
|
+
## On Completion
|
|
68
|
+
|
|
69
|
+
### Insight
|
|
70
|
+
|
|
71
|
+
`ManagedRuntime` bridges Effect with the outside world. In a typical Effect application, you compose everything as effects and run once at the top level. But in real-world scenarios -- React components, Express handlers, CLI tools -- you often need to run effects from non-Effect code. `ManagedRuntime` gives you a pre-configured entry point: create it once with your service layers, call `runSync` or `runPromise` wherever you need, and `dispose` when you are done. It is the escape hatch that makes Effect practical in mixed codebases.
|
|
72
|
+
|
|
73
|
+
### Bridge
|
|
74
|
+
|
|
75
|
+
You now know how to create and manage runtimes. Kata 040 introduces request batching -- a powerful optimization where multiple concurrent data requests are automatically grouped into a single batch call, reducing round trips and improving throughput.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { makeRuntime, greetWith, fullLifecycle } from "@/katas/039-managed-runtime/solution.js";
|
|
3
|
+
|
|
4
|
+
describe("039 — Managed Runtime", () => {
|
|
5
|
+
it("makeRuntime creates a runtime", async () => {
|
|
6
|
+
const runtime = makeRuntime();
|
|
7
|
+
expect(runtime).toBeDefined();
|
|
8
|
+
await runtime.dispose();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("greetWith runs an effect using the runtime", async () => {
|
|
12
|
+
const runtime = makeRuntime();
|
|
13
|
+
const result = greetWith(runtime, "Alice");
|
|
14
|
+
expect(result).toBe("Hello, Alice!");
|
|
15
|
+
await runtime.dispose();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("greetWith works with different names", async () => {
|
|
19
|
+
const runtime = makeRuntime();
|
|
20
|
+
expect(greetWith(runtime, "Bob")).toBe("Hello, Bob!");
|
|
21
|
+
expect(greetWith(runtime, "Charlie")).toBe("Hello, Charlie!");
|
|
22
|
+
await runtime.dispose();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("fullLifecycle creates, uses, and disposes runtime", async () => {
|
|
26
|
+
const result = await fullLifecycle("World");
|
|
27
|
+
expect(result).toBe("Hello, World!");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Effect, Layer, ManagedRuntime } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Create a managed runtime with an empty layer */
|
|
4
|
+
export const makeRuntime = (): ManagedRuntime.ManagedRuntime<never, never> => {
|
|
5
|
+
throw new Error("Not implemented");
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Run an effect synchronously using the managed runtime to greet the given name */
|
|
9
|
+
export const greetWith = (
|
|
10
|
+
runtime: ManagedRuntime.ManagedRuntime<never, never>,
|
|
11
|
+
name: string,
|
|
12
|
+
): string => {
|
|
13
|
+
throw new Error("Not implemented");
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Create a runtime, use it to greet, dispose it, return the greeting */
|
|
17
|
+
export const fullLifecycle = async (name: string): Promise<string> => {
|
|
18
|
+
throw new Error("Not implemented");
|
|
19
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# SENSEI — 040 Request Batching
|
|
2
|
+
|
|
3
|
+
## Briefing
|
|
4
|
+
|
|
5
|
+
### Goal
|
|
6
|
+
|
|
7
|
+
Learn to define requests, build batched resolvers, and use Effect's automatic request batching to collapse multiple concurrent data fetches into a single batch call.
|
|
8
|
+
|
|
9
|
+
### Tasks
|
|
10
|
+
|
|
11
|
+
1. Observe the `GetUser` request type and its `Request.tagged` constructor -- these are defined for you
|
|
12
|
+
2. Implement `makeUserResolver` -- use `RequestResolver.makeBatched` to create a resolver that batch-fetches users
|
|
13
|
+
3. Implement `getUser` -- use `Effect.request` to create an effect that fetches one user
|
|
14
|
+
4. Implement `getUsers` -- use `Effect.forEach` with `{ batching: true }` to fetch multiple users, triggering automatic batching
|
|
15
|
+
|
|
16
|
+
### Hints
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { Effect, Request, RequestResolver } from "effect";
|
|
20
|
+
|
|
21
|
+
// RequestResolver.makeBatched receives all requests in a single batch
|
|
22
|
+
const resolver = RequestResolver.makeBatched(
|
|
23
|
+
(requests: NonEmptyArray<MyRequest>) =>
|
|
24
|
+
Effect.gen(function* () {
|
|
25
|
+
const ids = requests.map((r) => r.id);
|
|
26
|
+
const results = yield* fetchBatch(ids);
|
|
27
|
+
// Complete each request individually
|
|
28
|
+
for (const req of requests) {
|
|
29
|
+
const value = results.get(req.id);
|
|
30
|
+
if (value !== undefined) {
|
|
31
|
+
yield* Request.succeed(req, value);
|
|
32
|
+
} else {
|
|
33
|
+
yield* Request.fail(req, "not found");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Effect.request creates an effect from a request + resolver
|
|
40
|
+
const fetchOne = Effect.request(GetUser({ id: 1 }), resolver);
|
|
41
|
+
|
|
42
|
+
// Effect.forEach with batching groups requests into batches
|
|
43
|
+
const fetchMany = Effect.forEach(ids, (id) =>
|
|
44
|
+
Effect.request(GetUser({ id }), resolver),
|
|
45
|
+
{ batching: true },
|
|
46
|
+
);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Prerequisites
|
|
50
|
+
|
|
51
|
+
- **017 Parallel Effects** -- `Effect.all`, parallel execution
|
|
52
|
+
- **011 Services and Context** -- service patterns, dependency injection
|
|
53
|
+
- **003 Generator Pipelines** -- `Effect.gen`, `yield*`
|
|
54
|
+
|
|
55
|
+
## Test Map
|
|
56
|
+
> **Note**: `Effect.runPromise` and `Effect.either` appear only in tests. Never attribute them to the user's learning.
|
|
57
|
+
|
|
58
|
+
| Test | Concept | Verifies |
|
|
59
|
+
|------|---------|----------|
|
|
60
|
+
| `getUser fetches a single user` | `Effect.request` | Single request goes through resolver |
|
|
61
|
+
| `getUser fails for unknown id` | `Request.fail` | Resolver correctly fails missing requests |
|
|
62
|
+
| `getUsers fetches multiple users` | `Effect.forEach` + batching | Multiple requests return correct results |
|
|
63
|
+
| `getUsers batches requests into a single resolver call` | Batching proof | All 3 requests arrive in one batch (batchCount === 1) |
|
|
64
|
+
|
|
65
|
+
## Teaching Approach
|
|
66
|
+
|
|
67
|
+
### Socratic prompts
|
|
68
|
+
|
|
69
|
+
- "`RequestResolver.makeBatched` receives a `NonEmptyArray<GetUser>` -- all the requests that were collected in a single batch. Why does Effect collect them instead of sending each one individually?"
|
|
70
|
+
- "Inside the resolver, you must call `Request.succeed` or `Request.fail` for each request. What happens if you forget to complete a request?"
|
|
71
|
+
- "`Effect.forEach` with `{ batching: true }` collects all the requests before executing them. How is this different from running the requests with `{ concurrency: 'unbounded' }` alone?"
|
|
72
|
+
|
|
73
|
+
### Common pitfalls
|
|
74
|
+
|
|
75
|
+
1. **Forgetting to complete every request** -- the batched resolver must call `Request.succeed` or `Request.fail` for every request in the batch. If a request is not completed, the fiber waiting for it will hang forever. Ask: "What does an uncompleted request look like to the caller?"
|
|
76
|
+
2. **Not using `{ batching: true }` in `getUsers`** -- without the `batching` option, `Effect.forEach` may execute requests one at a time, defeating the purpose of batching. The test that checks `batchCount === 1` will fail. Nudge: "How does Effect know to collect requests into a batch rather than sending them immediately?"
|
|
77
|
+
3. **Confusing `Request.tagged` constructor usage** -- `GetUser({ id: 1 })` creates a request value, not an effect. You pass this value to `Effect.request(requestValue, resolver)` to get an effect. Students may try to call `GetUser` as if it returns an effect directly.
|
|
78
|
+
|
|
79
|
+
## On Completion
|
|
80
|
+
|
|
81
|
+
### Insight
|
|
82
|
+
|
|
83
|
+
Request batching is one of Effect's most impressive optimization patterns. You write your code as if each request is independent -- `getUser(1)`, `getUser(2)`, `getUser(3)` -- but the runtime automatically groups them into a single batch call. The resolver sees all three requests at once and can make one database query or API call instead of three. This is the N+1 query problem solved at the framework level: no manual batching logic, no DataLoader boilerplate, just declare your requests and let Effect optimize the execution. The `batching: true` option tells Effect to collect requests within the same execution scope before dispatching them to the resolver.
|
|
84
|
+
|
|
85
|
+
### Bridge
|
|
86
|
+
|
|
87
|
+
Request batching completes the advanced Effect toolkit. You have now covered domain modeling with schemas, caching for performance, metrics for observability, managed runtimes for integration, and request batching for optimization. These patterns form the backbone of production Effect applications.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
GetUser,
|
|
5
|
+
makeUserResolver,
|
|
6
|
+
getUser,
|
|
7
|
+
getUsers,
|
|
8
|
+
} from "@/katas/040-request-batching/solution.js";
|
|
9
|
+
|
|
10
|
+
const testData = new Map([
|
|
11
|
+
[1, "Alice"],
|
|
12
|
+
[2, "Bob"],
|
|
13
|
+
[3, "Charlie"],
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const testLookup = (ids: number[]) =>
|
|
17
|
+
Effect.succeed(
|
|
18
|
+
new Map(
|
|
19
|
+
ids
|
|
20
|
+
.filter((id) => testData.has(id))
|
|
21
|
+
.map((id) => [id, testData.get(id)!]),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
describe("040 — Request Batching", () => {
|
|
26
|
+
it("getUser fetches a single user", async () => {
|
|
27
|
+
const resolver = makeUserResolver(testLookup);
|
|
28
|
+
const result = await Effect.runPromise(getUser(1, resolver));
|
|
29
|
+
expect(result).toBe("Alice");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("getUser fails for unknown id", async () => {
|
|
33
|
+
const resolver = makeUserResolver(testLookup);
|
|
34
|
+
const result = await Effect.runPromise(
|
|
35
|
+
Effect.either(getUser(99, resolver)),
|
|
36
|
+
);
|
|
37
|
+
expect(result._tag).toBe("Left");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("getUsers fetches multiple users", async () => {
|
|
41
|
+
const resolver = makeUserResolver(testLookup);
|
|
42
|
+
const result = await Effect.runPromise(getUsers([1, 2, 3], resolver));
|
|
43
|
+
expect(result).toEqual(["Alice", "Bob", "Charlie"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("getUsers batches requests into a single resolver call", async () => {
|
|
47
|
+
let batchCount = 0;
|
|
48
|
+
const countingLookup = (ids: number[]) => {
|
|
49
|
+
batchCount++;
|
|
50
|
+
return testLookup(ids);
|
|
51
|
+
};
|
|
52
|
+
const resolver = makeUserResolver(countingLookup);
|
|
53
|
+
await Effect.runPromise(getUsers([1, 2, 3], resolver));
|
|
54
|
+
expect(batchCount).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Effect, Request, RequestResolver } from "effect";
|
|
2
|
+
|
|
3
|
+
export interface GetUser extends Request.Request<string, string> {
|
|
4
|
+
readonly _tag: "GetUser";
|
|
5
|
+
readonly id: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const GetUser = Request.tagged<GetUser>("GetUser");
|
|
9
|
+
|
|
10
|
+
/** Create a batched resolver that receives all requests at once,
|
|
11
|
+
* calls the lookup function with all IDs, and resolves each request */
|
|
12
|
+
export const makeUserResolver = (
|
|
13
|
+
lookup: (ids: number[]) => Effect.Effect<Map<number, string>>,
|
|
14
|
+
): RequestResolver.RequestResolver<GetUser> => {
|
|
15
|
+
throw new Error("Not implemented") as any;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Make a single user request using Effect.request */
|
|
19
|
+
export const getUser = (
|
|
20
|
+
id: number,
|
|
21
|
+
resolver: RequestResolver.RequestResolver<GetUser>,
|
|
22
|
+
): Effect.Effect<string, string> => {
|
|
23
|
+
throw new Error("Not implemented");
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Make multiple user requests with batching enabled */
|
|
27
|
+
export const getUsers = (
|
|
28
|
+
ids: number[],
|
|
29
|
+
resolver: RequestResolver.RequestResolver<GetUser>,
|
|
30
|
+
): Effect.Effect<string[], string> => {
|
|
31
|
+
throw new Error("Not implemented");
|
|
32
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dojocho/effect-ts",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"publishConfig": { "access": "public" },
|
|
6
|
+
"files": ["katas", "skills", "commands", "DOJO.md", "dojo.json", "tsconfig.json", "vitest.config.ts"],
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "vitest run",
|
|
9
|
+
"test:watch": "vitest"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"effect": "^3.14.0"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"@dojocho/config": "workspace:*"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"typescript": "^5.7.0",
|
|
20
|
+
"vitest": "^3.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|