@smthrs/plugin 0.0.0-stage → 1.0.0-rc.13

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 (118) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/dist/cjs/Config.d.ts +129 -0
  5. package/dist/cjs/Config.d.ts.map +1 -0
  6. package/dist/cjs/Config.js +115 -0
  7. package/dist/cjs/Config.js.map +7 -0
  8. package/dist/cjs/Hooks.d.ts +176 -0
  9. package/dist/cjs/Hooks.d.ts.map +1 -0
  10. package/dist/cjs/Hooks.js +34 -0
  11. package/dist/cjs/Hooks.js.map +7 -0
  12. package/dist/cjs/Kernel.d.ts +81 -0
  13. package/dist/cjs/Kernel.d.ts.map +1 -0
  14. package/dist/cjs/Kernel.js +57 -0
  15. package/dist/cjs/Kernel.js.map +7 -0
  16. package/dist/cjs/Plugin.d.ts +71 -0
  17. package/dist/cjs/Plugin.d.ts.map +1 -0
  18. package/dist/cjs/Plugin.js +25 -0
  19. package/dist/cjs/Plugin.js.map +7 -0
  20. package/dist/cjs/PluginError.d.ts +50 -0
  21. package/dist/cjs/PluginError.d.ts.map +1 -0
  22. package/dist/cjs/PluginError.js +58 -0
  23. package/dist/cjs/PluginError.js.map +7 -0
  24. package/dist/cjs/Plugins.d.ts +97 -0
  25. package/dist/cjs/Plugins.d.ts.map +1 -0
  26. package/dist/cjs/Plugins.js +169 -0
  27. package/dist/cjs/Plugins.js.map +7 -0
  28. package/dist/cjs/Resolve.d.ts +128 -0
  29. package/dist/cjs/Resolve.d.ts.map +1 -0
  30. package/dist/cjs/Resolve.js +346 -0
  31. package/dist/cjs/Resolve.js.map +7 -0
  32. package/dist/cjs/index.d.ts +83 -0
  33. package/dist/cjs/index.d.ts.map +1 -0
  34. package/dist/cjs/index.js +45 -0
  35. package/dist/cjs/index.js.map +7 -0
  36. package/dist/cjs/internal/Boundary.d.ts +96 -0
  37. package/dist/cjs/internal/Boundary.d.ts.map +1 -0
  38. package/dist/cjs/internal/Boundary.js +129 -0
  39. package/dist/cjs/internal/Boundary.js.map +7 -0
  40. package/dist/cjs/internal/mergePluginLayers.d.ts +21 -0
  41. package/dist/cjs/internal/mergePluginLayers.d.ts.map +1 -0
  42. package/dist/cjs/internal/mergePluginLayers.js +57 -0
  43. package/dist/cjs/internal/mergePluginLayers.js.map +7 -0
  44. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts +23 -0
  45. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  46. package/dist/cjs/internal/snapshotCacheEnvironment.js +92 -0
  47. package/dist/cjs/internal/snapshotCacheEnvironment.js.map +7 -0
  48. package/dist/cjs/package.json +1 -0
  49. package/dist/esm/Config.d.ts +129 -0
  50. package/dist/esm/Config.d.ts.map +1 -0
  51. package/dist/esm/Config.js +160 -0
  52. package/dist/esm/Config.js.map +1 -0
  53. package/dist/esm/Hooks.d.ts +176 -0
  54. package/dist/esm/Hooks.d.ts.map +1 -0
  55. package/dist/esm/Hooks.js +56 -0
  56. package/dist/esm/Hooks.js.map +1 -0
  57. package/dist/esm/Kernel.d.ts +81 -0
  58. package/dist/esm/Kernel.d.ts.map +1 -0
  59. package/dist/esm/Kernel.js +58 -0
  60. package/dist/esm/Kernel.js.map +1 -0
  61. package/dist/esm/Plugin.d.ts +71 -0
  62. package/dist/esm/Plugin.d.ts.map +1 -0
  63. package/dist/esm/Plugin.js +20 -0
  64. package/dist/esm/Plugin.js.map +1 -0
  65. package/dist/esm/PluginError.d.ts +50 -0
  66. package/dist/esm/PluginError.d.ts.map +1 -0
  67. package/dist/esm/PluginError.js +53 -0
  68. package/dist/esm/PluginError.js.map +1 -0
  69. package/dist/esm/Plugins.d.ts +97 -0
  70. package/dist/esm/Plugins.d.ts.map +1 -0
  71. package/dist/esm/Plugins.js +190 -0
  72. package/dist/esm/Plugins.js.map +1 -0
  73. package/dist/esm/Resolve.d.ts +128 -0
  74. package/dist/esm/Resolve.d.ts.map +1 -0
  75. package/dist/esm/Resolve.js +369 -0
  76. package/dist/esm/Resolve.js.map +1 -0
  77. package/dist/esm/index.d.ts +83 -0
  78. package/dist/esm/index.d.ts.map +1 -0
  79. package/dist/esm/index.js +48 -0
  80. package/dist/esm/index.js.map +1 -0
  81. package/dist/esm/internal/Boundary.d.ts +96 -0
  82. package/dist/esm/internal/Boundary.d.ts.map +1 -0
  83. package/dist/esm/internal/Boundary.js +162 -0
  84. package/dist/esm/internal/Boundary.js.map +1 -0
  85. package/dist/esm/internal/mergePluginLayers.d.ts +21 -0
  86. package/dist/esm/internal/mergePluginLayers.d.ts.map +1 -0
  87. package/dist/esm/internal/mergePluginLayers.js +41 -0
  88. package/dist/esm/internal/mergePluginLayers.js.map +1 -0
  89. package/dist/esm/internal/snapshotCacheEnvironment.d.ts +23 -0
  90. package/dist/esm/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  91. package/dist/esm/internal/snapshotCacheEnvironment.js +64 -0
  92. package/dist/esm/internal/snapshotCacheEnvironment.js.map +1 -0
  93. package/docs/README.md +133 -0
  94. package/docs/api.md +524 -0
  95. package/docs/concepts/configuration.md +111 -0
  96. package/docs/concepts/hook-kinds.md +107 -0
  97. package/docs/concepts/resolution.md +132 -0
  98. package/docs/guides/cache-identity.md +106 -0
  99. package/docs/guides/contribute-services.md +84 -0
  100. package/docs/guides/host-your-own-hooks.md +129 -0
  101. package/docs/guides/order-handlers.md +76 -0
  102. package/docs/guides/select-plugins.md +72 -0
  103. package/docs/guides/testing.md +149 -0
  104. package/docs/installation.md +75 -0
  105. package/docs/quickstart.md +152 -0
  106. package/docs/troubleshooting.md +180 -0
  107. package/package.json +179 -3
  108. package/src/Config.ts +207 -0
  109. package/src/Hooks.ts +212 -0
  110. package/src/Kernel.ts +119 -0
  111. package/src/Plugin.ts +80 -0
  112. package/src/PluginError.ts +62 -0
  113. package/src/Plugins.ts +271 -0
  114. package/src/Resolve.ts +539 -0
  115. package/src/index.ts +91 -0
  116. package/src/internal/Boundary.ts +252 -0
  117. package/src/internal/mergePluginLayers.ts +57 -0
  118. package/src/internal/snapshotCacheEnvironment.ts +78 -0
package/src/Plugins.ts ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * The dispatcher: a plain service over a resolved plugin list, generic over the
3
+ * hook interface.
4
+ *
5
+ * The public package contract is documented at
6
+ * {@link https://smithers.sh/docs/reference/api/plugin}. Each host holds an instance over its
7
+ * augmented `FlowsHooks` and may dispatch only the runtime catalog it supplied.
8
+ * The shipped cell host owns the three
9
+ * waterfalls declared in `packages/smithers/agent/src/CellPlugin.ts`; there is
10
+ * no engine-wide lifecycle dispatcher.
11
+ *
12
+ * Cancellation is fiber interruption via scope closure; nothing here threads an
13
+ * `AbortSignal`.
14
+ *
15
+ * @since 1.0.0-rc.0
16
+ */
17
+
18
+ import * as ImmutableMap from "@smthrs/canonical/ReadonlyMap"
19
+ import type * as Cause from "effect/Cause"
20
+ import * as Context from "effect/Context"
21
+ import * as Effect from "effect/Effect"
22
+ import * as Layer from "effect/Layer"
23
+ import * as Option from "effect/Option"
24
+ import type { ArgsOf, ContextOf, HookKind, KeysOfKind, SuccessOf } from "./Hooks.ts"
25
+ import { engineHooks } from "./Hooks.ts"
26
+ import type { FlowsHooks } from "./index.ts"
27
+ import { PluginError } from "./PluginError.ts"
28
+ import { defaultParallelConcurrency, type HandlerRecord, type Resolved } from "./Resolve.ts"
29
+
30
+ const empty: ReadonlyArray<HandlerRecord> = Object.freeze([])
31
+
32
+ // The typed catalog and the runtime catalog can only drift when a host casts
33
+ // around `KeysOfKind`; refuse before any handler runs rather than execute a
34
+ // hook under semantics its declaration never promised.
35
+ // A hook the catalog does not declare dispatches as empty, unless a
36
+ // hand-built resolution registered handlers for it: those have no declared
37
+ // kind to honor, so they are refused too.
38
+ const kindMismatch = (
39
+ resolved: Resolved<unknown>,
40
+ hook: string,
41
+ kind: HookKind,
42
+ handlers: ReadonlyArray<HandlerRecord>
43
+ ): PluginError | undefined => {
44
+ if (!Object.hasOwn(resolved.kinds, hook)) {
45
+ if (handlers.length === 0) return undefined
46
+ return new PluginError({
47
+ code: "hook_kind_mismatch",
48
+ message: `hook "${hook}" has handlers but is not declared in the runtime catalog`,
49
+ hook
50
+ })
51
+ }
52
+ const declared = resolved.kinds[hook]
53
+ if (declared === kind) return undefined
54
+ return new PluginError({
55
+ code: "hook_kind_mismatch",
56
+ message: `hook "${hook}" is declared ${JSON.stringify(declared)} in the runtime catalog but was dispatched as ${
57
+ JSON.stringify(kind)
58
+ }`,
59
+ hook
60
+ })
61
+ }
62
+
63
+ const runHandler = (
64
+ record: HandlerRecord,
65
+ args: ReadonlyArray<unknown>
66
+ ): Effect.Effect<unknown, PluginError, any> => {
67
+ const onCause = (cause: Cause.Cause<unknown>) =>
68
+ Effect.fail(
69
+ new PluginError({
70
+ code: "hook_failed",
71
+ message: `hook "${record.hook}" failed in plugin "${record.plugin}"`,
72
+ plugin: record.plugin,
73
+ hook: record.hook,
74
+ cause
75
+ })
76
+ )
77
+
78
+ return Effect.sync(() => record.handler(...args)).pipe(
79
+ Effect.catchCause(onCause),
80
+ Effect.flatMap((result) =>
81
+ Effect.isEffect(result)
82
+ ? result.pipe(Effect.catchCause(onCause))
83
+ : Effect.fail(
84
+ new PluginError({
85
+ code: "hook_failed",
86
+ message: `hook "${record.hook}" in plugin "${record.plugin}" returned a non-Effect (${typeof result})`,
87
+ plugin: record.plugin,
88
+ hook: record.hook
89
+ })
90
+ )
91
+ )
92
+ )
93
+ }
94
+
95
+ /**
96
+ * The dispatch surface.
97
+ *
98
+ * @category models
99
+ * @since 1.0.0-rc.0
100
+ */
101
+ export interface Service<H = FlowsHooks> {
102
+ /** The resolved list this dispatcher walks. */
103
+ readonly resolved: Resolved<H>
104
+ /** Frozen, ordered handler records for a hook. */
105
+ readonly handlers: (hook: string) => ReadonlyArray<HandlerRecord>
106
+ /** Runs every handler in resolved order, one at a time. */
107
+ readonly sequential: <K extends KeysOfKind<H, "sequential">>(
108
+ hook: K,
109
+ ...args: ArgsOf<H[K]>
110
+ ) => Effect.Effect<ReadonlyArray<SuccessOf<H[K]>>, PluginError, ContextOf<H[K]>>
111
+ /**
112
+ * Runs every handler concurrently, ignoring results. Never fails: handler
113
+ * failures are returned so the caller can report them at its own boundary.
114
+ */
115
+ readonly parallel: <K extends KeysOfKind<H, "parallel">>(
116
+ hook: K,
117
+ ...args: ArgsOf<H[K]>
118
+ ) => Effect.Effect<ReadonlyArray<PluginError>, never, ContextOf<H[K]>>
119
+ /** Runs handlers in order until one returns `Option.some`. */
120
+ readonly first: <K extends KeysOfKind<H, "first">>(
121
+ hook: K,
122
+ ...args: ArgsOf<H[K]>
123
+ ) => Effect.Effect<SuccessOf<H[K]>, PluginError, ContextOf<H[K]>>
124
+ /** Threads a value through every handler, merging each returned patch. */
125
+ readonly waterfall: <K extends KeysOfKind<H, "waterfall">>(
126
+ hook: K,
127
+ initial: ArgsOf<H[K]>[0],
128
+ merge: (previous: ArgsOf<H[K]>[0], patch: Exclude<SuccessOf<H[K]>, void>) => ArgsOf<H[K]>[0]
129
+ ) => Effect.Effect<ArgsOf<H[K]>[0], PluginError, ContextOf<H[K]>>
130
+ }
131
+
132
+ /**
133
+ * Builds a dispatcher over an already resolved plugin list.
134
+ *
135
+ * @category constructors
136
+ * @since 1.0.0-rc.0
137
+ */
138
+ export const make = <H = FlowsHooks>(resolved: Resolved<H>): Service<H> => {
139
+ const handlers = (hook: string): ReadonlyArray<HandlerRecord> => resolved.handlers.get(hook) ?? empty
140
+
141
+ const sequential = ((hook: string, ...args: Array<unknown>) =>
142
+ Effect.gen(function*() {
143
+ const mismatch = kindMismatch(resolved, hook, "sequential", handlers(hook))
144
+ if (mismatch !== undefined) return yield* Effect.fail(mismatch)
145
+ const results: Array<unknown> = []
146
+ for (const record of handlers(hook)) {
147
+ results.push(yield* runHandler(record, args))
148
+ }
149
+ return results
150
+ })) as Service<H>["sequential"]
151
+
152
+ const parallel = ((hook: string, ...args: Array<unknown>) => {
153
+ const mismatch = kindMismatch(resolved, hook, "parallel", handlers(hook))
154
+ if (mismatch !== undefined) return Effect.succeed([mismatch])
155
+ return Effect.forEach(
156
+ handlers(hook),
157
+ (record) =>
158
+ runHandler(record, args).pipe(
159
+ Effect.match({ onFailure: (error) => [error], onSuccess: () => [] as Array<PluginError> })
160
+ ),
161
+ { concurrency: resolved.parallelConcurrency }
162
+ ).pipe(Effect.map((chunks) => chunks.flat()))
163
+ }) as Service<H>["parallel"]
164
+
165
+ const first = ((hook: string, ...args: Array<unknown>) =>
166
+ Effect.gen(function*() {
167
+ const mismatch = kindMismatch(resolved, hook, "first", handlers(hook))
168
+ if (mismatch !== undefined) return yield* Effect.fail(mismatch)
169
+ for (const record of handlers(hook)) {
170
+ const result = yield* runHandler(record, args)
171
+ if (!Option.isOption(result)) {
172
+ return yield* Effect.fail(
173
+ new PluginError({
174
+ code: "invalid_hook_result",
175
+ message: `first hook "${record.hook}" in plugin "${record.plugin}" did not return an Option`,
176
+ plugin: record.plugin,
177
+ hook: record.hook
178
+ })
179
+ )
180
+ }
181
+ if (Option.isSome(result)) return result
182
+ }
183
+ return Option.none()
184
+ })) as unknown as Service<H>["first"]
185
+
186
+ const waterfall =
187
+ ((hook: string, initial: unknown, merge: (previous: unknown, patch: unknown) => unknown) =>
188
+ Effect.gen(function*() {
189
+ const mismatch = kindMismatch(resolved, hook, "waterfall", handlers(hook))
190
+ if (mismatch !== undefined) return yield* Effect.fail(mismatch)
191
+ let value = initial
192
+ for (const record of handlers(hook)) {
193
+ const patch = yield* runHandler(record, [value])
194
+ if (patch !== undefined) {
195
+ value = yield* Effect.try({
196
+ try: () => merge(value, patch),
197
+ catch: (cause) =>
198
+ cause instanceof PluginError
199
+ ? new PluginError({
200
+ code: cause.code,
201
+ message: cause.message,
202
+ plugin: record.plugin,
203
+ hook: record.hook,
204
+ path: cause.path,
205
+ cause
206
+ })
207
+ : new PluginError({
208
+ code: "config_invalid",
209
+ message: `waterfall hook "${record.hook}" in plugin "${record.plugin}" returned an invalid patch`,
210
+ plugin: record.plugin,
211
+ hook: record.hook,
212
+ cause
213
+ })
214
+ })
215
+ }
216
+ }
217
+ return value
218
+ })) as Service<H>["waterfall"]
219
+
220
+ return Object.freeze({ resolved, handlers, sequential, parallel, first, waterfall })
221
+ }
222
+
223
+ /**
224
+ * A dispatcher with no plugins at all.
225
+ *
226
+ * @category constructors
227
+ * @since 1.0.0-rc.0
228
+ */
229
+ export const makeNoop = <H = FlowsHooks>(): Service<H> =>
230
+ make<H>(Object.freeze({
231
+ plugins: Object.freeze([]),
232
+ handlers: ImmutableMap.make<string, ReadonlyArray<HandlerRecord>>(),
233
+ kinds: engineHooks,
234
+ parallelConcurrency: defaultParallelConcurrency
235
+ }))
236
+
237
+ /**
238
+ * The shared dispatcher, as a service tag.
239
+ *
240
+ * This tag holds one dispatcher over the process-wide augmented `FlowsHooks`.
241
+ * `declare module "@smthrs/plugin"` augmentation is the supported extension
242
+ * mechanism for values placed in the tag. A host typed against a separate hook
243
+ * interface holds its `Service<H>` directly, as `@smthrs/agent` does, and does
244
+ * not use this tag or its layers.
245
+ *
246
+ * @category context
247
+ * @since 1.0.0-rc.0
248
+ */
249
+ export class Plugins extends Context.Service<Plugins, Service>()("flows/plugin/Plugins") {}
250
+
251
+ /**
252
+ * Layer providing a dispatcher over an already resolved plugin list.
253
+ *
254
+ * This layer targets the shared augmented `FlowsHooks` catalog only. A host
255
+ * with a separate hook interface holds its `Service<H>` directly.
256
+ *
257
+ * @category layers
258
+ * @since 1.0.0-rc.0
259
+ */
260
+ export const layer = (resolved: Resolved): Layer.Layer<Plugins> => Layer.succeed(Plugins)(make(resolved))
261
+
262
+ /**
263
+ * Layer providing a dispatcher with no plugins.
264
+ *
265
+ * This layer targets the shared augmented `FlowsHooks` catalog only. A host
266
+ * with a separate hook interface holds its `Service<H>` directly.
267
+ *
268
+ * @category layers
269
+ * @since 1.0.0-rc.0
270
+ */
271
+ export const layerNoop: Layer.Layer<Plugins> = Layer.succeed(Plugins)(makeNoop())