@lambdot/core 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#12](https://github.com/Embers-of-the-Fire/lambdot/pull/12) [`71e5732`](https://github.com/Embers-of-the-Fire/lambdot/commit/71e57321ad4ec7d1aef3651d104123f8167ec2e7) Thanks [@Embers-of-the-Fire](https://github.com/Embers-of-the-Fire)! - Add `Composite.expose(name)`: seal a kernel chain into a final, named `Engine` artifact. The engine preserves the chain's external input requirement, erases `bind`-encapsulated internals from its type, drops the composition methods (`use`/`bind` throw at runtime once exposed), and wires into a supervisor kernel under its new name.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#12](https://github.com/Embers-of-the-Fire/lambdot/pull/12) [`71e5732`](https://github.com/Embers-of-the-Fire/lambdot/commit/71e57321ad4ec7d1aef3651d104123f8167ec2e7) Thanks [@Embers-of-the-Fire](https://github.com/Embers-of-the-Fire)! - Implement `Engine.apply` on the runtime behind `Composite.expose`: the sealed engine now delegates to its inner composition, so calling `engine.apply(input, scope, config)` directly works instead of throwing `TypeError: engine.apply is not a function`.
12
+
3
13
  ## 0.1.1
4
14
 
5
15
  ### Patch Changes
package/README.md CHANGED
@@ -48,6 +48,14 @@ as })` feeds a plugin from the namespaces visible so far and exposes its
48
48
  composition order — resolve mapping, validate config, `apply` — and
49
49
  `stop()` disposes in reverse. Ordering mistakes are compile errors in the
50
50
  mappings, not runtime states.
51
+ - **`expose(name)` seals a chain into a final engine.** The engine is the
52
+ chain as an artifact: named, runnable (`start`/`stop`/`ctx`), and wireable
53
+ into a supervisor kernel under its new name — but no longer composable
54
+ (`use`/`bind` are gone from the type and throw at runtime). Its type is
55
+ exactly `Engine<TIn, TVisible, TName>`: the chain's external input
56
+ requirement survives, while the `bind`-encapsulated internals and the
57
+ chain's own name are erased. This is how N instances of one bot stack nest
58
+ into a supervisor without name tags or leaked internals.
51
59
 
52
60
  ## Usage
53
61
 
@@ -111,7 +119,8 @@ Types, grouped by theme:
111
119
  - **Streams** — `Stream`, `Channel`.
112
120
  - **Plugins** — `Plugin` (name, `Config`, `apply`, plus the composition
113
121
  methods), `PluginSpec` (the author-facing half), `Scope` (`onDispose` /
114
- `onError`), `Composite` (a composed chain — itself wireable), `AnyUnit`.
122
+ `onError`), `Composite` (a composed chain — itself wireable), `Engine`
123
+ (a chain sealed by `expose`: final, named, internals erased), `AnyUnit`.
115
124
  - **The composition types** — `InOf`, `OutOf`, `ConfigOf`, `NameOf`,
116
125
  `WireArgs` (the `use`/`bind` options: `mapping` required when identity
117
126
  wiring fails, `option` required when config is non-void, `as` to rename),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lambdot/core",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/Embers-of-the-Fire/lambdot",
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export type {
5
5
  AnyUnit,
6
6
  Composite,
7
7
  ConfigOf,
8
+ Engine,
8
9
  InOf,
9
10
  Kernel,
10
11
  NameOf,
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import type { Disposer, Scope } from "./index.ts";
5
+ import { createKernel, definePlugin } from "./index.ts";
6
+
7
+ function makeScope(disposers: Disposer[]): Scope {
8
+ return {
9
+ onDispose: (disposer) => {
10
+ disposers.push(disposer);
11
+ },
12
+ onError: () => {},
13
+ };
14
+ }
15
+
16
+ void test("engine.apply activates the chain and returns its visible context", async () => {
17
+ const greet = definePlugin<void, string>({
18
+ name: "greet",
19
+ apply: () => "hello",
20
+ });
21
+ const engine = createKernel().use(greet).expose("greeter");
22
+
23
+ const disposers: Disposer[] = [];
24
+ const output = await engine.apply(undefined, makeScope(disposers), undefined);
25
+ assert.deepEqual(output, { greet: "hello" });
26
+ assert.deepEqual(engine.ctx, { greet: "hello" });
27
+
28
+ // Applying the engine registered its teardown with the caller's scope.
29
+ for (const dispose of disposers.splice(0)) await dispose();
30
+ assert.deepEqual(engine.ctx, {});
31
+ });
32
+
33
+ void test("engine.apply feeds the external input through the chain", async () => {
34
+ const echo = definePlugin<{ value: string }, string>({
35
+ name: "echo",
36
+ apply: (input) => input.value.toUpperCase(),
37
+ });
38
+ const engine = createKernel()
39
+ .use(echo, { mapping: (ctx) => ctx as unknown as { value: string } })
40
+ .expose("upper");
41
+
42
+ const output = await engine.apply({ value: "hi" } as unknown as void, makeScope([]), undefined);
43
+ assert.deepEqual(output, { echo: "HI" });
44
+ });
package/src/kernel.ts CHANGED
@@ -134,6 +134,9 @@ class CompositeRuntime implements RuntimeUnit {
134
134
  this.onError = options.onError ?? defaultOnError;
135
135
  }
136
136
 
137
+ /** Set by `expose`: composing onto a sealed chain is a runtime error. */
138
+ private exposed: string | undefined;
139
+
137
140
  use(unit: RuntimeUnit, options?: WireOptions): this {
138
141
  return this.add(unit, options, true);
139
142
  }
@@ -142,7 +145,18 @@ class CompositeRuntime implements RuntimeUnit {
142
145
  return this.add(unit, options, false);
143
146
  }
144
147
 
148
+ /** Seal the chain and return it as a named engine — the final artifact. */
149
+ expose(name: string): EngineRuntime {
150
+ // Explicit undefined check: "" is a valid name and must still seal.
151
+ if (this.exposed !== undefined)
152
+ throw new Error(`kernel already exposed as engine "${this.exposed}"`);
153
+ this.exposed = name;
154
+ return new EngineRuntime(this, name);
155
+ }
156
+
145
157
  private add(unit: RuntimeUnit, options: WireOptions | undefined, visible: boolean): this {
158
+ if (this.exposed !== undefined)
159
+ throw new Error(`cannot compose onto a kernel exposed as engine "${this.exposed}"`);
146
160
  const entry: Entry = {
147
161
  unit,
148
162
  key: options?.as ?? unit.name,
@@ -207,6 +221,40 @@ class CompositeRuntime implements RuntimeUnit {
207
221
  }
208
222
  }
209
223
 
224
+ /**
225
+ * The runtime behind `Composite.expose`: a thin, sealed façade over the
226
+ * chain. Lifecycle delegates to the inner composition; as a nested unit it
227
+ * activates exactly like the chain itself — the only difference is the name
228
+ * and the erased type.
229
+ */
230
+ class EngineRuntime implements RuntimeUnit {
231
+ constructor(
232
+ private readonly inner: CompositeRuntime,
233
+ readonly name: string,
234
+ ) {}
235
+
236
+ /** Public contract: apply the engine directly, like any other unit. */
237
+ apply(input: unknown, scope: Scope, config: unknown): Promise<unknown> {
238
+ return this.activate(input, scope, config);
239
+ }
240
+
241
+ activate(input: unknown, scope: Scope, _config: unknown): Promise<unknown> {
242
+ return this.inner.activate(input, scope, undefined);
243
+ }
244
+
245
+ start(input?: unknown): Promise<void> {
246
+ return this.inner.start(input);
247
+ }
248
+
249
+ stop(): Promise<void> {
250
+ return this.inner.stop();
251
+ }
252
+
253
+ get ctx(): Record<string, unknown> {
254
+ return this.inner.ctx;
255
+ }
256
+ }
257
+
210
258
  /**
211
259
  * Author a plugin. The spec is just `name`, optional `Config` schema, and
212
260
  * `apply`; the returned plugin carries the composition methods (`use`,
package/src/plugin.ts CHANGED
@@ -73,12 +73,42 @@ export interface Composite<TIn = void, TVisible = {}, THidden = {}, TName extend
73
73
  ...args: WireArgs<TUnit, InputPart<TIn> & TVisible & THidden, TAs>
74
74
  ): Composite<TIn, TVisible, THidden & { [K in TAs]: OutOf<TUnit> }, TName>;
75
75
 
76
+ /**
77
+ * Seal the chain into a final artifact under a new name. The engine's
78
+ * type is exactly `Engine<TIn, TVisible, TAlias>`: the external input
79
+ * requirement is preserved, the `bind`-encapsulated internals
80
+ * (`THidden`) and the chain's own name are erased. Composing onto an
81
+ * exposed chain throws at runtime.
82
+ */
83
+ expose<const TAlias extends string>(name: TAlias): Engine<TIn, TVisible, TAlias>;
84
+
76
85
  start(...args: StartArgs<TIn>): Promise<void>;
77
86
  stop(): Promise<void>;
78
87
  readonly ctx: TVisible;
79
88
  }
80
89
 
81
- export type AnyUnit = Plugin<any, any, any, any> | Composite<any, any, any, any>;
90
+ /**
91
+ * A sealed composition — what {@link Composite.expose} returns. Structurally
92
+ * a unit (`name` + `apply`), so it wires into a supervisor kernel like any
93
+ * plugin, but the composition methods are gone: an engine is final. Its
94
+ * output is the chain's visible context; its input is the chain's external
95
+ * requirement. Internal (`bind`ed) namespaces exist only at runtime, never
96
+ * in the type.
97
+ */
98
+ export interface Engine<TIn = void, TOut = {}, TName extends string = string> {
99
+ readonly name: TName;
100
+ apply(input: TIn, scope: Scope, config: void): Promise<TOut>;
101
+
102
+ start(...args: StartArgs<TIn>): Promise<void>;
103
+ stop(): Promise<void>;
104
+ /** The exposed namespaces. Populated during `start`; typed regardless. */
105
+ readonly ctx: TOut;
106
+ }
107
+
108
+ export type AnyUnit =
109
+ | Plugin<any, any, any, any>
110
+ | Composite<any, any, any, any>
111
+ | Engine<any, any, any>;
82
112
 
83
113
  /** The kernel is a composition seeded empty: `createKernel().use(...)`. */
84
114
  export type Kernel<TVisible = {}, THidden = {}> = Composite<void, TVisible, THidden, "kernel">;