@lambdot/core 0.1.0 → 0.1.1
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 +6 -0
- package/README.md +135 -0
- package/package.json +15 -10
- package/src/effect.ts +2 -33
- package/src/index.ts +26 -36
- package/src/kernel.ts +170 -249
- package/src/message.ts +37 -0
- package/src/plugin.ts +138 -186
- package/src/state.ts +4 -23
- package/src/stream.ts +281 -0
- package/src/context.ts +0 -120
- package/src/events.ts +0 -147
- package/src/fiber.ts +0 -41
package/src/kernel.ts
CHANGED
|
@@ -1,306 +1,227 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type {
|
|
3
|
-
import { collectEffect, type Disposer } from "./effect.ts";
|
|
4
|
-
import { EventBus, INGRESS, type BotEvent, type OnOptions } from "./events.ts";
|
|
5
|
-
import type { EventMap } from "./events.ts";
|
|
6
|
-
import { Fiber } from "./fiber.ts";
|
|
7
|
-
import type {
|
|
8
|
-
AnyPlugin,
|
|
9
|
-
CapsOf,
|
|
10
|
-
ConfigOf,
|
|
11
|
-
EventsOf,
|
|
12
|
-
OutputsOf,
|
|
13
|
-
Spread,
|
|
14
|
-
StateOf,
|
|
15
|
-
Validate,
|
|
16
|
-
} from "./plugin.ts";
|
|
1
|
+
import { runDisposers, type Disposer } from "./effect.ts";
|
|
2
|
+
import type { Composite, Plugin, PluginSpec, Scope } from "./plugin.ts";
|
|
17
3
|
import { validateConfig } from "./schema.ts";
|
|
18
|
-
import { createStateAccessor, type StateBackend } from "./state.ts";
|
|
19
4
|
|
|
20
5
|
export interface KernelOptions {
|
|
21
|
-
/** Sink for errors
|
|
6
|
+
/** Sink for errors reported through `scope.onError` and background activations. */
|
|
22
7
|
onError?: (error: unknown) => void;
|
|
23
8
|
}
|
|
24
9
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
readonly bus: EventBus;
|
|
31
|
-
private readonly outputs = new Map<
|
|
32
|
-
string,
|
|
33
|
-
{ send(to: never, content: never): void | Promise<void> }
|
|
34
|
-
>();
|
|
35
|
-
private readonly provided = new Map<string, unknown>();
|
|
36
|
-
/** Events are processed sequentially, in ingestion order. */
|
|
37
|
-
private queue: Promise<void> = Promise.resolve();
|
|
38
|
-
onProvideChange: (name: string, available: boolean) => void = () => {};
|
|
10
|
+
/** Runtime view of anything wireable: a leaf plugin or a nested composition. */
|
|
11
|
+
interface RuntimeUnit {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
activate(input: unknown, scope: Scope, config: unknown): Promise<unknown>;
|
|
14
|
+
}
|
|
39
15
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
16
|
+
interface Entry {
|
|
17
|
+
readonly unit: RuntimeUnit;
|
|
18
|
+
/** The namespace key the unit's output is stored under (`as` ?? name). */
|
|
19
|
+
readonly key: string;
|
|
20
|
+
readonly visible: boolean;
|
|
21
|
+
readonly mapping: ((ctx: Record<string, unknown>) => unknown) | undefined;
|
|
22
|
+
readonly config: unknown;
|
|
23
|
+
}
|
|
43
24
|
|
|
44
|
-
|
|
45
|
-
return this.bus.on(kind, listener, options);
|
|
46
|
-
}
|
|
25
|
+
const defaultOnError = (error: unknown): void => console.error("[lambdot]", error);
|
|
47
26
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
27
|
+
function makeScope(disposers: Disposer[], onError: (error: unknown) => void): Scope {
|
|
28
|
+
return {
|
|
29
|
+
onDispose: (disposer) => {
|
|
30
|
+
disposers.push(disposer);
|
|
31
|
+
},
|
|
32
|
+
onError,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
51
35
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
36
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
37
|
+
return typeof value === "object" && value !== null;
|
|
38
|
+
}
|
|
55
39
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
40
|
+
/**
|
|
41
|
+
* A leaf plugin at runtime. The composition methods seed an artifact chain
|
|
42
|
+
* with the plugin itself as the first entry, so a lone plugin is runnable.
|
|
43
|
+
*/
|
|
44
|
+
class PluginRuntime implements RuntimeUnit {
|
|
45
|
+
private artifact: CompositeRuntime | undefined;
|
|
59
46
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
event: unknown,
|
|
63
|
-
inner: (event: any) => Promise<unknown>,
|
|
64
|
-
): Promise<unknown> {
|
|
65
|
-
return this.bus.waterfall(kind, event, inner);
|
|
47
|
+
constructor(private readonly spec: PluginSpec<any, any, any, string>) {
|
|
48
|
+
this.name = spec.name;
|
|
66
49
|
}
|
|
67
50
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
51
|
+
readonly name: string;
|
|
52
|
+
|
|
53
|
+
get Config(): PluginSpec<any, any, any, string>["Config"] {
|
|
54
|
+
return this.spec.Config;
|
|
72
55
|
}
|
|
73
56
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
output: { send(to: never, content: never): void | Promise<void> },
|
|
77
|
-
): Disposer {
|
|
78
|
-
if (this.outputs.has(platform))
|
|
79
|
-
throw new Error(`duplicate output for platform "${platform}"`);
|
|
80
|
-
this.outputs.set(platform, output);
|
|
81
|
-
return () => {
|
|
82
|
-
this.outputs.delete(platform);
|
|
83
|
-
};
|
|
57
|
+
apply(input: unknown, scope: Scope, config: unknown): Promise<unknown> {
|
|
58
|
+
return this.activate(input, scope, config);
|
|
84
59
|
}
|
|
85
60
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (!backend)
|
|
93
|
-
throw new Error(
|
|
94
|
-
'no state backend active — register a state plugin and add `inject: ["state"]`',
|
|
95
|
-
);
|
|
96
|
-
return createStateAccessor(backend, plugin);
|
|
97
|
-
},
|
|
98
|
-
};
|
|
61
|
+
async activate(input: unknown, scope: Scope, config: unknown): Promise<unknown> {
|
|
62
|
+
const validated = this.spec.Config
|
|
63
|
+
? await validateConfig(this.spec.name, this.spec.Config, config)
|
|
64
|
+
: config;
|
|
65
|
+
return await this.spec.apply(input, scope, validated);
|
|
66
|
+
}
|
|
99
67
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
this.provided.set(name, value);
|
|
103
|
-
const defined = value !== undefined && !(name in this);
|
|
104
|
-
if (defined) {
|
|
105
|
-
Object.defineProperty(this, name, { value, configurable: true });
|
|
106
|
-
}
|
|
107
|
-
this.onProvideChange(name, true);
|
|
108
|
-
return () => {
|
|
109
|
-
this.provided.delete(name);
|
|
110
|
-
if (defined) {
|
|
111
|
-
delete (this as Record<string, unknown>)[name];
|
|
112
|
-
}
|
|
113
|
-
this.onProvideChange(name, false);
|
|
114
|
-
};
|
|
68
|
+
use(unit: RuntimeUnit, options?: WireOptions): CompositeRuntime {
|
|
69
|
+
return this.asArtifact().use(unit, options);
|
|
115
70
|
}
|
|
116
71
|
|
|
117
|
-
|
|
118
|
-
return this.
|
|
72
|
+
bind(unit: RuntimeUnit, options?: WireOptions): CompositeRuntime {
|
|
73
|
+
return this.asArtifact().bind(unit, options);
|
|
119
74
|
}
|
|
120
75
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const run = this.queue.then(() => this.process(event));
|
|
124
|
-
// A failing event rejects its own caller but never jams the queue.
|
|
125
|
-
this.queue = run.catch(this.onError);
|
|
126
|
-
return run;
|
|
76
|
+
start(input?: unknown): Promise<void> {
|
|
77
|
+
return this.asArtifact().start(input);
|
|
127
78
|
}
|
|
128
79
|
|
|
129
|
-
|
|
130
|
-
await this.
|
|
131
|
-
|
|
132
|
-
|
|
80
|
+
async stop(): Promise<void> {
|
|
81
|
+
await this.artifact?.stop();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get ctx(): Record<string, unknown> {
|
|
85
|
+
return this.asArtifact().ctx;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private asArtifact(): CompositeRuntime {
|
|
89
|
+
if (!this.artifact) {
|
|
90
|
+
this.artifact = new CompositeRuntime(
|
|
91
|
+
[
|
|
92
|
+
{
|
|
93
|
+
unit: this,
|
|
94
|
+
key: this.spec.name,
|
|
95
|
+
visible: true,
|
|
96
|
+
mapping: undefined,
|
|
97
|
+
config: undefined,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
{},
|
|
101
|
+
this.spec.name,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return this.artifact;
|
|
133
105
|
}
|
|
134
106
|
}
|
|
135
107
|
|
|
136
|
-
interface
|
|
137
|
-
readonly
|
|
138
|
-
readonly
|
|
139
|
-
readonly
|
|
108
|
+
interface WireOptions {
|
|
109
|
+
readonly mapping?: (ctx: Record<string, unknown>) => unknown;
|
|
110
|
+
readonly option?: unknown;
|
|
111
|
+
readonly as?: string;
|
|
140
112
|
}
|
|
141
113
|
|
|
142
114
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
115
|
+
* A composition at runtime. Inert while built: `start` activates the entries
|
|
116
|
+
* in composition order — resolve the mapping (or identity-wire the visible
|
|
117
|
+
* context), activate the unit, stash its output under its namespace key —
|
|
118
|
+
* and `stop` disposes in reverse. Activation order is definition order;
|
|
119
|
+
* ordering mistakes are compile errors in the mappings, not runtime states.
|
|
147
120
|
*/
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
> {
|
|
154
|
-
/** The typed context. This is the fold of everything registered so far. */
|
|
155
|
-
readonly ctx: ContextView<TEvents, TOutputs, TState, TCaps> & TCaps;
|
|
156
|
-
|
|
157
|
-
private readonly runtime: RuntimeContext;
|
|
158
|
-
private readonly entries: FiberEntry[] = [];
|
|
159
|
-
private readonly onError: (error: unknown) => void;
|
|
160
|
-
private readonly inflightActivations = new Set<Promise<void>>();
|
|
121
|
+
class CompositeRuntime implements RuntimeUnit {
|
|
122
|
+
readonly ctx: Record<string, unknown> = {};
|
|
123
|
+
private readonly hidden: Record<string, unknown> = {};
|
|
124
|
+
private readonly activated: { entry: Entry; disposers: Disposer[] }[] = [];
|
|
125
|
+
private inputRecord: Record<string, unknown> = {};
|
|
161
126
|
private started = false;
|
|
127
|
+
private readonly onError: (error: unknown) => void;
|
|
162
128
|
|
|
163
|
-
constructor(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
};
|
|
170
|
-
this.ctx = this.runtime as unknown as ContextView<TEvents, TOutputs, TState, TCaps> & TCaps;
|
|
129
|
+
constructor(
|
|
130
|
+
private readonly entries: Entry[],
|
|
131
|
+
options: KernelOptions,
|
|
132
|
+
readonly name: string,
|
|
133
|
+
) {
|
|
134
|
+
this.onError = options.onError ?? defaultOnError;
|
|
171
135
|
}
|
|
172
136
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
* handles, every output platform it sends through, and every typed
|
|
176
|
-
* capability it injects must already be in the fold.
|
|
177
|
-
*/
|
|
178
|
-
use<TPlugin extends AnyPlugin>(
|
|
179
|
-
plugin: TPlugin & Validate<TPlugin, TEvents, TOutputs, TCaps>,
|
|
180
|
-
...config: Spread<ConfigOf<TPlugin>>
|
|
181
|
-
): Kernel<
|
|
182
|
-
TEvents & EventsOf<TPlugin>,
|
|
183
|
-
TOutputs & OutputsOf<TPlugin>,
|
|
184
|
-
TCaps & CapsOf<TPlugin>,
|
|
185
|
-
TState & StateOf<TPlugin>
|
|
186
|
-
> {
|
|
187
|
-
const entry: FiberEntry = {
|
|
188
|
-
plugin,
|
|
189
|
-
config: config[0],
|
|
190
|
-
fiber: new Fiber(plugin, config[0]),
|
|
191
|
-
};
|
|
192
|
-
this.entries.push(entry);
|
|
193
|
-
if (this.started) void this.activateEligible().catch(this.onError);
|
|
194
|
-
return this as unknown as Kernel<
|
|
195
|
-
TEvents & EventsOf<TPlugin>,
|
|
196
|
-
TOutputs & OutputsOf<TPlugin>,
|
|
197
|
-
TCaps & CapsOf<TPlugin>,
|
|
198
|
-
TState & StateOf<TPlugin>
|
|
199
|
-
>;
|
|
137
|
+
use(unit: RuntimeUnit, options?: WireOptions): this {
|
|
138
|
+
return this.add(unit, options, true);
|
|
200
139
|
}
|
|
201
140
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (this.started) return;
|
|
205
|
-
this.started = true;
|
|
206
|
-
await this.activateEligible();
|
|
207
|
-
const stuck = this.entries
|
|
208
|
-
.filter((entry) => entry.fiber.state === "pending")
|
|
209
|
-
.map((entry) => entry.plugin.name);
|
|
210
|
-
if (stuck.length > 0)
|
|
211
|
-
console.warn(`[lambdot] plugins pending on missing capabilities: ${stuck.join(", ")}`);
|
|
141
|
+
bind(unit: RuntimeUnit, options?: WireOptions): this {
|
|
142
|
+
return this.add(unit, options, false);
|
|
212
143
|
}
|
|
213
144
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
145
|
+
private add(unit: RuntimeUnit, options: WireOptions | undefined, visible: boolean): this {
|
|
146
|
+
const entry: Entry = {
|
|
147
|
+
unit,
|
|
148
|
+
key: options?.as ?? unit.name,
|
|
149
|
+
visible,
|
|
150
|
+
mapping: options?.mapping,
|
|
151
|
+
config: options?.option,
|
|
152
|
+
};
|
|
153
|
+
this.entries.push(entry);
|
|
154
|
+
// Composing onto a running artifact activates in the background —
|
|
155
|
+
// same fire-and-report semantics as composition before `start`.
|
|
156
|
+
if (this.started) void this.activateEntry(entry).catch(this.onError);
|
|
157
|
+
return this;
|
|
220
158
|
}
|
|
221
159
|
|
|
222
|
-
|
|
223
|
-
|
|
160
|
+
/** RuntimeUnit: run as a nested unit, torn down with the parent. */
|
|
161
|
+
async activate(input: unknown, scope: Scope, _config: unknown): Promise<unknown> {
|
|
162
|
+
scope.onDispose(() => this.stop());
|
|
163
|
+
await this.start(input);
|
|
164
|
+
return this.ctx;
|
|
224
165
|
}
|
|
225
166
|
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
167
|
+
async start(input?: unknown): Promise<void> {
|
|
168
|
+
if (this.started) return;
|
|
169
|
+
this.started = true;
|
|
170
|
+
this.inputRecord = isRecord(input) ? input : {};
|
|
171
|
+
try {
|
|
231
172
|
for (const entry of this.entries) {
|
|
232
|
-
if (
|
|
233
|
-
await this.
|
|
234
|
-
progressed = true;
|
|
173
|
+
if (this.activated.some((done) => done.entry === entry)) continue;
|
|
174
|
+
await this.activateEntry(entry);
|
|
235
175
|
}
|
|
236
|
-
}
|
|
237
|
-
// Nested passes (triggered by `provide` during activation) may still
|
|
238
|
-
// have activations in flight; wait for quiescence so `start()` and
|
|
239
|
-
// `stop()` only observe settled fibers.
|
|
240
|
-
while (this.inflightActivations.size > 0) {
|
|
241
|
-
await Promise.allSettled(this.inflightActivations);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
private activate(entry: FiberEntry): Promise<void> {
|
|
246
|
-
// Mark the fiber synchronously so a nested `activateEligible` pass
|
|
247
|
-
// cannot pick it up again while activation is in flight.
|
|
248
|
-
entry.fiber.state = "activating";
|
|
249
|
-
const activation = this.completeActivation(entry);
|
|
250
|
-
this.inflightActivations.add(activation);
|
|
251
|
-
const settled = () => this.inflightActivations.delete(activation);
|
|
252
|
-
activation.then(settled, settled);
|
|
253
|
-
return activation;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
private async completeActivation(entry: FiberEntry): Promise<void> {
|
|
257
|
-
const { plugin, fiber } = entry;
|
|
258
|
-
let config = entry.config;
|
|
259
|
-
try {
|
|
260
|
-
if (plugin.Config) config = await validateConfig(plugin.name, plugin.Config, config);
|
|
261
176
|
} catch (error) {
|
|
262
|
-
|
|
177
|
+
await this.stop();
|
|
263
178
|
throw error;
|
|
264
179
|
}
|
|
265
|
-
|
|
266
|
-
// withdrawn, while we awaited; mid-activation fibers are invisible to
|
|
267
|
-
// `deactivateDependents` and `stop`.
|
|
268
|
-
if (!this.started || !this.eligible(plugin)) {
|
|
269
|
-
fiber.state = "pending";
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Role-specific registration happens before `apply` so plugin code
|
|
274
|
-
// can rely on its own capability being live.
|
|
275
|
-
if (plugin.role === "output") {
|
|
276
|
-
fiber.state = "active";
|
|
277
|
-
fiber.addDisposer(this.runtime.registerOutput(plugin.platform, plugin));
|
|
278
|
-
} else {
|
|
279
|
-
fiber.state = "active";
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if ("apply" in plugin && plugin.apply) {
|
|
283
|
-
await collectEffect(plugin.apply(this.runtime as never, config as never), (disposer) =>
|
|
284
|
-
fiber.addDisposer(disposer),
|
|
285
|
-
);
|
|
286
|
-
}
|
|
180
|
+
}
|
|
287
181
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
182
|
+
async stop(): Promise<void> {
|
|
183
|
+
this.started = false;
|
|
184
|
+
for (const { entry, disposers } of this.activated.splice(0).reverse()) {
|
|
185
|
+
await runDisposers(disposers, this.onError);
|
|
186
|
+
if (entry.visible) delete this.ctx[entry.key];
|
|
187
|
+
else delete this.hidden[entry.key];
|
|
291
188
|
}
|
|
292
189
|
}
|
|
293
190
|
|
|
294
|
-
private async
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
191
|
+
private async activateEntry(entry: Entry): Promise<void> {
|
|
192
|
+
if (entry.key in this.ctx || entry.key in this.hidden)
|
|
193
|
+
throw new Error(`duplicate namespace "${entry.key}"`);
|
|
194
|
+
const disposers: Disposer[] = [];
|
|
195
|
+
// Identity wiring hands over the whole visible context; the unit's
|
|
196
|
+
// declared input type narrows the view at compile time.
|
|
197
|
+
const wireCtx = { ...this.inputRecord, ...this.hidden, ...this.ctx };
|
|
198
|
+
const input = entry.mapping ? entry.mapping(wireCtx) : wireCtx;
|
|
199
|
+
const output = await entry.unit.activate(
|
|
200
|
+
input,
|
|
201
|
+
makeScope(disposers, this.onError),
|
|
202
|
+
entry.config,
|
|
203
|
+
);
|
|
204
|
+
if (entry.visible) this.ctx[entry.key] = output;
|
|
205
|
+
else this.hidden[entry.key] = output;
|
|
206
|
+
this.activated.push({ entry, disposers });
|
|
300
207
|
}
|
|
301
208
|
}
|
|
302
209
|
|
|
303
|
-
/**
|
|
304
|
-
|
|
305
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Author a plugin. The spec is just `name`, optional `Config` schema, and
|
|
212
|
+
* `apply`; the returned plugin carries the composition methods (`use`,
|
|
213
|
+
* `bind`, `start`, `stop`, `ctx`).
|
|
214
|
+
*/
|
|
215
|
+
export function definePlugin<
|
|
216
|
+
TIn = void,
|
|
217
|
+
TOut = void,
|
|
218
|
+
TConfig = void,
|
|
219
|
+
TName extends string = string,
|
|
220
|
+
>(spec: PluginSpec<TIn, TOut, TConfig, TName>): Plugin<TIn, TOut, TConfig, TName> {
|
|
221
|
+
return new PluginRuntime(spec) as never;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Create an empty composition. Wire plugins in with `.use(...)` / `.bind(...)`. */
|
|
225
|
+
export function createKernel(options?: KernelOptions): Composite<void, {}, {}, "kernel"> {
|
|
226
|
+
return new CompositeRuntime([], options ?? {}, "kernel") as never;
|
|
306
227
|
}
|
package/src/message.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The envelope flowing through streams: a payload plus its return address.
|
|
3
|
+
* The core never inspects the address beyond the `platform` routing tag.
|
|
4
|
+
*/
|
|
5
|
+
export interface Address<TPlatform extends string = string> {
|
|
6
|
+
readonly platform: TPlatform;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One inbound event. `payload` and `address` are platform-defined; `id` and
|
|
11
|
+
* `at` are minted by the producing input (see {@link message}).
|
|
12
|
+
*/
|
|
13
|
+
export interface Message<TPayload = unknown, TAddress = unknown> {
|
|
14
|
+
readonly payload: TPayload;
|
|
15
|
+
readonly address: TAddress;
|
|
16
|
+
/** Unique id for dedup and tracing. */
|
|
17
|
+
readonly id: string;
|
|
18
|
+
/** Unix epoch milliseconds. */
|
|
19
|
+
readonly at: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Mint a message envelope with a fresh id and timestamp. */
|
|
23
|
+
export function message<TPayload, TAddress>(
|
|
24
|
+
payload: TPayload,
|
|
25
|
+
address: TAddress,
|
|
26
|
+
): Message<TPayload, TAddress> {
|
|
27
|
+
return { payload, address, id: crypto.randomUUID(), at: Date.now() };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One outbound reply: content addressed back through the platform that owns
|
|
32
|
+
* the address. Features emit streams of these; output plugins consume them.
|
|
33
|
+
*/
|
|
34
|
+
export interface Command<TAddress = unknown, TContent = unknown> {
|
|
35
|
+
readonly address: TAddress;
|
|
36
|
+
readonly content: TContent;
|
|
37
|
+
}
|