@smthrs/plugin 0.0.0-stage → 1.0.0-rc.3
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 +79 -0
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/dist/cjs/Config.d.ts +129 -0
- package/dist/cjs/Config.d.ts.map +1 -0
- package/dist/cjs/Config.js +115 -0
- package/dist/cjs/Config.js.map +7 -0
- package/dist/cjs/Hooks.d.ts +176 -0
- package/dist/cjs/Hooks.d.ts.map +1 -0
- package/dist/cjs/Hooks.js +34 -0
- package/dist/cjs/Hooks.js.map +7 -0
- package/dist/cjs/Kernel.d.ts +81 -0
- package/dist/cjs/Kernel.d.ts.map +1 -0
- package/dist/cjs/Kernel.js +57 -0
- package/dist/cjs/Kernel.js.map +7 -0
- package/dist/cjs/Plugin.d.ts +71 -0
- package/dist/cjs/Plugin.d.ts.map +1 -0
- package/dist/cjs/Plugin.js +25 -0
- package/dist/cjs/Plugin.js.map +7 -0
- package/dist/cjs/PluginError.d.ts +50 -0
- package/dist/cjs/PluginError.d.ts.map +1 -0
- package/dist/cjs/PluginError.js +58 -0
- package/dist/cjs/PluginError.js.map +7 -0
- package/dist/cjs/Plugins.d.ts +97 -0
- package/dist/cjs/Plugins.d.ts.map +1 -0
- package/dist/cjs/Plugins.js +160 -0
- package/dist/cjs/Plugins.js.map +7 -0
- package/dist/cjs/Resolve.d.ts +128 -0
- package/dist/cjs/Resolve.d.ts.map +1 -0
- package/dist/cjs/Resolve.js +343 -0
- package/dist/cjs/Resolve.js.map +7 -0
- package/dist/cjs/index.d.ts +83 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +45 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/Boundary.d.ts +96 -0
- package/dist/cjs/internal/Boundary.d.ts.map +1 -0
- package/dist/cjs/internal/Boundary.js +129 -0
- package/dist/cjs/internal/Boundary.js.map +7 -0
- package/dist/cjs/internal/mergePluginLayers.d.ts +21 -0
- package/dist/cjs/internal/mergePluginLayers.d.ts.map +1 -0
- package/dist/cjs/internal/mergePluginLayers.js +57 -0
- package/dist/cjs/internal/mergePluginLayers.js.map +7 -0
- package/dist/cjs/internal/snapshotCacheEnvironment.d.ts +19 -0
- package/dist/cjs/internal/snapshotCacheEnvironment.d.ts.map +1 -0
- package/dist/cjs/internal/snapshotCacheEnvironment.js +92 -0
- package/dist/cjs/internal/snapshotCacheEnvironment.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/Config.d.ts +129 -0
- package/dist/esm/Config.d.ts.map +1 -0
- package/dist/esm/Config.js +160 -0
- package/dist/esm/Config.js.map +1 -0
- package/dist/esm/Hooks.d.ts +176 -0
- package/dist/esm/Hooks.d.ts.map +1 -0
- package/dist/esm/Hooks.js +56 -0
- package/dist/esm/Hooks.js.map +1 -0
- package/dist/esm/Kernel.d.ts +81 -0
- package/dist/esm/Kernel.d.ts.map +1 -0
- package/dist/esm/Kernel.js +58 -0
- package/dist/esm/Kernel.js.map +1 -0
- package/dist/esm/Plugin.d.ts +71 -0
- package/dist/esm/Plugin.d.ts.map +1 -0
- package/dist/esm/Plugin.js +20 -0
- package/dist/esm/Plugin.js.map +1 -0
- package/dist/esm/PluginError.d.ts +50 -0
- package/dist/esm/PluginError.d.ts.map +1 -0
- package/dist/esm/PluginError.js +53 -0
- package/dist/esm/PluginError.js.map +1 -0
- package/dist/esm/Plugins.d.ts +97 -0
- package/dist/esm/Plugins.d.ts.map +1 -0
- package/dist/esm/Plugins.js +178 -0
- package/dist/esm/Plugins.js.map +1 -0
- package/dist/esm/Resolve.d.ts +128 -0
- package/dist/esm/Resolve.d.ts.map +1 -0
- package/dist/esm/Resolve.js +367 -0
- package/dist/esm/Resolve.js.map +1 -0
- package/dist/esm/index.d.ts +83 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +48 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/Boundary.d.ts +96 -0
- package/dist/esm/internal/Boundary.d.ts.map +1 -0
- package/dist/esm/internal/Boundary.js +162 -0
- package/dist/esm/internal/Boundary.js.map +1 -0
- package/dist/esm/internal/mergePluginLayers.d.ts +21 -0
- package/dist/esm/internal/mergePluginLayers.d.ts.map +1 -0
- package/dist/esm/internal/mergePluginLayers.js +41 -0
- package/dist/esm/internal/mergePluginLayers.js.map +1 -0
- package/dist/esm/internal/snapshotCacheEnvironment.d.ts +19 -0
- package/dist/esm/internal/snapshotCacheEnvironment.d.ts.map +1 -0
- package/dist/esm/internal/snapshotCacheEnvironment.js +63 -0
- package/dist/esm/internal/snapshotCacheEnvironment.js.map +1 -0
- package/docs/README.md +131 -0
- package/docs/api.md +522 -0
- package/docs/concepts/configuration.md +111 -0
- package/docs/concepts/hook-kinds.md +107 -0
- package/docs/concepts/resolution.md +132 -0
- package/docs/guides/cache-identity.md +106 -0
- package/docs/guides/contribute-services.md +84 -0
- package/docs/guides/host-your-own-hooks.md +129 -0
- package/docs/guides/order-handlers.md +76 -0
- package/docs/guides/select-plugins.md +72 -0
- package/docs/guides/testing.md +149 -0
- package/docs/installation.md +71 -0
- package/docs/quickstart.md +151 -0
- package/docs/troubleshooting.md +179 -0
- package/package.json +179 -3
- package/src/Config.ts +207 -0
- package/src/Hooks.ts +212 -0
- package/src/Kernel.ts +119 -0
- package/src/Plugin.ts +80 -0
- package/src/PluginError.ts +62 -0
- package/src/Plugins.ts +254 -0
- package/src/Resolve.ts +535 -0
- package/src/index.ts +91 -0
- package/src/internal/Boundary.ts +252 -0
- package/src/internal/mergePluginLayers.ts +57 -0
- package/src/internal/snapshotCacheEnvironment.ts +77 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Include a plugin conditionally"
|
|
3
|
+
description: "Use apply to select a plugin by host target or by a predicate over the pre-resolution configuration, and know what selection does not excuse."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 3
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A plugin can decide it does not belong in this composition. Declare `apply`,
|
|
9
|
+
and the kernel drops the plugin before it contributes a handler, a layer, or a
|
|
10
|
+
cache identity.
|
|
11
|
+
|
|
12
|
+
## Select by host target
|
|
13
|
+
|
|
14
|
+
`apply: "engine"` and `apply: "harness"` name the host the plugin is for. The
|
|
15
|
+
kernel compares the literal with the `target` option, which defaults to
|
|
16
|
+
`"engine"`:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { Kernel, make } from "@smthrs/plugin"
|
|
20
|
+
|
|
21
|
+
const list = [
|
|
22
|
+
make({ name: "flows-plugin-shared" }),
|
|
23
|
+
make({ name: "flows-plugin-harness-only", apply: "harness" })
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
// Engine kernel: only flows-plugin-shared is selected.
|
|
27
|
+
const engine = Kernel.make(list)
|
|
28
|
+
|
|
29
|
+
// Harness kernel: both are selected.
|
|
30
|
+
const harness = Kernel.make(list, {}, { target: "harness" })
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A plugin with no `apply` is always selected. This is how one shared preset
|
|
34
|
+
serves two hosts: list every plugin once, and let each host take its half.
|
|
35
|
+
|
|
36
|
+
## Select by a predicate over the configuration
|
|
37
|
+
|
|
38
|
+
A predicate receives the pre-resolution configuration, the same value you passed
|
|
39
|
+
to `Kernel.make`, and returns a boolean:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const fast = make({
|
|
43
|
+
name: "flows-plugin-fast",
|
|
44
|
+
apply: (config) => config["mode"] === "fast"
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const kernel = Kernel.make([fast], { mode: "fast" })
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The configuration a predicate sees is a snapshot taken before the first
|
|
51
|
+
predicate runs, so a predicate cannot observe another predicate's side effects,
|
|
52
|
+
and a caller cannot mutate the object between two predicate calls.
|
|
53
|
+
|
|
54
|
+
It is the _pre-resolution_ configuration: the `config` waterfall has not run
|
|
55
|
+
yet, so a predicate cannot see a namespace another plugin is about to
|
|
56
|
+
contribute. Selection has to settle before the waterfall, because the waterfall
|
|
57
|
+
runs the plugins selection chose.
|
|
58
|
+
|
|
59
|
+
A predicate that throws fails resolution with `apply_failed`, and its raw
|
|
60
|
+
failure is not retained. A predicate that returns anything other than a boolean
|
|
61
|
+
fails with `invalid_plugin`. Keep predicates pure and total.
|
|
62
|
+
|
|
63
|
+
## What selection does not excuse
|
|
64
|
+
|
|
65
|
+
Every plugin record is validated before `apply` runs, so a malformed plugin
|
|
66
|
+
fails startup even when the predicate would have excluded it. Selection cannot
|
|
67
|
+
hide a typo.
|
|
68
|
+
|
|
69
|
+
Hook names are checked after selection, and that asymmetry is deliberate: a
|
|
70
|
+
shared preset may carry `apply: "harness"` plugins whose hooks only the harness
|
|
71
|
+
catalog declares, and it still resolves under a bare engine kernel because those
|
|
72
|
+
plugins were never selected.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Test a plugin"
|
|
3
|
+
description: "Exercise a plugin without booting a host: resolve a list, dispatch a hook, assert on ordering and typed failures, and stub the kernel out of a composition under test."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 6
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A plugin is a plain record and the dispatcher is a plain service, so a plugin
|
|
9
|
+
test needs no host, no engine, and no I/O. Resolve the list, dispatch the hook,
|
|
10
|
+
assert on the result.
|
|
11
|
+
|
|
12
|
+
## Dispatch a hook without a kernel
|
|
13
|
+
|
|
14
|
+
`Resolve.resolve` gives you the catalog and `Plugins.make` gives you the
|
|
15
|
+
dispatcher. Use them directly when the test is about one hook, with the hook
|
|
16
|
+
interface as an explicit type argument:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { type FlowsPlugin, Plugins, Resolve, type WaterfallHook } from "@smthrs/plugin"
|
|
20
|
+
import * as Effect from "effect/Effect"
|
|
21
|
+
import { expect, it } from "vitest"
|
|
22
|
+
|
|
23
|
+
interface HostHooks {
|
|
24
|
+
readonly tools: WaterfallHook<
|
|
25
|
+
(tools: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<string> | void>
|
|
26
|
+
>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const subject: FlowsPlugin<HostHooks> = {
|
|
30
|
+
name: "flows-plugin-subject",
|
|
31
|
+
hooks: { tools: (tools) => Effect.succeed([...tools, "read", "write"]) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it("contributes its tools", async () => {
|
|
35
|
+
const resolved = await Effect.runPromise(
|
|
36
|
+
Resolve.resolve<HostHooks>([subject], { hooks: { tools: "waterfall" } })
|
|
37
|
+
)
|
|
38
|
+
const tools = await Effect.runPromise(
|
|
39
|
+
Plugins.make<HostHooks>(resolved).waterfall("tools", [] as ReadonlyArray<string>, (_previous, next) => next)
|
|
40
|
+
)
|
|
41
|
+
expect(tools).toEqual(["read", "write"])
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Use `Kernel.make` instead when the test is about the whole startup path: the
|
|
46
|
+
waterfall, the freeze, the observers, and the merged layer together.
|
|
47
|
+
|
|
48
|
+
## Startup hooks see no services
|
|
49
|
+
|
|
50
|
+
The shared `config` and `configResolved` hooks are context-free: `Kernel.make`
|
|
51
|
+
runs them before any plugin layer exists and supplies nothing itself, so a
|
|
52
|
+
handler that reads a service does not compile against `FlowsHooks`, and one
|
|
53
|
+
cast past the type fails at runtime with `hook_failed` on that hook. Provide
|
|
54
|
+
the service inside the hook, or, when a host's startup hooks genuinely require
|
|
55
|
+
services, declare a separate hook interface and provide them to the startup
|
|
56
|
+
Effect. `Kernel.make<H>` carries the requirement as `Kernel.StartupContext<H>`:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
interface HostHooks {
|
|
60
|
+
readonly config: WaterfallHook<
|
|
61
|
+
(config: FlowsConfig) => Effect.Effect<Partial<FlowsConfig> | void, never, Settings>
|
|
62
|
+
>
|
|
63
|
+
readonly configResolved: ParallelHook<(config: ResolvedConfig) => Effect.Effect<void, never, Settings>>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const kernel = await Effect.runPromise(
|
|
67
|
+
Kernel.make<HostHooks>([reader], {}, { hooks: { config: "waterfall", configResolved: "parallel" } }).pipe(
|
|
68
|
+
Effect.provideService(Settings, { enabled: true })
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Without the `provideService`, the program does not compile.
|
|
74
|
+
|
|
75
|
+
## Assert the order, not just the membership
|
|
76
|
+
|
|
77
|
+
`resolved.plugins` is the resolved plugin order and
|
|
78
|
+
`resolved.handlers.get(hook)` is the ordered handler list for one hook, each
|
|
79
|
+
record carrying the plugin that declared it:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const names = (resolved: Resolve.Resolved, hook: string) =>
|
|
83
|
+
(resolved.handlers.get(hook) ?? []).map((record) => record.plugin)
|
|
84
|
+
|
|
85
|
+
expect(names(resolved, "configResolved")).toEqual(["early", "normal", "late"])
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Assert both when a plugin declares a per-hook `order`: the two lists differ on
|
|
89
|
+
purpose, and only the handler list proves the hook is positioned the way you
|
|
90
|
+
meant.
|
|
91
|
+
|
|
92
|
+
## Assert typed failures by code
|
|
93
|
+
|
|
94
|
+
Every refusal is a `PluginError` with a `code`, and most carry the plugin, the
|
|
95
|
+
hook, and the value path. Flip the Effect and match the fields you care about:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { Kernel } from "@smthrs/plugin"
|
|
99
|
+
|
|
100
|
+
const error = await Effect.runPromise(
|
|
101
|
+
Kernel.make([{ name: "duplicate" }, { name: "duplicate" }]).pipe(Effect.flip)
|
|
102
|
+
)
|
|
103
|
+
expect(error).toMatchObject({ code: "duplicate_name", plugin: "duplicate" })
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Match on `code`, `plugin`, `hook`, and `path`, not on `message`. The codes are a
|
|
107
|
+
closed set, listed in [Troubleshooting](../troubleshooting.md); the messages are
|
|
108
|
+
prose and may be reworded.
|
|
109
|
+
|
|
110
|
+
## Observer failures are a return value
|
|
111
|
+
|
|
112
|
+
A failing `configResolved` handler does not fail startup, so an assertion that
|
|
113
|
+
the kernel succeeded proves nothing about it. Read `observerErrors`:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
const kernel = await Effect.runPromise(
|
|
117
|
+
Kernel.make([{ name: "noisy", hooks: { configResolved: () => Effect.fail("boom") } }])
|
|
118
|
+
)
|
|
119
|
+
expect(kernel.observerErrors.map((error) => error.plugin)).toEqual(["noisy"])
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Stub the kernel out of a composition under test
|
|
123
|
+
|
|
124
|
+
When the subject is the host rather than the plugins, give it a dispatcher with
|
|
125
|
+
no plugins at all:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const dispatcher = Plugins.makeNoop()
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`Plugins.layerNoop` is the same thing as a layer, for a composition that resolves
|
|
132
|
+
the shared `Plugins` service tag. Both dispatch nothing: a parallel hook returns
|
|
133
|
+
an empty error list, a first hook answers `Option.none()`, and a waterfall
|
|
134
|
+
returns the value it was given.
|
|
135
|
+
|
|
136
|
+
## Test the pure pieces directly
|
|
137
|
+
|
|
138
|
+
`Config.merge` and `Config.deepFreeze` are ordinary functions that throw a
|
|
139
|
+
`PluginError` rather than returning an Effect, so a merge-semantics test needs no
|
|
140
|
+
runtime:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { Config } from "@smthrs/plugin"
|
|
144
|
+
|
|
145
|
+
expect(Config.merge({ a: { b: 1 } }, { a: { c: 2 } })).toEqual({ a: { b: 1, c: 2 } })
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`Hooks.handlerOf` and `Hooks.orderOf` read either hook entry form and are the
|
|
149
|
+
same shape of test.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Installation"
|
|
3
|
+
description: "Install @smthrs/plugin, its runtime requirements, its import forms, and the subpaths its export map blocks."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 1
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Install the package
|
|
9
|
+
|
|
10
|
+
Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
|
|
11
|
+
|
|
12
|
+
Install `effect` explicitly at `4.0.0-rc.115`. The kernel depends on it, but your
|
|
13
|
+
plugins import `Effect`, `Layer`, and `Option` directly, so `effect` belongs in
|
|
14
|
+
your own dependency list at the release the kernel is built against. A different
|
|
15
|
+
`effect` major gives you two copies of the runtime and two sets of service tags.
|
|
16
|
+
|
|
17
|
+
The package requires Node.js 26.4.0 or later and ships as both ESM and
|
|
18
|
+
CommonJS with TypeScript declarations. Its runtime dependencies install with it:
|
|
19
|
+
[`@smthrs/flow`](/api/flow) supplies `Action.CacheEnvironment` for
|
|
20
|
+
[cache identity](./guides/cache-identity.md), and
|
|
21
|
+
[`@smthrs/canonical`](/api/canonical) supplies immutable catalog maps.
|
|
22
|
+
|
|
23
|
+
## Import forms
|
|
24
|
+
|
|
25
|
+
The root entry point exports the plugin, hook, and error declarations directly,
|
|
26
|
+
and the four stateful modules as namespaces:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { Config, Kernel, Plugins, Resolve } from "@smthrs/plugin"
|
|
30
|
+
import type { FlowsHooks, FlowsPlugin, PluginInput } from "@smthrs/plugin"
|
|
31
|
+
import { engineHooks, make, PluginError } from "@smthrs/plugin"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Each module is also importable from its own subpath, which is the form the
|
|
35
|
+
[API reference](./api.md) uses:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import * as Config from "@smthrs/plugin/Config"
|
|
39
|
+
import * as Kernel from "@smthrs/plugin/Kernel"
|
|
40
|
+
import * as Plugins from "@smthrs/plugin/Plugins"
|
|
41
|
+
import * as Resolve from "@smthrs/plugin/Resolve"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The full list of public subpaths is `@smthrs/plugin/Config`,
|
|
45
|
+
`@smthrs/plugin/Hooks`, `@smthrs/plugin/Kernel`, `@smthrs/plugin/Plugin`,
|
|
46
|
+
`@smthrs/plugin/PluginError`, `@smthrs/plugin/Plugins`, and
|
|
47
|
+
`@smthrs/plugin/Resolve`. Two forms are blocked in the export map:
|
|
48
|
+
`@smthrs/plugin/internal/*` and `@smthrs/plugin/*/index`.
|
|
49
|
+
`@smthrs/plugin/package.json` is exported.
|
|
50
|
+
|
|
51
|
+
## The augmentation specifier
|
|
52
|
+
|
|
53
|
+
A host that declares its own hooks augments the interface in the root module,
|
|
54
|
+
never in a subpath:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
declare module "@smthrs/plugin" {
|
|
58
|
+
interface FlowsHooks {
|
|
59
|
+
// your hooks
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`FlowsHooks` is declared in the entry point precisely so this specifier works:
|
|
65
|
+
TypeScript can augment an interface only in the module that declares it. See
|
|
66
|
+
[Declare hooks for your host](./guides/host-your-own-hooks.md).
|
|
67
|
+
|
|
68
|
+
## Next step
|
|
69
|
+
|
|
70
|
+
Build a host with one hook of its own and run it in the
|
|
71
|
+
[Quickstart](./quickstart.md).
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Quickstart"
|
|
3
|
+
description: "Build a host with a hook of its own: declare the catalog, write two plugins, run the kernel, and dispatch the hook to a typed result."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 2
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
This quickstart builds a small host end to end. The host offers plugins one
|
|
9
|
+
extension point of its own, a `tools` waterfall, alongside the kernel's config
|
|
10
|
+
lifecycle. By the end you will have run the kernel, read a frozen configuration,
|
|
11
|
+
and dispatched a hook whose result is typed by the declaration you wrote.
|
|
12
|
+
|
|
13
|
+
The example uses two files: `host.ts` declares the hook catalog, and `main.ts`
|
|
14
|
+
defines the plugins and runs the host. There are no services to provide.
|
|
15
|
+
|
|
16
|
+
## Prerequisites
|
|
17
|
+
|
|
18
|
+
- Node.js 26.4.0 or later.
|
|
19
|
+
- A TypeScript project with the package installed. Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
|
|
20
|
+
|
|
21
|
+
## Declare the host's hook catalog
|
|
22
|
+
|
|
23
|
+
A host declares its hooks twice: once in the type system, so plugin authors get
|
|
24
|
+
completion and excess-key errors, and once as a runtime catalog, so the kernel
|
|
25
|
+
can refuse a hook name nobody dispatches.
|
|
26
|
+
|
|
27
|
+
Create `host.ts`:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { engineHooks, type WaterfallHook } from "@smthrs/plugin"
|
|
31
|
+
import type * as Effect from "effect/Effect"
|
|
32
|
+
|
|
33
|
+
declare module "@smthrs/plugin" {
|
|
34
|
+
interface FlowsHooks {
|
|
35
|
+
/** Contributes or rewrites the tool names the host offers. */
|
|
36
|
+
readonly tools: WaterfallHook<
|
|
37
|
+
(tools: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<string> | void>
|
|
38
|
+
>
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The runtime catalog: the kernel's two config hooks plus this host's one. */
|
|
43
|
+
export const hooks = Object.freeze(
|
|
44
|
+
{
|
|
45
|
+
...engineHooks,
|
|
46
|
+
tools: "waterfall"
|
|
47
|
+
} as const
|
|
48
|
+
)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The two declarations must agree. The interface says what compiles; the runtime
|
|
52
|
+
catalog says what dispatches. `WaterfallHook` in the interface and
|
|
53
|
+
`"waterfall"` in the catalog are the same claim, written for the two audiences.
|
|
54
|
+
|
|
55
|
+
## Write two plugins
|
|
56
|
+
|
|
57
|
+
Add all remaining TypeScript snippets to `main.ts` in order, starting with the
|
|
58
|
+
complete import block and the first plugin.
|
|
59
|
+
|
|
60
|
+
A plugin is a record. This one contributes a configuration namespace and two
|
|
61
|
+
tools.
|
|
62
|
+
|
|
63
|
+
Create `main.ts` beside `host.ts`:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { type FlowsHooks, Kernel, make } from "@smthrs/plugin"
|
|
67
|
+
import * as Effect from "effect/Effect"
|
|
68
|
+
import { hooks } from "./host.ts"
|
|
69
|
+
|
|
70
|
+
const editor = make<FlowsHooks>({
|
|
71
|
+
name: "flows-plugin-editor",
|
|
72
|
+
hooks: {
|
|
73
|
+
config: () => Effect.succeed({ editor: { readOnly: false } }),
|
|
74
|
+
tools: (tools) => Effect.succeed([...tools, "read", "write"])
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`make` is an identity function that pins the literal to `FlowsPlugin<H>`. Pass
|
|
80
|
+
the hook interface explicitly, as above: it is what types the `tools` parameter
|
|
81
|
+
as `ReadonlyArray<string>` and what makes a misspelled hook key fail at the
|
|
82
|
+
definition site instead of at startup.
|
|
83
|
+
|
|
84
|
+
The second plugin runs first, because it is declared `enforce: "pre"`, and it
|
|
85
|
+
reads what the host resolved rather than adding to it:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const audit = make<FlowsHooks>({
|
|
89
|
+
name: "flows-plugin-audit",
|
|
90
|
+
enforce: "pre",
|
|
91
|
+
hooks: {
|
|
92
|
+
configResolved: (config) => Effect.log(`resolved namespaces: ${Object.keys(config).join(", ")}`)
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Run the kernel
|
|
98
|
+
|
|
99
|
+
`Kernel.make` takes the plugin list, the pre-resolution configuration, and the
|
|
100
|
+
host's options. It resolves the list, runs the config waterfall, freezes the
|
|
101
|
+
result, notifies the observers, and merges the plugin layers:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const kernel = await Effect.runPromise(
|
|
105
|
+
Kernel.make([editor, audit], { host: { name: "quickstart" } }, { target: "harness", hooks })
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
console.log(kernel.config)
|
|
109
|
+
// { host: { name: "quickstart" }, editor: { readOnly: false } }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`target` names which kind of host you are, and it is what a plugin's
|
|
113
|
+
`apply: "engine" | "harness"` selector is compared against. It defaults to
|
|
114
|
+
`"engine"`, meaning the Smithers durable engine, so pass `"harness"` for any
|
|
115
|
+
other host, including this one.
|
|
116
|
+
|
|
117
|
+
## Dispatch the hook
|
|
118
|
+
|
|
119
|
+
`kernel.plugins` is the dispatcher. A waterfall needs one more argument than the
|
|
120
|
+
other kinds, the merge function that folds each handler's answer into the value
|
|
121
|
+
the next handler sees:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const tools = await Effect.runPromise(
|
|
125
|
+
kernel.plugins.waterfall("tools", [] as ReadonlyArray<string>, (_previous, next) => next)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
console.log(tools) // ["read", "write"]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The literal `"tools"` is checked against the hook interface: a name that is not
|
|
132
|
+
a waterfall in `FlowsHooks` does not compile, and `tools` is typed as the
|
|
133
|
+
handler's argument type, not as `unknown`.
|
|
134
|
+
|
|
135
|
+
## What you just built
|
|
136
|
+
|
|
137
|
+
- A hook catalog only your host dispatches. Another host augmenting the same
|
|
138
|
+
interface still dispatches only the catalog it supplied.
|
|
139
|
+
- A plugin list resolved once into a frozen catalog. Mutating `editor` after
|
|
140
|
+
this point changes nothing about dispatch.
|
|
141
|
+
- A configuration every plugin agreed on, deeply copied and frozen before the
|
|
142
|
+
first plugin saw it.
|
|
143
|
+
|
|
144
|
+
## Next steps
|
|
145
|
+
|
|
146
|
+
- [Hook kinds](./concepts/hook-kinds.md) for what `sequential`, `parallel`,
|
|
147
|
+
`first`, and `waterfall` each promise.
|
|
148
|
+
- [Control the order handlers run in](./guides/order-handlers.md) for `enforce`
|
|
149
|
+
and the per-hook `order`.
|
|
150
|
+
- [Contribute services from a plugin](./guides/contribute-services.md) to add a
|
|
151
|
+
`layer` to the plugin above.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Troubleshooting"
|
|
3
|
+
description: "Every PluginError code the kernel reports, what causes it, and what to change, plus the failures that are silent by design."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Every refusal this package makes is a `PluginError` with a `code` from a closed
|
|
7
|
+
set. Find the code and read its section. The full error schema is in the
|
|
8
|
+
[API reference](./api.md).
|
|
9
|
+
|
|
10
|
+
Most errors carry `plugin`, `hook`, and `path` alongside the message. Read
|
|
11
|
+
`path` first: it is a diagnostic dollar-path into the value the kernel refused,
|
|
12
|
+
not an RFC 6901 JSON Pointer. `$` is the input, `.config` an identifier
|
|
13
|
+
property, `[2]` an array index, and `["not an identifier"]` any other key,
|
|
14
|
+
JSON-quoted. So `$[2].hooks.config` is the third plugin's `config` entry and
|
|
15
|
+
`$options.parallelConcurrency` is an option.
|
|
16
|
+
|
|
17
|
+
## duplicate_name
|
|
18
|
+
|
|
19
|
+
**What happened.** Two selected plugins have the same name. Names compare as
|
|
20
|
+
exact Unicode strings with no normalization, so this is a real collision, not a
|
|
21
|
+
spelling variant.
|
|
22
|
+
|
|
23
|
+
**What to change.** Rename one, or exclude one with
|
|
24
|
+
[`apply`](./guides/select-plugins.md). The kernel refuses rather than letting
|
|
25
|
+
the last one win, because last-wins turns a duplicated preset into a silently
|
|
26
|
+
dropped plugin.
|
|
27
|
+
|
|
28
|
+
## unknown_hook
|
|
29
|
+
|
|
30
|
+
**What happened.** A selected plugin declares a hook name that is not in the
|
|
31
|
+
host's runtime catalog. The error names the plugin and the hook.
|
|
32
|
+
|
|
33
|
+
**What to change.** If the hook is yours to add, list it in the catalog you pass
|
|
34
|
+
as `Resolve.Options.hooks` and augment `FlowsHooks` to match. See
|
|
35
|
+
[Declare hooks for your host](./guides/host-your-own-hooks.md). If the hook
|
|
36
|
+
belongs to a different host, give the plugin an `apply` selector so this host
|
|
37
|
+
does not select it.
|
|
38
|
+
|
|
39
|
+
Passing no catalog means the shared one: `config` and `configResolved` only.
|
|
40
|
+
|
|
41
|
+
## hook_kind_mismatch
|
|
42
|
+
|
|
43
|
+
**What happened.** The runtime catalog and the kind actually run disagree.
|
|
44
|
+
At startup, `Options.hooks` labelled `config` with a kind other than
|
|
45
|
+
`waterfall`, or `configResolved` with a kind other than `parallel`; the path
|
|
46
|
+
names the entry, such as `$options.hooks.config`. At dispatch, a method was
|
|
47
|
+
called for a hook the catalog declares with a different kind, which only a cast
|
|
48
|
+
around the typed dispatcher can reach. In both cases no handler ran.
|
|
49
|
+
|
|
50
|
+
**What to change.** Make the catalog agree with the hook interface: spread
|
|
51
|
+
`engineHooks` for the shared hooks and write each of your own hooks with the
|
|
52
|
+
kind its type declares. `HookCatalog<H>` refuses a wrong kind for a declared
|
|
53
|
+
name at compile time.
|
|
54
|
+
|
|
55
|
+
## invalid_plugin
|
|
56
|
+
|
|
57
|
+
**What happened.** A plugin, preset, option, or hook entry has a shape the
|
|
58
|
+
kernel will not accept. Common causes, each reported at its own path:
|
|
59
|
+
|
|
60
|
+
- A hook entry that is `undefined` or `null`. Declarations must be callable, so
|
|
61
|
+
a conditionally built hook is refused rather than skipped.
|
|
62
|
+
- An unknown property on a plugin record or on the options object.
|
|
63
|
+
- A name or version that is empty, whitespace only, longer than 256 UTF-16 code
|
|
64
|
+
units, or not well-formed text.
|
|
65
|
+
- `enforce` or `order` that is not `"pre"` or `"post"`.
|
|
66
|
+
- `apply` that is not `"engine"`, `"harness"`, or a function.
|
|
67
|
+
- `layer` that is not an Effect `Layer`.
|
|
68
|
+
- A preset array that is sparse, cyclic, repeated, or carries extra properties.
|
|
69
|
+
- An accessor, a symbol key, or a non-enumerable property anywhere in the
|
|
70
|
+
record.
|
|
71
|
+
|
|
72
|
+
**What to change.** Read `path`, fix that value. To omit an optional hook, leave
|
|
73
|
+
the key out of the `hooks` record instead of setting it to `undefined`.
|
|
74
|
+
|
|
75
|
+
## apply_failed
|
|
76
|
+
|
|
77
|
+
**What happened.** An `apply` predicate threw. The raw failure is not retained,
|
|
78
|
+
because a predicate runs before the kernel has anywhere safe to put an arbitrary
|
|
79
|
+
value.
|
|
80
|
+
|
|
81
|
+
**What to change.** Make the predicate total. It receives the pre-resolution
|
|
82
|
+
configuration and must return a boolean without reading anything it cannot
|
|
83
|
+
guarantee is there. A predicate that returns a non-boolean fails with
|
|
84
|
+
`invalid_plugin` instead.
|
|
85
|
+
|
|
86
|
+
## config_invalid
|
|
87
|
+
|
|
88
|
+
**What happened.** A configuration value is not bounded strict JSON, or it uses
|
|
89
|
+
a reserved root key. This covers the pre-resolution config, any patch a `config`
|
|
90
|
+
handler returned, and the merged result.
|
|
91
|
+
|
|
92
|
+
**What to change.** Read `path`. The frequent causes:
|
|
93
|
+
|
|
94
|
+
- `$.engine`, `$.retry`, `$.store`, or `$.plugins`: rename the namespace. The
|
|
95
|
+
kernel does not apply those policies and refuses to look like it does.
|
|
96
|
+
- An `undefined` member: omit the key, or write `null`. `{ endpoint: undefined }`
|
|
97
|
+
fails at `$.endpoint` rather than resolving to `{}`.
|
|
98
|
+
- A `Date`, `Map`, class instance, or non-finite number: encode it as JSON, or
|
|
99
|
+
keep it in a service instead of in configuration.
|
|
100
|
+
- `__proto__`, `constructor`, or `prototype` as a key at any depth.
|
|
101
|
+
- A tree over 1 MiB, 64 deep, 4,096 members, or 8,192 values. See
|
|
102
|
+
[Configuration](./concepts/configuration.md) for the whole list.
|
|
103
|
+
|
|
104
|
+
When a `config` handler returned the offending patch, the error also carries
|
|
105
|
+
that plugin and hook.
|
|
106
|
+
|
|
107
|
+
## cache_environment_invalid
|
|
108
|
+
|
|
109
|
+
**What happened.** You declared `cacheEnvironment` and the kernel could not
|
|
110
|
+
build a complete identity from it. Either a selected plugin has no `version`, or
|
|
111
|
+
the environment does not match the `Action.CacheEnvironment` schema.
|
|
112
|
+
|
|
113
|
+
**What to change.** Add a `version` to every selected plugin, or drop
|
|
114
|
+
`cacheEnvironment` and let sealed keys stay run-local. Both are correct; a bare
|
|
115
|
+
plugin name folded into a cross-run cache key is not. See
|
|
116
|
+
[Declare a cache identity](./guides/cache-identity.md).
|
|
117
|
+
|
|
118
|
+
## invalid_hook_result
|
|
119
|
+
|
|
120
|
+
**What happened.** A `first` hook handler returned something other than an
|
|
121
|
+
`Option`. The error names the plugin and the hook.
|
|
122
|
+
|
|
123
|
+
**What to change.** Return `Option.some(value)` to answer and `Option.none()` to
|
|
124
|
+
pass. A `first` hook stops at the first `some`, so returning a bare value would
|
|
125
|
+
make "no opinion" indistinguishable from "the answer is undefined".
|
|
126
|
+
|
|
127
|
+
## resource_limit
|
|
128
|
+
|
|
129
|
+
**What happened.** The plugin input, the handler count, or a concurrency option
|
|
130
|
+
exceeded a published bound: 256 plugins, 1,024 handlers, 4,096 input nodes, 64
|
|
131
|
+
nested preset arrays, or a `parallelConcurrency` outside 1 through 256.
|
|
132
|
+
|
|
133
|
+
**What to change.** Split the composition, or raise nothing: these bounds are
|
|
134
|
+
fixed. A list this large is usually a preset included twice or an array building
|
|
135
|
+
itself in a loop. The [limits table](./concepts/resolution.md) has each bound
|
|
136
|
+
and the export that names it.
|
|
137
|
+
|
|
138
|
+
## hook_failed
|
|
139
|
+
|
|
140
|
+
**What happened.** A handler failed, defected, threw synchronously, or returned
|
|
141
|
+
something that is not an Effect. The error names the plugin and the hook, and
|
|
142
|
+
the original failure is on `cause`.
|
|
143
|
+
|
|
144
|
+
**What to change.** Fix the handler. Where it surfaces depends on the kind: a
|
|
145
|
+
`sequential`, `first`, or `waterfall` hook fails the caller and stops dispatch
|
|
146
|
+
at that handler, while a `parallel` hook returns the error and keeps going. See
|
|
147
|
+
[Hook kinds](./concepts/hook-kinds.md).
|
|
148
|
+
|
|
149
|
+
## layer_failed
|
|
150
|
+
|
|
151
|
+
**What happened.** A plugin's layer failed while it was being built. The error
|
|
152
|
+
names the plugin, with the layer's own failure on `cause`.
|
|
153
|
+
|
|
154
|
+
**What to change.** Fix the layer's acquisition, or make the plugin conditional
|
|
155
|
+
so it is not selected in compositions where its dependencies are absent. The
|
|
156
|
+
failure arrives when something provides `kernel.layer`, not when `Kernel.make`
|
|
157
|
+
returns.
|
|
158
|
+
|
|
159
|
+
## Failures that are silent by design
|
|
160
|
+
|
|
161
|
+
**A `configResolved` handler failed and nothing happened.** Parallel observers
|
|
162
|
+
are lossy on purpose: their failures are collected, not raised. Read
|
|
163
|
+
`kernel.observerErrors` and log them. A composition that ignores that array
|
|
164
|
+
cannot tell a working observer from a broken one.
|
|
165
|
+
|
|
166
|
+
**A plugin was dropped and no error was reported.** Exclusion is not a failure.
|
|
167
|
+
`apply: "harness"` under the default `target: "engine"` drops the plugin
|
|
168
|
+
silently, which is exactly what a shared preset needs. Check
|
|
169
|
+
`kernel.plugins.resolved.plugins` for what was actually selected, and pass
|
|
170
|
+
`{ target: "harness" }` if your host is a harness.
|
|
171
|
+
|
|
172
|
+
**A hook never runs even though it resolved.** The kernel dispatches only the
|
|
173
|
+
config lifecycle. Every other hook runs when the host calls the dispatcher for
|
|
174
|
+
it, so a hook in the catalog that no host code dispatches is a handler nobody
|
|
175
|
+
calls.
|
|
176
|
+
|
|
177
|
+
**Mutating a plugin or a config object after startup changed nothing.**
|
|
178
|
+
Resolution copies everything it accepts. Dispatch reads the kernel's own frozen
|
|
179
|
+
records, never yours. Change the input and resolve again.
|