@shivaedev/service 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +101 -57
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 - 2026-11-05
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- Require Effect 4.1.0 and its matching companion packages. Move `effect`, `@effect/platform-node`, `@effect/platform-node-shared`, `@effect/sql-pg`, `@effect/sql-sqlite-node`, `@effect/atom-react` and `@effect/vitest` to exactly 4.1.0 together.
|
|
8
|
+
|
|
3
9
|
## 0.7.1 - 2026-11-04
|
|
4
10
|
|
|
5
11
|
### Changed
|
package/README.md
CHANGED
|
@@ -1,82 +1,95 @@
|
|
|
1
1
|
# @shivaedev/service
|
|
2
2
|
|
|
3
|
-
Declare an Effect service
|
|
3
|
+
Declare once what an Effect service requires, and write its operations as ordinary `Effect.fn` exports that `yield*` their dependencies and the caller. Callers use the service without providing those dependencies again. The service remains a native Context service with a native Layer, and its Layer is its lifetime.
|
|
4
4
|
|
|
5
5
|
## Why you want this
|
|
6
6
|
|
|
7
|
-
A service should
|
|
7
|
+
A service should say what it needs and what callers can do with it. When operations take a repository, a client or the user's id as arguments, that knowledge spreads into every caller and every caller can get it wrong. Here each operation reads what it needs from the environment, including who asked:
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
|
+
import { Effect } from "effect";
|
|
11
|
+
import { SessionUser } from "@shivaedev/identity/identity.ts";
|
|
12
|
+
import { BookmarkRows } from "#bookmarks/BookmarkRows.ts";
|
|
13
|
+
|
|
14
|
+
export const list = Effect.fn("Bookmarks.list")(function* () {
|
|
15
|
+
const user = yield* SessionUser;
|
|
16
|
+
const rows = yield* BookmarkRows;
|
|
17
|
+
return yield* rows.ownedBy(user.id);
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The service lists its requirements once and names its operations:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { Effect } from "effect";
|
|
25
|
+
import { SessionUser } from "@shivaedev/identity/identity.ts";
|
|
10
26
|
import { defineService } from "@shivaedev/service/defineService.ts";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
27
|
+
import { add } from "#bookmarks/add.ts";
|
|
28
|
+
import { BookmarkRows } from "#bookmarks/BookmarkRows.ts";
|
|
29
|
+
import { list } from "#bookmarks/list.ts";
|
|
14
30
|
|
|
15
|
-
const
|
|
16
|
-
id: "app/
|
|
17
|
-
requires: [
|
|
18
|
-
initialize:
|
|
19
|
-
methods: (
|
|
31
|
+
export const Bookmarks = defineService({
|
|
32
|
+
id: "app/Bookmarks",
|
|
33
|
+
requires: [BookmarkRows, SessionUser],
|
|
34
|
+
initialize: Effect.void,
|
|
35
|
+
methods: () => ({ add, list }),
|
|
20
36
|
});
|
|
21
37
|
```
|
|
22
38
|
|
|
23
|
-
|
|
39
|
+
A handler yields `Bookmarks` and calls `list()`. It passes no user id and no repository, so nothing a caller passes can make it list another account's bookmarks.
|
|
24
40
|
|
|
25
41
|
## Using it
|
|
26
42
|
|
|
27
43
|
### How to think about it
|
|
28
44
|
|
|
29
|
-
A **service definition** has four parts: an identity,
|
|
45
|
+
A **service definition** has four parts: an identity, the services it requires, an initialization Effect for the state it owns, and its operations. `defineService` returns the native Context service callers yield and its `.layer`, which the application composes with the dependencies' Layers.
|
|
30
46
|
|
|
31
|
-
|
|
47
|
+
An **operation** is an `Effect.fn("Service.operation")` export in its own file, in the service's folder beside its `service.ts`. It reads every dependency with `yield*`: repositories, `SqlClient`, HTTP clients, config, other services and the caller. A dependency never arrives as a parameter, a closure capture or a bundle, and the acting user's id is never an argument. A target id, such as the account an administrator acts on, is an ordinary argument.
|
|
32
48
|
|
|
33
|
-
**
|
|
49
|
+
A **declared dependency** is a service tag in `requires`. The Layer supplies those services to initialization and binds them to each operation, and the public operation's type no longer requires them. If a caller provides a different value for a declared dependency around a call, the operation still uses the value supplied when the Layer was built.
|
|
34
50
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
The work has three parts:
|
|
38
|
-
1. Once at the application boundary, compose service Layers with their dependencies.
|
|
39
|
-
2. Once per service, define its dependency list, private state and methods.
|
|
40
|
-
3. In application code, yield the service and call its methods.
|
|
51
|
+
The **lifetime** of a service is its Layer's. Each build of the Layer runs `initialize` once, and every call on that build shares its state. Where the Layer is registered decides the lifetime: built once by the host, the service is global; built for each request, it is per request and may require the caller, such as `SessionUser` or `Subject`, which the host provides for that request; built with `Layer.fresh` or for a single use, it is fresh each time. A service that requires the caller cannot be built globally, because nothing global provides the caller.
|
|
41
52
|
|
|
42
|
-
|
|
53
|
+
**Owned state** is the value `initialize` returns: caches, refs, locks, and values captured from the context at setup, such as the caller a per-request service captures. It never holds a dependency. A caller receives only the operations. When reaching a dependency seems to need a closure or a factory, the dependency should be a service of its own; add it instead.
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
**Caller scope** is different from initialization scope. The Layer owns initialization resources: their finalizers run once when its scope closes or its owner is interrupted, including when initialization is still running. An operation that acquires a scoped resource keeps `Scope.Scope` in its public requirement type; its caller supplies the scope and releases the resource when that scope closes, even while the service stays live. Interrupting a call in `Effect.scoped` interrupts the call and releases its resources without ending the service's lifetime.
|
|
45
56
|
|
|
46
|
-
|
|
57
|
+
The work has three parts:
|
|
58
|
+
1. Once at the application boundary, register each service Layer where its lifetime belongs and compose it with its dependencies' Layers.
|
|
59
|
+
2. Once per service, write its operations and assemble its definition.
|
|
60
|
+
3. In application code, yield the service and call its operations.
|
|
47
61
|
|
|
48
|
-
|
|
62
|
+
### 1. Write operations and assemble the service
|
|
49
63
|
|
|
50
|
-
|
|
51
|
-
import { Effect } from "effect";
|
|
64
|
+
A service file is an inventory: a top-level `defineService` call lists `id`, `requires`, `initialize` and `methods` directly. Keep each operation in its own file and import its named export. Use `Effect.void` when the service owns no state; the methods arrow then takes no argument and returns the record of imported operations.
|
|
52
65
|
|
|
53
|
-
|
|
54
|
-
```
|
|
66
|
+
Quality enforces this with `effect/service-definition-assembly`, including adapters and tests. It rejects a configuration hidden behind a variable, spreads or computed configuration members, hidden method records, inline operations, local implementation references and implementation bodies beside the assembly. Quality's span checks apply to the operations: name each span after the service and the operation, such as `Greetings.greet`, with the operation matching its binding.
|
|
55
67
|
|
|
56
|
-
|
|
68
|
+
An operation yields its dependencies. `Prefix` is a separate native Context service:
|
|
57
69
|
|
|
58
70
|
```ts
|
|
59
71
|
import { Effect } from "effect";
|
|
60
72
|
import { Prefix } from "#Prefix.ts";
|
|
61
73
|
|
|
62
|
-
export
|
|
63
|
-
|
|
64
|
-
}
|
|
74
|
+
export const greet = Effect.fn("Greetings.greet")(function* (name: string) {
|
|
75
|
+
const prefix = yield* Prefix;
|
|
76
|
+
return `${prefix}${name}`;
|
|
77
|
+
});
|
|
65
78
|
```
|
|
66
79
|
|
|
67
|
-
The service module imports
|
|
80
|
+
The service module imports it:
|
|
68
81
|
|
|
69
82
|
```ts
|
|
83
|
+
import { Effect } from "effect";
|
|
70
84
|
import { defineService } from "@shivaedev/service/defineService.ts";
|
|
71
85
|
import { greet } from "#greetings/greet.ts";
|
|
72
|
-
import { initializeGreetings } from "#greetings/initialize.ts";
|
|
73
86
|
import { Prefix } from "#Prefix.ts";
|
|
74
87
|
|
|
75
|
-
const Greetings = defineService({
|
|
88
|
+
export const Greetings = defineService({
|
|
76
89
|
id: "app/Greetings",
|
|
77
90
|
requires: [Prefix],
|
|
78
|
-
initialize:
|
|
79
|
-
methods: (
|
|
91
|
+
initialize: Effect.void,
|
|
92
|
+
methods: () => ({ greet }),
|
|
80
93
|
});
|
|
81
94
|
```
|
|
82
95
|
|
|
@@ -93,15 +106,46 @@ const greeting = Effect.gen(function* () {
|
|
|
93
106
|
}).pipe(Effect.provide(live));
|
|
94
107
|
```
|
|
95
108
|
|
|
96
|
-
The Layer needs `Prefix`. After a caller resolves `Greetings`, `greet` needs no `Prefix` from that caller. Its argument and success type are inferred from the
|
|
109
|
+
The Layer needs `Prefix`. After a caller resolves `Greetings`, `greet` needs no `Prefix` from that caller. Its argument and success type are inferred from the operation. An initialization failure fails the Layer build with that typed error and runs finalizers registered before the failure. An operation failure reaches the caller as that operation's typed error, and the same service instance can handle the next call. An operation defect remains a defect.
|
|
97
110
|
|
|
98
|
-
`initialize` and ordinary
|
|
111
|
+
`initialize` and ordinary operations may require the listed services and `Scope.Scope`. The compiler rejects a definition that reads another service. For an ordinary operation, the diagnostic names the operation and its missing service types, such as `MethodHasUndeclaredServiceRequirements<"write", Reactivity>`. When an operation is missing several services, the diagnostic includes their union; declared services and caller scope are excluded. Scope is supplied by the Layer or the caller, so `Scope.Scope` itself cannot appear in `requires`.
|
|
99
112
|
|
|
100
113
|
Native `Context.Reference` keys can appear in `requires`. Their defaults make them requirement-free in Effect, so they add no Layer dependency, including when the reference's value is a scope.
|
|
101
114
|
|
|
102
|
-
|
|
115
|
+
The method record holds functions, each producing an Effect. An Effect value placed directly in the record is rejected.
|
|
116
|
+
|
|
117
|
+
### 2. Keep owned state in the initializer
|
|
118
|
+
|
|
119
|
+
A service that owns state for its lifetime returns it from `initialize`, and the operations that use it are exported as state factories: functions of the state that return the `Effect.fn` operation. Use this only for state the service owns, never to reach a dependency.
|
|
120
|
+
|
|
121
|
+
A per-request service can cache a lookup within its request. The initializer builds the cache once per Layer build; `loadTodayWater` is an operation that yields its own dependencies, such as the caller and a repository:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { Effect } from "effect";
|
|
125
|
+
import { loadTodayWater } from "#water/loadTodayWater.ts";
|
|
126
|
+
|
|
127
|
+
export const initializeWater = Effect.gen(function* () {
|
|
128
|
+
return { today: yield* Effect.cached(loadTodayWater()) };
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The operation reads the cache from the state:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { Effect } from "effect";
|
|
136
|
+
import type { initializeWater } from "#water/initialize.ts";
|
|
137
|
+
|
|
138
|
+
export function todayTotal(state: Effect.Success<typeof initializeWater>) {
|
|
139
|
+
return Effect.fn("Water.todayTotal")(function* () {
|
|
140
|
+
const entries = yield* state.today;
|
|
141
|
+
return entries.reduce((sum, entry) => sum + entry.millilitres, 0);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The assembly passes the state on: `methods: (state) => ({ todayTotal: todayTotal(state) })`. Every call within one build of the Layer shares the cached result; a request that builds its own Layer loads again.
|
|
103
147
|
|
|
104
|
-
###
|
|
148
|
+
### 3. Annotate generators when inference needs help
|
|
105
149
|
|
|
106
150
|
For an explicitly annotated `Effect.fn` generator, `ServiceRequirements` expresses the declared services without repeating their union:
|
|
107
151
|
|
|
@@ -127,9 +171,9 @@ export const initializeIdentity = Effect.fn("Identity.initializeIdentity")(funct
|
|
|
127
171
|
})();
|
|
128
172
|
```
|
|
129
173
|
|
|
130
|
-
The alias is `Effect.fn.Return<Success, Failure, declared services | CallerScope>`. The last parameter can be `Scope.Scope` when the generator acquires a scoped resource; it cannot add another undeclared service. The annotation describes the implementation's requirements. `defineService` removes declared dependencies from the public
|
|
174
|
+
The alias is `Effect.fn.Return<Success, Failure, declared services | CallerScope>`. The last parameter can be `Scope.Scope` when the generator acquires a scoped resource; it cannot add another undeclared service. The annotation describes the implementation's requirements. `defineService` removes declared dependencies from the public operations' requirements.
|
|
131
175
|
|
|
132
|
-
###
|
|
176
|
+
### 4. Give operation resources a caller scope
|
|
133
177
|
|
|
134
178
|
```ts
|
|
135
179
|
import { Effect, Ref } from "effect";
|
|
@@ -139,7 +183,7 @@ export function open(released: Ref.Ref<number>) {
|
|
|
139
183
|
}
|
|
140
184
|
```
|
|
141
185
|
|
|
142
|
-
The assembly imports `open`, a `released` state factory and an initializer that returns `Ref.make(0)`. Its methods inventory is `(state) => ({ open: open(state), released: released(state) })`. A caller can then use:
|
|
186
|
+
The counter is state the service owns. The assembly imports `open`, a `released` state factory and an initializer that returns `Ref.make(0)`. Its methods inventory is `(state) => ({ open: open(state), released: released(state) })`. A caller can then use:
|
|
143
187
|
|
|
144
188
|
```ts
|
|
145
189
|
const useResource = Effect.gen(function* () {
|
|
@@ -149,11 +193,11 @@ const useResource = Effect.gen(function* () {
|
|
|
149
193
|
}).pipe(Effect.provide(Resources.layer));
|
|
150
194
|
```
|
|
151
195
|
|
|
152
|
-
`open()` requires a caller scope. `Effect.scoped` supplies it and closes it before the next
|
|
196
|
+
`open()` requires a caller scope. `Effect.scoped` supplies it and closes it before the next call. The finalizer updates the private counter while the service is still available. An initializer that registers a finalizer instead ties that finalizer to the service Layer's scope.
|
|
153
197
|
|
|
154
198
|
### Replace a service at an external test boundary
|
|
155
199
|
|
|
156
|
-
A native Layer can provide the
|
|
200
|
+
A native Layer can provide the operation interface directly:
|
|
157
201
|
|
|
158
202
|
```ts
|
|
159
203
|
const testGreetings = Layer.succeed(Greetings, {
|
|
@@ -161,7 +205,7 @@ const testGreetings = Layer.succeed(Greetings, {
|
|
|
161
205
|
});
|
|
162
206
|
```
|
|
163
207
|
|
|
164
|
-
The replacement provides the public
|
|
208
|
+
The replacement provides the public operation interface, rather than owned state. The compiler rejects a replacement that leaves a required operation out. Keep the real service in tests of its behavior; use a replacement where the service represents an external boundary.
|
|
165
209
|
|
|
166
210
|
### Preserve a generic method's signature
|
|
167
211
|
|
|
@@ -208,9 +252,9 @@ The wrapped Effect still requires `Caller`, which the caller supplies separately
|
|
|
208
252
|
|
|
209
253
|
A generic method's remaining requirements follow native Effect rules: execution without them is a compile error. Composing the call into a Layer retains those dependencies until they are provided; an initializer cannot use the call unless its service declares those dependencies.
|
|
210
254
|
|
|
211
|
-
Marked generic methods also work with declared initializer dependencies. Their signatures keep all caller requirements unchanged; unlike ordinary methods, they do not subtract declared dependencies from the public signature. The compiler rejects an unmarked generic that correlates its input and returned Effect, and a structurally distinct two-signature overload; it may not reject other generic or overloaded signatures.
|
|
255
|
+
Marked generic methods also work with declared initializer dependencies. Their signatures keep all caller requirements unchanged; unlike ordinary methods, they do not subtract declared dependencies from the public signature. The compiler rejects an unmarked generic that correlates its input and returned Effect, and a structurally distinct two-signature overload; it may not reject other generic or overloaded signatures.
|
|
212
256
|
|
|
213
|
-
Dependency binding wraps the
|
|
257
|
+
Dependency binding wraps the operation's outer returned Effect. A Stream returned as its value retains its own service requirements; provide them when running the Stream.
|
|
214
258
|
|
|
215
259
|
### API
|
|
216
260
|
|
|
@@ -224,12 +268,12 @@ Import the module that defines the name:
|
|
|
224
268
|
|
|
225
269
|
The definition's fields are:
|
|
226
270
|
|
|
227
|
-
| Field | Meaning
|
|
228
|
-
| ---------------- |
|
|
229
|
-
| `id` | The Context service identity, also retained in the Layer's output type.
|
|
230
|
-
| `requires` | A readonly list of service tags available to initialization and ordinary
|
|
231
|
-
| `initialize` | The Effect producing
|
|
232
|
-
| `methods(state)` | A function returning the public
|
|
271
|
+
| Field | Meaning |
|
|
272
|
+
| ---------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
273
|
+
| `id` | The Context service identity, also retained in the Layer's output type. |
|
|
274
|
+
| `requires` | A readonly list of service tags available to initialization and ordinary operations. |
|
|
275
|
+
| `initialize` | The Effect producing owned state, or `Effect.void`; its failure type becomes the Layer's failure type. |
|
|
276
|
+
| `methods(state)` | A function returning the public operation record, with declared dependencies removed from ordinary operations' types. |
|
|
233
277
|
|
|
234
278
|
### Install and limits
|
|
235
279
|
|
|
@@ -238,5 +282,5 @@ pnpm add @shivaedev/service effect
|
|
|
238
282
|
```
|
|
239
283
|
|
|
240
284
|
`effect` is a peer dependency, and the package needs Node.js 24 or later.
|
|
241
|
-
-
|
|
242
|
-
-
|
|
285
|
+
- `defineService` does not choose a lifetime. The application decides it by where it registers the service's Layer; a Layer that requires the caller can only be built where the caller is provided.
|
|
286
|
+
- Initialization resources belong to the Layer's scope, never to an operation caller's scope.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/service",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Declared Effect services with scoped initialization and bound dependencies",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect"
|
|
@@ -43,17 +43,17 @@
|
|
|
43
43
|
"README.md"
|
|
44
44
|
],
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@effect/vitest": "4.0
|
|
47
|
-
"@shivaedev/quality": "0.
|
|
48
|
-
"@shivaedev/test": "0.
|
|
49
|
-
"@shivaedev/test-story": "0.
|
|
46
|
+
"@effect/vitest": "4.1.0",
|
|
47
|
+
"@shivaedev/quality": "0.65.0",
|
|
48
|
+
"@shivaedev/test": "0.17.0",
|
|
49
|
+
"@shivaedev/test-story": "0.42.0",
|
|
50
50
|
"@types/node": "24.19.2",
|
|
51
|
-
"effect": "4.0
|
|
51
|
+
"effect": "4.1.0",
|
|
52
52
|
"typescript": "7.0.2",
|
|
53
53
|
"vitest": "5.0.5"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"effect": "4.0
|
|
56
|
+
"effect": "4.1.0"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
59
|
"node": ">=24"
|