@shivaedev/service 0.7.0 → 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.
Files changed (3) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +101 -57
  3. package/package.json +7 -7
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
9
+ ## 0.7.1 - 2026-11-04
10
+
11
+ ### Changed
12
+
13
+ - Require Effect 4.0.7 and its matching companion packages. Install the 4.0.7 Effect stack together.
14
+
3
15
  ## 0.7.0 - 2026-11-04
4
16
 
5
17
  ### Breaking
package/README.md CHANGED
@@ -1,82 +1,95 @@
1
1
  # @shivaedev/service
2
2
 
3
- Declare an Effect service's dependencies once, initialize its private state, and call its methods without supplying those dependencies again. The service remains a native Context service with a native Layer; its methods remain Effects with their own result, failure and caller scope types.
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 explain what it needs and what callers can do with it. When every method binds dependencies separately, that explanation is scattered across setup and application code. Put the dependency list, private initialization and public methods in one definition:
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 { greet } from "#greetings/greet.ts";
12
- import { initializeGreetings } from "#greetings/initialize.ts";
13
- import { Prefix } from "#Prefix.ts";
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 Greetings = defineService({
16
- id: "app/Greetings",
17
- requires: [Prefix],
18
- initialize: initializeGreetings,
19
- methods: (state) => ({ greet: greet(state) }),
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
- `Prefix` is an Effect service. Provide it to `Greetings.layer` once; callers ask for `Greetings` and call `greet(name)`. The punctuation stays private, the prefix stays bound to the service's dependency, and each caller sees an ordinary Effect instead of dependency plumbing.
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, a list of required services, an initialization Effect, and a function that turns its private state into methods. `defineService` returns the native Context service callers yield and its `.layer`, which applications compose with the dependencies' Layers.
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
- A **declared dependency** is a service tag in `requires`. The Layer supplies those services to initialization and binds them to each method. The public method's type removes their requirements. If a caller provides a different value for a declared dependency around a method call, the method still uses the value supplied when the service was initialized.
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
- **Private initialization state** is the value returned by `initialize`. The method factory receives it, but a caller receives only the methods. Within one provided Layer's lifetime, initialization and the method factory each run once, and two reads of the service share the same state.
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
- **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. A method 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 method call in `Effect.scoped` interrupts the call and releases its resources without ending the service's lifetime.
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
- ### 1. Assemble the service from focused files
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
- A service file is an inventory: a top-level `defineService` call lists `id`, `requires`, `initialize` and `methods` directly. Keep each initializer and operation in its own focused file and import its named export. Use `Effect.void` when there is no initialization state. The methods arrow returns the operation record directly. Each entry names an imported operation or calls an imported state factory; `genericMethod` may wrap either form.
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
- 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. Keep setup inside the imported initializer or operation factory. Existing Quality span checks apply to the implementations: use names such as `Greetings.greet`, with an operation matching its binding. No second span convention applies to service assemblies.
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
- The initializer module exports its Effect:
62
+ ### 1. Write operations and assemble the service
49
63
 
50
- ```ts
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
- export const initializeGreetings = Effect.succeed({ punctuation: "!" });
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
- The operation module exports a state factory; its returned function is the public method:
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 function greet(state: { readonly punctuation: string }) {
63
- return Effect.fn("Greetings.greet")((name: string) => Effect.map(Prefix, (prefix) => `${prefix}${name}${state.punctuation}`));
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 those exports. `Prefix` is a separate native Context service:
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: initializeGreetings,
79
- methods: (state) => ({ greet: greet(state) }),
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 method. An initialization failure fails the Layer build with that typed error and runs finalizers registered before the failure. A method failure reaches the caller as that method's typed error, and the same service instance can handle the next call. A method defect remains a defect.
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 methods may require the listed services and `Scope.Scope`. The compiler rejects a definition that reads another service. For an ordinary method, the diagnostic names the method and its missing service types, such as `MethodHasUndeclaredServiceRequirements<"write", Reactivity>`. When a method is missing several services, the diagnostic includes their union; declared services and caller scope are excluded. Scope is supplied by the Layer or method caller, so `Scope.Scope` itself cannot appear in `requires`.
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
- Return functions from the method factory, each producing an Effect. An Effect value placed directly in the method record is rejected. Keep the private state behind those functions; it is absent from the emitted public service declaration.
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
- ### 2. Annotate generators when inference needs help
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 methods' requirements.
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
- ### 3. Give method resources a caller scope
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 method 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.
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 method interface directly:
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 method interface, rather than private initialization state. The compiler rejects a replacement that leaves a required method out. Keep the real service in tests of its behavior; use a replacement where the service represents an external boundary.
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. Use initialization state to close over bound dependencies in generic methods.
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 method's outer returned Effect. A Stream returned as its value retains its own service requirements; provide them when running the Stream.
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 methods. |
231
- | `initialize` | The Effect producing private state; its failure type becomes the Layer's failure type. |
232
- | `methods(state)` | A function returning the public method record, with declared dependencies removed from ordinary methods' requirement types. |
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
- - Keep request identity at the request boundary when it changes per call. Pass it as a method argument rather than binding it into a service intended to live across requests.
242
- - Choose service Layer lifetimes at the application's composition boundary; keep initialization resource ownership separate from a method caller's scope.
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.7.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.6",
47
- "@shivaedev/quality": "0.60.0",
48
- "@shivaedev/test": "0.15.0",
49
- "@shivaedev/test-story": "0.38.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.6",
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.6"
56
+ "effect": "4.1.0"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">=24"