katagami 3.0.2 → 4.0.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/README.md +84 -37
- package/dist/chunk-R66WOZUM.js +71 -0
- package/dist/container/index.d.cts +142 -35
- package/dist/container/index.d.ts +142 -35
- package/dist/container/policy.d.cts +36 -0
- package/dist/container/policy.d.ts +36 -0
- package/dist/disposable/index.cjs +27 -5
- package/dist/disposable/index.d.cts +17 -7
- package/dist/disposable/index.d.ts +17 -7
- package/dist/disposable/index.js +2 -41
- package/dist/entrypoint/index.d.cts +20 -0
- package/dist/entrypoint/index.d.ts +20 -0
- package/dist/index.cjs +551 -149
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +495 -150
- package/dist/internal.d.cts +65 -0
- package/dist/internal.d.ts +65 -0
- package/dist/metadata/index.d.cts +67 -0
- package/dist/metadata/index.d.ts +67 -0
- package/dist/resolver/index.d.cts +5 -0
- package/dist/resolver/index.d.ts +5 -0
- package/dist/scope/index.d.cts +49 -24
- package/dist/scope/index.d.ts +49 -24
- package/dist/scope/operations.d.cts +22 -0
- package/dist/scope/operations.d.ts +22 -0
- package/docs/README.de.md +3 -2
- package/docs/README.es.md +3 -2
- package/docs/README.fr.md +3 -2
- package/docs/README.ja.md +61 -27
- package/docs/README.ko.md +3 -2
- package/docs/README.zh-CN.md +3 -2
- package/docs/README.zh-TW.md +3 -2
- package/docs/ai-coding-agents.md +3 -3
- package/docs/articles/ai-coding-agents.md +1 -1
- package/docs/articles/request-scope.md +2 -1
- package/docs/choosing-di.md +36 -9
- package/docs/guide.md +35 -4
- package/docs/registration-policies.ja.md +261 -0
- package/docs/registration-policies.md +435 -0
- package/docs/type-safety.md +13 -4
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/dist/chunk-J2NYR3SH.js +0 -6
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
# Registration policies and operations
|
|
2
|
+
|
|
3
|
+
Japanese: [登録の属性と公開操作](./registration-policies.ja.md)
|
|
4
|
+
|
|
5
|
+
You can attach metadata to registrations and derive resolution-time checks and public operations from
|
|
6
|
+
those same registrations. Define the shared policy once as `policy` and pass the same object reference
|
|
7
|
+
to each module. You do not need to maintain a separate list of target tokens. What the metadata means,
|
|
8
|
+
and what to allow or deny, is decided by your code.
|
|
9
|
+
|
|
10
|
+
The plain `createContainer()` and `createScope(container)` keep working as they are. The policies and
|
|
11
|
+
operations described here are features you add when you need them.
|
|
12
|
+
|
|
13
|
+
## Assemble modules with one policy
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createContainer, createMetadataKey, createScope, entrypoint } from 'katagami';
|
|
17
|
+
import type { ContainerPolicy } from 'katagami';
|
|
18
|
+
|
|
19
|
+
const EXPOSURE = createMetadataKey<'internal' | 'public'>()('exposure');
|
|
20
|
+
const policy = {
|
|
21
|
+
name: 'reports',
|
|
22
|
+
requiredMetadata: [EXPOSURE] as const,
|
|
23
|
+
beforeReturn({ registrations }) {
|
|
24
|
+
if (registrations.some(registration =>
|
|
25
|
+
registration.metadata.require(EXPOSURE) === 'internal'
|
|
26
|
+
)) {
|
|
27
|
+
throw new Error('Internal dependency instances cannot be exposed');
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
} satisfies ContainerPolicy;
|
|
31
|
+
|
|
32
|
+
function createReports(options: { policy: typeof policy }) {
|
|
33
|
+
return createContainer({ policy: options.policy })
|
|
34
|
+
.registerSingleton('repository', () => ({
|
|
35
|
+
read: (id: string) => `report:${id}`,
|
|
36
|
+
}), {
|
|
37
|
+
metadata: [EXPOSURE('internal')],
|
|
38
|
+
})
|
|
39
|
+
.registerScoped('readReport', entrypoint(resolver => {
|
|
40
|
+
const repository = resolver.resolve('repository');
|
|
41
|
+
return (id: string) => ({ id, title: repository.read(id) });
|
|
42
|
+
}), {
|
|
43
|
+
metadata: [EXPOSURE('public')],
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const container = createContainer({ policy }).use(createReports({ policy }));
|
|
48
|
+
await using operations = createScope(container, { access: 'operations' });
|
|
49
|
+
const readReport = operations.get('readReport');
|
|
50
|
+
const report = await readReport('42');
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`entrypoint(factory)` marks a factory that returns a function to expose. In the example above, only
|
|
54
|
+
`readReport` can be invoked. The resolver used to resolve `repository` stays inside the factory, and
|
|
55
|
+
callers pass ordinary arguments. The operation produces the result; `policy` does not transform it.
|
|
56
|
+
|
|
57
|
+
`ContainerPolicy` is a public type for use with `satisfies`. It types the hook arguments while keeping
|
|
58
|
+
the concrete keys of `requiredMetadata: [EXPOSURE] as const`. If you widen the type with
|
|
59
|
+
`const policy: ContainerPolicy` or `as ContainerPolicy`, individual required keys can no longer be
|
|
60
|
+
type-checked.
|
|
61
|
+
|
|
62
|
+
Pass the original `policy` reference to modules. Rebuilding the outer `{ policy }` argument still keeps
|
|
63
|
+
the policy shared, but copying the policy itself with `{ ...policy }` creates a different policy. The
|
|
64
|
+
module in this example supports policies it can satisfy by attaching `EXPOSURE`; it makes no promise to
|
|
65
|
+
support policies with arbitrary required metadata.
|
|
66
|
+
|
|
67
|
+
A runnable example in the repository is
|
|
68
|
+
[examples/registration-policies.ts](https://github.com/hiroiku/katagami/blob/master/examples/registration-policies.ts).
|
|
69
|
+
After building, run it with `bun examples/registration-policies.ts`.
|
|
70
|
+
|
|
71
|
+
## Metadata and required keys
|
|
72
|
+
|
|
73
|
+
`createMetadataKey<T>()('name')` separates specifying the value type from inferring the key name. The
|
|
74
|
+
name can be a string literal or a unique symbol. A key with a different name does not satisfy a
|
|
75
|
+
required key, even if its value type is the same. Separately created keys with the same name cannot be
|
|
76
|
+
told apart by type alone, so the runtime check uses the identity of the key object. Share key
|
|
77
|
+
definitions as well.
|
|
78
|
+
|
|
79
|
+
Setting `policy.requiredMetadata` requires each registration's `metadata` to include the required keys.
|
|
80
|
+
Missing keys are checked both by type and at runtime, at registration and on `.use()`. Older
|
|
81
|
+
registrations for the same token are still resolved by `resolveAll`, so they are not exempt from the
|
|
82
|
+
check. The runtime check is not skipped even when the type has lost the registration history. Duplicate
|
|
83
|
+
or invalid entries are also rejected, and you can register additional metadata that is not required.
|
|
84
|
+
|
|
85
|
+
`container.getMetadata(token)` returns only the metadata of the last registration and does not run the
|
|
86
|
+
factory. An unregistered token throws `ContainerError`. The reader has the following methods.
|
|
87
|
+
|
|
88
|
+
| Method | Result |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `get(key)` | The metadata value, or `undefined` if not set |
|
|
91
|
+
| `require(key)` | The metadata value; throws if not set |
|
|
92
|
+
| `has(key)` | Whether the metadata exists |
|
|
93
|
+
|
|
94
|
+
Readers and entries are read-only, but objects you pass as metadata values are not frozen internally.
|
|
95
|
+
|
|
96
|
+
### Conditions for `.use()`
|
|
97
|
+
|
|
98
|
+
| Source module | Target container | Behavior |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| No policy | No policy | Ordinary composition |
|
|
101
|
+
| No policy | Has a policy | Checks every registration for the target's required metadata, then composes |
|
|
102
|
+
| Same policy | Same policy | Checks every registration, then composes |
|
|
103
|
+
| Different policy | Has a policy | Rejected |
|
|
104
|
+
| Has a policy | No policy | Rejected |
|
|
105
|
+
|
|
106
|
+
A rejected `.use()` never applies only some of the registrations. Composition copies registration
|
|
107
|
+
definitions; it does not merge the module's cache into the target. If you create each module with
|
|
108
|
+
`createContainer({ policy })`, missing metadata is also detected when the module registers.
|
|
109
|
+
|
|
110
|
+
## Policy identity and fixed settings
|
|
111
|
+
|
|
112
|
+
Containers that use a `policy` with the same original object reference share its settings and the
|
|
113
|
+
record of instance origins. A different object with the same `name` is a different policy. `name` is
|
|
114
|
+
a label for telling policies apart in your own code; katagami does not include it in errors or events,
|
|
115
|
+
and never uses it as a key for sharing or authorization. Containers without an explicit
|
|
116
|
+
policy get no implicit shared return check.
|
|
117
|
+
|
|
118
|
+
On first use, katagami validates the settings, copies the array of required keys and fixes its internal
|
|
119
|
+
settings. Hooks are also taken as the function references at that moment. Katagami neither modifies nor
|
|
120
|
+
freezes your `policy` object or its arrays, so `Object.freeze` is unnecessary. `as const` and
|
|
121
|
+
`satisfies` do not freeze anything at runtime either.
|
|
122
|
+
|
|
123
|
+
Changing the original definition after first use does not affect existing containers. Passing the same,
|
|
124
|
+
modified object to `createContainer({ policy })` again is rejected. To change `name`, the order or
|
|
125
|
+
references of the required keys, or the hook references, prepare a new policy object.
|
|
126
|
+
|
|
127
|
+
Use data properties for settings. Accessors are rejected without running their getters. Detecting that
|
|
128
|
+
an object is a Proxy is not guaranteed.
|
|
129
|
+
|
|
130
|
+
Katagami does not fix the state that hook closures refer to, or the internals of metadata values. Keep
|
|
131
|
+
per-request authorization state out of the shared policy and pass it to the scope's `beforeResolve`
|
|
132
|
+
instead. Even with the same policy, separate roots do not share singleton caches, request state or
|
|
133
|
+
resource ownership.
|
|
134
|
+
|
|
135
|
+
## Instance origins and return checks
|
|
136
|
+
|
|
137
|
+
### Recording resolved references
|
|
138
|
+
|
|
139
|
+
For each object or function actually resolved within one policy, katagami records the reference and the
|
|
140
|
+
details of its registration at that time (token, lifetime, whether it is an `entrypoint`, and metadata).
|
|
141
|
+
Primitives such as strings, numbers and symbols are not tracked, even when the values are equal.
|
|
142
|
+
|
|
143
|
+
- Resolving the same instance under another token adds an origin. Existing origins are not removed.
|
|
144
|
+
- Overriding a token keeps the old origins of instances already created.
|
|
145
|
+
- Origins whose registration details are all identical are merged into one, even across different
|
|
146
|
+
registrations or containers. Even if you rebuild the container for every request, the record for one
|
|
147
|
+
instance grows no larger than the number of origins `beforeReturn` can tell apart. Metadata is
|
|
148
|
+
compared regardless of order, by key reference and value identity (`Object.is`). If you use values
|
|
149
|
+
that change per request, such as a request ID, or objects created anew for each request, as metadata
|
|
150
|
+
values or tokens, each request counts as a separate origin and the record grows by that many.
|
|
151
|
+
- A new instance does not inherit the origins of an old one.
|
|
152
|
+
- For an `async` factory or a factory that returns a native `Promise`, katagami builds a chain that awaits
|
|
153
|
+
completion and records the fulfilled value inside it. Callers receive the end of this chain, not the
|
|
154
|
+
promise the factory returned. Custom properties attached to that promise are not carried over, so if
|
|
155
|
+
you want to hand out an object as the value, make it a non-Promise instance or a custom thenable. The
|
|
156
|
+
cache also holds this chain end, and resolving again in the same scope returns the same promise. A
|
|
157
|
+
top-level resolution under a policy with a return check returns a promise per call, as described below.
|
|
158
|
+
- Records are shared across caches, every lifetime, parent/child and sibling scopes, and separate roots
|
|
159
|
+
that use the same policy. Records of different policies never mix.
|
|
160
|
+
|
|
161
|
+
A custom thenable (a user-defined object with `then`) returned by a synchronous factory is recorded by
|
|
162
|
+
reference as an ordinary dependency instance. Katagami keeps the original instance, cache and custom
|
|
163
|
+
methods, does not touch `then` or its getter, and does not subscribe to its completion. It does not
|
|
164
|
+
automatically record an origin for its completion value. If you want the completion value treated as a
|
|
165
|
+
registered dependency, make that explicit with an ordinary async factory, such as
|
|
166
|
+
`async () => await thenable`.
|
|
167
|
+
|
|
168
|
+
To create an alias, `resolve` the original token inside an ordinary factory. The original origin is then
|
|
169
|
+
observed as well. Katagami does not guess origins for factories that have not run, or for references
|
|
170
|
+
brought in without going through a registration. Nor does it replace old origins with the current
|
|
171
|
+
`getMetadata(token)`.
|
|
172
|
+
|
|
173
|
+
Records are held through weak references so they do not keep instances alive unnecessarily, and they are
|
|
174
|
+
not cleared when a scope ends. A record holds only the origin details (token, lifetime, whether it is an
|
|
175
|
+
`entrypoint`, and metadata keys and values). It holds no registrations, factories or their closures,
|
|
176
|
+
caches, scopes or containers, so even when an instance or the policy outlives the container, none of
|
|
177
|
+
these outlive the container. However, if a token or a metadata value itself references the container,
|
|
178
|
+
the container stays alive through the record. Settings and records are also shared between the ESM and
|
|
179
|
+
CJS builds of the same package when they receive the same policy reference. This is not guaranteed
|
|
180
|
+
across different versions or realms.
|
|
181
|
+
|
|
182
|
+
### Promises you receive are left untouched
|
|
183
|
+
|
|
184
|
+
For its bookkeeping and disposal, katagami never attaches `then` / `catch` / `finally` to a promise it has
|
|
185
|
+
handed to a caller. It does its bookkeeping inside a chain of its own and hands out the end of that chain. The creation results that
|
|
186
|
+
disposal uses to know what to close, and the completion notices that closing uses to wait for running
|
|
187
|
+
operations, are also kept by katagami itself, separately from the promises it hands out.
|
|
188
|
+
|
|
189
|
+
As a result, if you drop a promise you received and it fails, the failure surfaces as an unhandled
|
|
190
|
+
rejection, as the runtime does by default. Even when scope disposal or the closing of an operations
|
|
191
|
+
scope overlaps, katagami never takes over that failure before you do. Always receive the return value of
|
|
192
|
+
`resolve` and the promise an operation returns, and either `await` it or handle it with `catch`.
|
|
193
|
+
|
|
194
|
+
A promise that a singleton or scoped creation put in the cache is shared with later resolutions,
|
|
195
|
+
though. A top-level resolution under a policy with a return check, and a call through an operations
|
|
196
|
+
scope, build their own promise from the shared one and wait on it. From then on, the failure of the
|
|
197
|
+
shared promise is handled and reaches that later caller, even if an earlier caller dropped the same
|
|
198
|
+
promise, just as when another caller `await`s the same cached promise.
|
|
199
|
+
|
|
200
|
+
Failures of creations that could not be handed to a caller are not hidden either. If another
|
|
201
|
+
registration fails synchronously partway through `resolveAll`, failures of asynchronous creations that
|
|
202
|
+
had already started surface as unhandled rejections. Singleton and scoped creations remain in the cache,
|
|
203
|
+
so resolving the same token again gives you the same failure. Transient registrations run the factory
|
|
204
|
+
again on every resolution, so the original failure cannot be recovered.
|
|
205
|
+
|
|
206
|
+
### What `beforeReturn` covers
|
|
207
|
+
|
|
208
|
+
`beforeReturn({ registrations })` is a synchronous check that keeps values the policy forbids from
|
|
209
|
+
leaving through any surface. It covers the following two surfaces, and both look only at observed
|
|
210
|
+
origins.
|
|
211
|
+
|
|
212
|
+
| Surface | Values checked |
|
|
213
|
+
| --- | --- |
|
|
214
|
+
| Ordinary resolver scope | Values returned by top-level resolution with `resolve`, `resolveAll`, `tryResolve` and `tryResolveAll` |
|
|
215
|
+
| Operations scope | Fulfillment values returned directly by public operations |
|
|
216
|
+
|
|
217
|
+
Resolutions requested by a factory (those that carry a `requester` in `beforeResolve`) are inside the
|
|
218
|
+
surface, so they are not checked. Factories are free to receive the dependencies they need for assembly,
|
|
219
|
+
and the policy decides only what goes out. The path by which an operations scope resolves the invoking
|
|
220
|
+
function of a public operation is also inside the surface. To `beforeResolve`, this internal resolution
|
|
221
|
+
looks the same as a top-level one, with no `requester`, so you cannot tell inside from outside the
|
|
222
|
+
surface by `beforeResolve`'s `requester` alone. An optional resolution of an unregistered token produces
|
|
223
|
+
no value, so it returns `undefined` without a check. `lazy` calls `resolve` on the first property
|
|
224
|
+
access, so the check happens at that point.
|
|
225
|
+
|
|
226
|
+
Origins are tracked only for objects and functions. A primitive result is checked with empty
|
|
227
|
+
`registrations`, so if a registration tagged with `area` returns a string or number, a decision based
|
|
228
|
+
on origins does not stop it. If you pass a factory's resolver itself out through an operation result or a
|
|
229
|
+
closure, resolutions through that resolver are treated as inside the surface and are not checked.
|
|
230
|
+
Register things in a way that keeps the resolver from leaving. A factory that synchronously calls the
|
|
231
|
+
public methods of the scope building it resolves inside its own construction as well: lifetime and cycle
|
|
232
|
+
checks apply, and the return check does not.
|
|
233
|
+
|
|
234
|
+
A synchronous factory's value is checked on the spot; for an asynchronous factory, the resolved value is
|
|
235
|
+
checked before it is handed to the caller. With a policy that has a return check, each top-level
|
|
236
|
+
asynchronous resolution is checked per call, so every call returns its own promise for the same
|
|
237
|
+
creation. For an operation that returns a promise or thenable, the result is `await`ed and only the
|
|
238
|
+
known origins of the fulfillment value are checked. The origin of a custom thenable itself is not
|
|
239
|
+
assumed to carry over to its completion value. `registrations` is a read-only snapshot of the origins
|
|
240
|
+
observed for the result reference. The operation's own registration is never passed in place of the
|
|
241
|
+
result's origins. For primitives and results of unknown origin, it is an empty array.
|
|
242
|
+
|
|
243
|
+
To allow, return nothing; to deny, throw. Async hooks cannot be used, and the hook cannot transform the
|
|
244
|
+
return value. The value is not handed to the caller until the check completes. On denial, a synchronous
|
|
245
|
+
resolution throws, and an asynchronous resolution or an operation rejects its promise. The caller
|
|
246
|
+
receives the error the hook threw, with its `cause` intact.
|
|
247
|
+
|
|
248
|
+
In the opening example, the return is denied if any observed origin is `internal`. Katagami does not
|
|
249
|
+
define what that classification means or which value takes precedence.
|
|
250
|
+
|
|
251
|
+
The check is not recursive. It does not inspect nested values such as `{ repository }`, the inside of a
|
|
252
|
+
`Result`, exceptions or their `cause`, closures, or values a stream emits later. The reason an operation
|
|
253
|
+
rejects with is also out of scope. It is neither a mechanism for detecting arbitrary information leaks
|
|
254
|
+
nor a JavaScript security sandbox.
|
|
255
|
+
|
|
256
|
+
## Getting and invoking operations
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import { createContainer, createScope, entrypoint } from 'katagami';
|
|
260
|
+
|
|
261
|
+
const container = createContainer()
|
|
262
|
+
.registerScoped('repository', () => ({ read: (id: string) => `report:${id}` }))
|
|
263
|
+
.registerScoped('readReport', entrypoint(resolver => {
|
|
264
|
+
const repository = resolver.resolve('repository');
|
|
265
|
+
return (id: string, prefix = '', ...labels: string[]) =>
|
|
266
|
+
`${prefix}${repository.read(id)}${labels.join(',')}`;
|
|
267
|
+
}));
|
|
268
|
+
|
|
269
|
+
await using operations = createScope(container, { access: 'operations' });
|
|
270
|
+
const readReport = operations.get('readReport');
|
|
271
|
+
const report: string = await readReport('42', 'Report: ', 'draft');
|
|
272
|
+
// @ts-expect-error — a non-public registration cannot be retrieved as an operation; wrap its factory in entrypoint() to expose it
|
|
273
|
+
operations.get('repository');
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`createScope(container, { access: 'operations' })` always creates a new scope, and its public surface is
|
|
277
|
+
only `get` and `Symbol.asyncDispose`. Passing an ordinary resolver scope or a disposable scope as the
|
|
278
|
+
source also creates a new child scope. It is not an API that turns the source into a view with the same
|
|
279
|
+
scoped cache and lifetime.
|
|
280
|
+
|
|
281
|
+
`get(name)` synchronously checks that the token is public and returns an invoking function bound to the
|
|
282
|
+
scope. **Getting the function does not create any dependency.** It returns neither the raw registered
|
|
283
|
+
function, nor a resolver, nor the registration map. On invocation, it checks whether the scope is
|
|
284
|
+
closing, re-checks the public registration, resolves normally, runs the operation and applies the
|
|
285
|
+
return check, in that order.
|
|
286
|
+
|
|
287
|
+
If `.use()` or an additional registration makes the last registration non-public, calls through
|
|
288
|
+
previously obtained functions are rejected too. The authorization result at retrieval time is not
|
|
289
|
+
remembered to skip the resolution checks at call time. `beforeResolve` runs even when the value comes
|
|
290
|
+
from the cache.
|
|
291
|
+
|
|
292
|
+
`entrypoint` supports singleton, transient and scoped registrations, and async factories. It does not
|
|
293
|
+
automatically expose every method of a class. For a method that needs `this`, `bind` it to the owning
|
|
294
|
+
instance inside the factory. Make operations that use request state scoped.
|
|
295
|
+
|
|
296
|
+
Invocations always return a promise, even for synchronous operations. Fixed, optional and rest
|
|
297
|
+
parameters and the result type are inferred from the registration. For overloads, the last signature is
|
|
298
|
+
used; not every signature is kept. Generic functions also lose the correspondence between input and
|
|
299
|
+
output types. Expose such functions wrapped in a function with concrete parameter and result types.
|
|
300
|
+
|
|
301
|
+
You can pass just the operation functions you need to other code, but they cannot be used beyond the
|
|
302
|
+
lifetime of the scope they were obtained from. The ordinary resolver `createScope(container)` and its
|
|
303
|
+
`resolve` and other methods remain as they are. The `entrypoint`-only exposure restriction belongs to the
|
|
304
|
+
operations scope surface alone and does not apply to paths that pass an ordinary resolver. `beforeReturn`
|
|
305
|
+
applies to the "outside" of each surface; for an ordinary resolver scope, that is top-level resolution.
|
|
306
|
+
|
|
307
|
+
## Closing and long-running work
|
|
308
|
+
|
|
309
|
+
With `await using operations = createScope(container, { access: 'operations' })`, leaving the block
|
|
310
|
+
waits for the scope to close.
|
|
311
|
+
|
|
312
|
+
Once closing starts, new invocations are rejected, including those through functions already obtained.
|
|
313
|
+
Invocations that started before closing are awaited to completion, including internal resolution after
|
|
314
|
+
`await` and `beforeReturn`; whether they succeed or fail, the scope is disposed only after the result has
|
|
315
|
+
reached the invocation's promise. Duplicate close requests join the same closing process. Singletons
|
|
316
|
+
remain owned by the original container and are not disposed when an operations scope closes.
|
|
317
|
+
|
|
318
|
+
Disposal is responsible only for closing instances that were successfully created. A registration whose
|
|
319
|
+
async factory rejected yields nothing to close, so closing awaits it, skips it, continues disposing the
|
|
320
|
+
remaining instances, and does not include it in the `AggregateError`. The creation failure is received by
|
|
321
|
+
the caller that resolved the token (the return value of `resolve`, or the promise an operation returns).
|
|
322
|
+
This way, business or authorization failures and resources that genuinely could not be disposed are each
|
|
323
|
+
reported once, through their own promise. A failure you drop without receiving surfaces as is, as an
|
|
324
|
+
unhandled rejection in the runtime.
|
|
325
|
+
|
|
326
|
+
If an operation's promise returns a stream or async iterator, the invocation completes when that value
|
|
327
|
+
is returned. Consumption, completion and cancellation of the stream are not tracked automatically. If
|
|
328
|
+
consumption or cleanup needs scoped resources, keep the `await using` block open until it completes.
|
|
329
|
+
Also complete notifications, job records, lease releases and the like within the lifetime they need.
|
|
330
|
+
|
|
331
|
+
When authorization state changes, do not rewrite the state of a running scope; switch to a new
|
|
332
|
+
operations scope with the new fixed state. Start closing the old scope to stop new invocations, and wait
|
|
333
|
+
for the work already started and for closing to finish. Functions already obtained belong to the old
|
|
334
|
+
scope and are not automatically rebound to the new one.
|
|
335
|
+
|
|
336
|
+
## What resolution hooks cover
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { createContainer, createMetadataKey, createScope, entrypoint } from 'katagami';
|
|
340
|
+
import type { ContainerPolicy, OperationsScopeOptions } from 'katagami';
|
|
341
|
+
|
|
342
|
+
const AREA = createMetadataKey<'reports' | 'settings'>()('area');
|
|
343
|
+
const policy = {
|
|
344
|
+
name: 'application',
|
|
345
|
+
requiredMetadata: [AREA] as const,
|
|
346
|
+
} satisfies ContainerPolicy;
|
|
347
|
+
const container = createContainer({ policy })
|
|
348
|
+
.registerScoped('countReports', entrypoint(() => () => 3), {
|
|
349
|
+
metadata: [AREA('reports')],
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const allowedAreas = new Set(['reports']);
|
|
353
|
+
const options = {
|
|
354
|
+
access: 'operations',
|
|
355
|
+
beforeResolve(event) {
|
|
356
|
+
if (!allowedAreas.has(event.metadata.require(AREA))) {
|
|
357
|
+
throw new Error('This dependency is not available');
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
} satisfies OperationsScopeOptions;
|
|
361
|
+
await using operations = createScope(container, options);
|
|
362
|
+
const count = await operations.get('countReports')();
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
When you store the options in a variable, `satisfies OperationsScopeOptions` keeps the type of `access`
|
|
366
|
+
as `'operations'` and types the hook arguments too. An object whose `access` has widened to `string`, or
|
|
367
|
+
a misspelled `access`, cannot be passed to `createScope`. Even if you pass it with the types stripped,
|
|
368
|
+
any `access` other than omitted or `'operations'` is rejected at runtime with `ContainerError`; it is
|
|
369
|
+
never created as an ordinary scope.
|
|
370
|
+
|
|
371
|
+
In this example, `allowedAreas` holds authorization that your code settled in advance, and it does not
|
|
372
|
+
change during the scope's lifetime. Katagami does not make business authorization decisions.
|
|
373
|
+
|
|
374
|
+
`beforeResolve` is a synchronous function. To deny, throw; to allow, return nothing. Async hooks are
|
|
375
|
+
rejected. It is also available on ordinary resolver scopes.
|
|
376
|
+
|
|
377
|
+
It covers registered dependencies for `resolve`, `resolveAll`, `tryResolve` and `tryResolveAll`, and it
|
|
378
|
+
also runs before a cached value is returned. Optional resolution of an unknown token returns `undefined`,
|
|
379
|
+
as before. Child scopes inherit the parent's policy, and additional hooks cannot remove the parent's
|
|
380
|
+
checks.
|
|
381
|
+
|
|
382
|
+
The event has `token`, `lifetime`, `metadata`, `entrypoint`, `requester` and `path`. A factory's resolver
|
|
383
|
+
keeps its owning scope and requester, and they stay the same after `await`. Dependencies held by cached
|
|
384
|
+
instances, and method calls on an already resolved `lazy`, cause no new resolution, so the hook does not
|
|
385
|
+
re-check the past dependency graph every time.
|
|
386
|
+
|
|
387
|
+
### Singletons and policy ownership
|
|
388
|
+
|
|
389
|
+
When a singleton factory keeps its resolver in a closure, that resolver stays bound to the scope that
|
|
390
|
+
created the singleton. Even if you obtain and call the singleton from another scope, subsequent `resolve`
|
|
391
|
+
calls use the creating scope's hooks. Once the creating scope is closed, that resolver cannot be used. It
|
|
392
|
+
is not rebound to the calling scope.
|
|
393
|
+
|
|
394
|
+
Even when a resolver kept by a singleton factory obtains a scoped dependency later, in a callback or after
|
|
395
|
+
`await`, it is rejected as a captive dependency. Make request-dependent callbacks scoped, or inject
|
|
396
|
+
dependencies with an appropriate lifetime at creation time. Make only request-independent shared
|
|
397
|
+
infrastructure singleton.
|
|
398
|
+
|
|
399
|
+
## Preserve type information
|
|
400
|
+
|
|
401
|
+
Let the registration chain and the return value of `.use()` be inferred.
|
|
402
|
+
`RegisteredTokens<typeof container>` gets the types of the tokens the registration chain tracked as
|
|
403
|
+
registered. A container created from a predeclared type map is tracked only from its first registration
|
|
404
|
+
with metadata or an `entrypoint`; before that, and without such a registration, the result is `never`. A predeclared
|
|
405
|
+
service type is not proof that a token is registered.
|
|
406
|
+
|
|
407
|
+
Assigning to an existing `Container<T>` annotation keeps the ordinary resolver types but erases the
|
|
408
|
+
registration history. Erased history is never used as grounds for required metadata or public
|
|
409
|
+
operations. Public operations added explicitly afterwards are inferred. If you need the required
|
|
410
|
+
metadata guarantee of `.use()`, keep the inference from `createContainer({ policy })`.
|
|
411
|
+
|
|
412
|
+
When you combine a predeclared service type with a policy, specify the type arguments as follows,
|
|
413
|
+
because of TypeScript's limits on partial type argument inference. You do not need to list the required
|
|
414
|
+
keys again in the type arguments.
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { createContainer, createMetadataKey } from 'katagami';
|
|
418
|
+
import type { ContainerPolicy } from 'katagami';
|
|
419
|
+
|
|
420
|
+
const AREA = createMetadataKey<string>()('area');
|
|
421
|
+
const policy = {
|
|
422
|
+
name: 'clock',
|
|
423
|
+
requiredMetadata: [AREA] as const,
|
|
424
|
+
} satisfies ContainerPolicy;
|
|
425
|
+
interface Dependencies { clock: () => number }
|
|
426
|
+
const container = createContainer<Dependencies, Record<never, never>, typeof policy>({ policy })
|
|
427
|
+
.registerSingleton('clock', () => Date.now, { metadata: [AREA('core')] })
|
|
428
|
+
.registerScoped('now', resolver => resolver.resolve('clock')(), {
|
|
429
|
+
metadata: [AREA('core')],
|
|
430
|
+
});
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Types cannot fully prove arbitrary changes through mutable aliases, arrays declared with broad value
|
|
434
|
+
types, or type assertions. Use the runtime checks as well, and create scopes after you finish assembling
|
|
435
|
+
the registrations.
|
package/docs/type-safety.md
CHANGED
|
@@ -113,10 +113,19 @@ Use a predeclared map when order independence is useful and verify the compositi
|
|
|
113
113
|
## Runtime checks and limits
|
|
114
114
|
|
|
115
115
|
Runtime checks report missing registrations, disposed scopes and circular resolution paths.
|
|
116
|
-
The captive-dependency guard detects scoped resolution
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
The captive-dependency guard detects scoped resolution from a singleton factory, including indirect calls.
|
|
117
|
+
The resolver passed to a factory keeps its requester and the singleton restriction, so resolutions made
|
|
118
|
+
after an `await`, or later through a stored resolver, are checked too. A factory that calls the scope
|
|
119
|
+
building it directly, instead of its resolver, is checked only while it runs synchronously; after an
|
|
120
|
+
`await`, such a call counts as a call from outside. Dependencies captured from another scope through a
|
|
121
|
+
closure are not covered. While an async creation is pending, the factories building it cannot resolve the
|
|
122
|
+
same token again, even without waiting for it. A creation that waits for its own Promise through a
|
|
123
|
+
dependency that has already finished building is not detected and never settles. Synchronous cycles are
|
|
124
|
+
reported whichever resolver or scope they pass through. Use the typed factory API and runtime tests
|
|
125
|
+
together.
|
|
126
|
+
|
|
127
|
+
See [registration policies and operations](./registration-policies.md) for the additional guarantees of
|
|
128
|
+
required metadata and public operations, and for their limits when types are erased.
|
|
120
129
|
|
|
121
130
|
## Verification
|
|
122
131
|
|
package/llms.txt
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
- [AI coding agents](https://github.com/hiroiku/katagami/blob/master/docs/ai-coding-agents.md): workflow, diagnostics and project instructions.
|
|
9
9
|
- [Type safety](https://github.com/hiroiku/katagami/blob/master/docs/type-safety.md): accumulated tokens, structural classes, declared maps and limits.
|
|
10
10
|
- [API guide](https://github.com/hiroiku/katagami/blob/master/docs/guide.md): lifetimes, factories, modules, disposal and lazy resolution.
|
|
11
|
+
- [Registration policies](https://github.com/hiroiku/katagami/blob/master/docs/registration-policies.md): a shared policy object, required metadata, beforeReturn and operations scopes, with their contracts and limits.
|
|
11
12
|
- [Request-scope starter](https://github.com/hiroiku/katagami/tree/master/examples/request-scope): a runnable example with fake injection and cleanup.
|
|
12
13
|
- [Choosing DI](https://github.com/hiroiku/katagami/blob/master/docs/choosing-di.md): use cases and trade-offs.
|
|
13
14
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "katagami",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Type-safe dependency injection for TypeScript, with inferred types and scope checks for AI-assisted development. No decorators or reflect-metadata.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|