station-broadcast 1.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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapters/index.d.ts +20 -0
  3. package/dist/adapters/index.d.ts.map +1 -0
  4. package/dist/adapters/index.js +2 -0
  5. package/dist/adapters/index.js.map +1 -0
  6. package/dist/adapters/memory.d.ts +22 -0
  7. package/dist/adapters/memory.d.ts.map +1 -0
  8. package/dist/adapters/memory.js +108 -0
  9. package/dist/adapters/memory.js.map +1 -0
  10. package/dist/broadcast-runner.d.ts +78 -0
  11. package/dist/broadcast-runner.d.ts.map +1 -0
  12. package/dist/broadcast-runner.js +659 -0
  13. package/dist/broadcast-runner.js.map +1 -0
  14. package/dist/broadcast.d.ts +87 -0
  15. package/dist/broadcast.d.ts.map +1 -0
  16. package/dist/broadcast.js +209 -0
  17. package/dist/broadcast.js.map +1 -0
  18. package/dist/config.d.ts +7 -0
  19. package/dist/config.d.ts.map +1 -0
  20. package/dist/config.js +23 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/errors.d.ts +10 -0
  23. package/dist/errors.d.ts.map +1 -0
  24. package/dist/errors.js +17 -0
  25. package/dist/errors.js.map +1 -0
  26. package/dist/index.d.ts +9 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +8 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/subscribers/console.d.ts +44 -0
  31. package/dist/subscribers/console.d.ts.map +1 -0
  32. package/dist/subscribers/console.js +34 -0
  33. package/dist/subscribers/console.js.map +1 -0
  34. package/dist/subscribers/index.d.ts +53 -0
  35. package/dist/subscribers/index.d.ts.map +1 -0
  36. package/dist/subscribers/index.js +2 -0
  37. package/dist/subscribers/index.js.map +1 -0
  38. package/dist/types.d.ts +42 -0
  39. package/dist/types.d.ts.map +1 -0
  40. package/dist/types.js +2 -0
  41. package/dist/types.js.map +1 -0
  42. package/dist/util.d.ts +10 -0
  43. package/dist/util.d.ts.map +1 -0
  44. package/dist/util.js +42 -0
  45. package/dist/util.js.map +1 -0
  46. package/package.json +33 -0
  47. package/src/adapters/index.ts +32 -0
  48. package/src/adapters/memory.ts +131 -0
  49. package/src/broadcast-runner.ts +747 -0
  50. package/src/broadcast.ts +293 -0
  51. package/src/config.ts +31 -0
  52. package/src/errors.ts +19 -0
  53. package/src/index.ts +31 -0
  54. package/src/subscribers/console.ts +66 -0
  55. package/src/subscribers/index.ts +35 -0
  56. package/src/types.ts +47 -0
  57. package/src/util.ts +49 -0
@@ -0,0 +1,293 @@
1
+ import type { AnySignal, Signal } from "station-signal";
2
+ import { isSignal, parseInterval, getTriggerAdapter } from "station-signal";
3
+ import { getBroadcastAdapter } from "./config.js";
4
+ import { BroadcastValidationError } from "./errors.js";
5
+ import type { BroadcastRun, FailurePolicy } from "./types.js";
6
+ import { BROADCAST_BRAND, topologicalSort } from "./util.js";
7
+
8
+ /** A named node in the broadcast DAG. */
9
+ export interface BroadcastNode {
10
+ readonly name: string;
11
+ readonly signalName: string;
12
+ /** Reference to the actual Signal object — used for input validation via signal.trigger(). */
13
+ readonly signal: AnySignal;
14
+ readonly dependsOn: readonly string[];
15
+ readonly timeout: number;
16
+ readonly maxAttempts: number;
17
+ readonly map?: (upstream: Record<string, unknown>) => unknown;
18
+ readonly when?: (upstream: Record<string, unknown>) => boolean;
19
+ }
20
+
21
+ export interface BroadcastDefinition {
22
+ readonly [BROADCAST_BRAND]: true;
23
+ readonly name: string;
24
+ readonly nodes: readonly BroadcastNode[];
25
+ readonly failurePolicy: FailurePolicy;
26
+ /** Max time (ms) the entire broadcast may run before being auto-failed. */
27
+ readonly timeout?: number;
28
+ readonly interval?: string;
29
+ readonly recurringInput?: unknown;
30
+ trigger(input: unknown): Promise<string>;
31
+ }
32
+
33
+ export interface ThenOptions {
34
+ /** Node label (defaults to signal name). */
35
+ as?: string;
36
+ /** Explicit upstream dependencies (defaults to all nodes in the previous tier). */
37
+ after?: string[];
38
+ /** Transform upstream outputs into this node's input. */
39
+ map?: (upstream: Record<string, unknown>) => unknown;
40
+ /** Conditional guard — skip this node if the predicate returns false. */
41
+ when?: (upstream: Record<string, unknown>) => boolean;
42
+ }
43
+
44
+ export class BroadcastChain<TInput> {
45
+ /** @internal */
46
+ readonly _name: string;
47
+ /** @internal */
48
+ readonly _nodes: BroadcastNode[];
49
+ /** @internal */
50
+ readonly _lastTier: string[];
51
+ /** @internal */
52
+ readonly _failurePolicy: FailurePolicy;
53
+ /** @internal */
54
+ readonly _timeout?: number;
55
+ /** @internal */
56
+ readonly _interval?: string;
57
+ /** @internal */
58
+ readonly _recurringInput?: unknown;
59
+
60
+ /** @internal */
61
+ constructor(opts: {
62
+ name: string;
63
+ nodes: BroadcastNode[];
64
+ lastTier: string[];
65
+ failurePolicy: FailurePolicy;
66
+ timeout?: number;
67
+ interval?: string;
68
+ recurringInput?: unknown;
69
+ }) {
70
+ this._name = opts.name;
71
+ this._nodes = opts.nodes;
72
+ this._lastTier = opts.lastTier;
73
+ this._failurePolicy = opts.failurePolicy;
74
+ this._timeout = opts.timeout;
75
+ this._interval = opts.interval;
76
+ this._recurringInput = opts.recurringInput;
77
+ }
78
+
79
+ /** @internal */
80
+ private _clone(overrides: Partial<ConstructorParameters<typeof BroadcastChain>[0]> = {}): BroadcastChain<TInput> {
81
+ return new BroadcastChain<TInput>({
82
+ name: this._name,
83
+ nodes: this._nodes,
84
+ lastTier: this._lastTier,
85
+ failurePolicy: this._failurePolicy,
86
+ timeout: this._timeout,
87
+ interval: this._interval,
88
+ recurringInput: this._recurringInput,
89
+ ...overrides,
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Add one or more signals to the DAG.
95
+ *
96
+ * - `.then(signal)` — runs after the previous tier, pass-through data
97
+ * - `.then(signal, { as, after, map, when })` — single signal with options
98
+ * - `.then(signal1, signal2, ...)` — fan-out: all run in parallel after previous tier
99
+ */
100
+ then(...args: (AnySignal | ThenOptions)[]): BroadcastChain<TInput> {
101
+ const signals: AnySignal[] = [];
102
+ let options: ThenOptions | undefined;
103
+
104
+ for (const arg of args) {
105
+ if (isSignal(arg)) {
106
+ signals.push(arg);
107
+ } else if (typeof arg === "object" && arg !== null) {
108
+ options = arg as ThenOptions;
109
+ }
110
+ }
111
+
112
+ if (signals.length === 0) {
113
+ throw new BroadcastValidationError(
114
+ `then() requires at least one signal in broadcast "${this._name}".`,
115
+ );
116
+ }
117
+
118
+ // Single signal with options
119
+ if (signals.length === 1) {
120
+ const sig = signals[0];
121
+ const nodeName = options?.as ?? sig.name;
122
+ const deps = options?.after ? [...options.after] : [...this._lastTier];
123
+
124
+ const node: BroadcastNode = {
125
+ name: nodeName,
126
+ signalName: sig.name,
127
+ signal: sig,
128
+ dependsOn: deps,
129
+ timeout: sig.timeout,
130
+ maxAttempts: sig.maxAttempts,
131
+ map: options?.map,
132
+ when: options?.when,
133
+ };
134
+
135
+ return this._clone({ nodes: [...this._nodes, node], lastTier: [nodeName] });
136
+ }
137
+
138
+ // Fan-out with options is ambiguous — throw an error
139
+ if (options) {
140
+ throw new BroadcastValidationError(
141
+ `Options (as, after, map, when) cannot be used with fan-out (multiple signals) in broadcast "${this._name}". ` +
142
+ `Use separate .then() calls with options for each signal instead.`,
143
+ );
144
+ }
145
+
146
+ // Fan-out: multiple signals, all depend on the last tier
147
+ const newNodes: BroadcastNode[] = [];
148
+ const newTier: string[] = [];
149
+
150
+ for (const sig of signals) {
151
+ newNodes.push({
152
+ name: sig.name,
153
+ signalName: sig.name,
154
+ signal: sig,
155
+ dependsOn: [...this._lastTier],
156
+ timeout: sig.timeout,
157
+ maxAttempts: sig.maxAttempts,
158
+ });
159
+ newTier.push(sig.name);
160
+ }
161
+
162
+ return this._clone({ nodes: [...this._nodes, ...newNodes], lastTier: newTier });
163
+ }
164
+
165
+ /** Set a broadcast-level timeout (ms). Auto-fails the broadcast if exceeded. */
166
+ timeout(ms: number): BroadcastChain<TInput> {
167
+ return this._clone({ timeout: ms });
168
+ }
169
+
170
+ every(interval: string): BroadcastChain<TInput> {
171
+ parseInterval(interval);
172
+ return this._clone({ interval });
173
+ }
174
+
175
+ withInput(input: TInput): BroadcastChain<TInput> {
176
+ return this._clone({ recurringInput: input });
177
+ }
178
+
179
+ onFailure(policy: FailurePolicy): BroadcastChain<TInput> {
180
+ return this._clone({ failurePolicy: policy });
181
+ }
182
+
183
+ build(): BroadcastDefinition {
184
+ this.validate();
185
+
186
+ const name = this._name;
187
+ const nodes = [...this._nodes];
188
+ const failurePolicy = this._failurePolicy;
189
+ const timeout = this._timeout;
190
+ const interval = this._interval;
191
+ const recurringInput = this._recurringInput;
192
+
193
+ return {
194
+ [BROADCAST_BRAND]: true as const,
195
+ name,
196
+ nodes,
197
+ failurePolicy,
198
+ timeout,
199
+ interval,
200
+ recurringInput,
201
+ async trigger(input: unknown): Promise<string> {
202
+ // Remote trigger path
203
+ const triggerAdapter = getTriggerAdapter();
204
+ if (triggerAdapter?.triggerBroadcast) {
205
+ return triggerAdapter.triggerBroadcast(name, input);
206
+ }
207
+
208
+ // Local trigger path
209
+ const adapter = getBroadcastAdapter();
210
+ const id = adapter.generateId();
211
+ const run: BroadcastRun = {
212
+ id,
213
+ broadcastName: name,
214
+ input: JSON.stringify(input),
215
+ status: "pending",
216
+ failurePolicy,
217
+ timeout,
218
+ createdAt: new Date(),
219
+ };
220
+ await adapter.addBroadcastRun(run);
221
+ return id;
222
+ },
223
+ };
224
+ }
225
+
226
+ private validate(): void {
227
+ const names = new Set<string>();
228
+
229
+ // Check for duplicate node names
230
+ for (const node of this._nodes) {
231
+ if (names.has(node.name)) {
232
+ throw new BroadcastValidationError(
233
+ `Duplicate node name "${node.name}" in broadcast "${this._name}". ` +
234
+ `Use the "as" option to disambiguate.`,
235
+ );
236
+ }
237
+ names.add(node.name);
238
+ }
239
+
240
+ // Check for missing dependencies
241
+ for (const node of this._nodes) {
242
+ for (const dep of node.dependsOn) {
243
+ if (!names.has(dep)) {
244
+ throw new BroadcastValidationError(
245
+ `Node "${node.name}" depends on "${dep}", but no node named "${dep}" ` +
246
+ `exists in broadcast "${this._name}".`,
247
+ );
248
+ }
249
+ }
250
+ }
251
+
252
+ // Cycle detection via topological sort (throws BroadcastCycleError)
253
+ topologicalSort(this._name, this._nodes);
254
+ }
255
+ }
256
+
257
+ const VALID_NAME = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
258
+
259
+ export class BroadcastBuilder {
260
+ private _name: string;
261
+
262
+ constructor(name: string) {
263
+ if (!VALID_NAME.test(name)) {
264
+ throw new Error(
265
+ `Invalid broadcast name "${name}". Names must start with a letter and contain only letters, digits, hyphens, and underscores.`,
266
+ );
267
+ }
268
+ this._name = name;
269
+ }
270
+
271
+ /** Set the root signal — the entry point of the broadcast. Input type is inferred from the signal. */
272
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
273
+ input<T>(rootSignal: Signal<T, any>): BroadcastChain<T> {
274
+ const node: BroadcastNode = {
275
+ name: rootSignal.name,
276
+ signalName: rootSignal.name,
277
+ signal: rootSignal,
278
+ dependsOn: [],
279
+ timeout: rootSignal.timeout,
280
+ maxAttempts: rootSignal.maxAttempts,
281
+ };
282
+ return new BroadcastChain<T>({
283
+ name: this._name,
284
+ nodes: [node],
285
+ lastTier: [rootSignal.name],
286
+ failurePolicy: "fail-fast",
287
+ });
288
+ }
289
+ }
290
+
291
+ export function broadcast(name: string): BroadcastBuilder {
292
+ return new BroadcastBuilder(name);
293
+ }
package/src/config.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { BroadcastMemoryAdapter } from "./adapters/memory.js";
2
+ import type { BroadcastQueueAdapter } from "./adapters/index.js";
3
+
4
+ let _adapter: BroadcastQueueAdapter = new BroadcastMemoryAdapter();
5
+ let _configured = false;
6
+ let _warnedUnconfigured = false;
7
+
8
+ export function configureBroadcast(options: { adapter: BroadcastQueueAdapter }): void {
9
+ if (_configured) {
10
+ console.warn(
11
+ "[station-broadcast] configureBroadcast() called multiple times. The previous adapter will be replaced.",
12
+ );
13
+ }
14
+ _adapter = options.adapter;
15
+ _configured = true;
16
+ }
17
+
18
+ export function getBroadcastAdapter(): BroadcastQueueAdapter {
19
+ if (!_configured && !_warnedUnconfigured) {
20
+ _warnedUnconfigured = true;
21
+ console.warn(
22
+ "[station-broadcast] No adapter configured — using default BroadcastMemoryAdapter. " +
23
+ "Call configureBroadcast({ adapter }) for persistent storage.",
24
+ );
25
+ }
26
+ return _adapter;
27
+ }
28
+
29
+ export function isBroadcastConfigured(): boolean {
30
+ return _configured;
31
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,19 @@
1
+ export class BroadcastValidationError extends Error {
2
+ readonly code: string = "BROADCAST_VALIDATION_ERROR";
3
+ constructor(message: string) {
4
+ super(message);
5
+ this.name = "BroadcastValidationError";
6
+ }
7
+ }
8
+
9
+ export class BroadcastCycleError extends BroadcastValidationError {
10
+ override readonly code = "BROADCAST_CYCLE_ERROR";
11
+ readonly cycle: string[];
12
+ constructor(broadcastName: string, cycle: string[]) {
13
+ super(
14
+ `Broadcast "${broadcastName}" contains a cycle: ${cycle.join(" → ")}`,
15
+ );
16
+ this.name = "BroadcastCycleError";
17
+ this.cycle = cycle;
18
+ }
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ export { broadcast, BroadcastBuilder, BroadcastChain, type BroadcastDefinition, type BroadcastNode, type ThenOptions } from "./broadcast.js";
2
+ export { BroadcastRunner, type BroadcastRunnerOptions } from "./broadcast-runner.js";
3
+ export { configureBroadcast, getBroadcastAdapter, isBroadcastConfigured } from "./config.js";
4
+
5
+ export type {
6
+ BroadcastRun,
7
+ BroadcastRunStatus,
8
+ BroadcastRunPatch,
9
+ BroadcastNodeRun,
10
+ BroadcastNodeStatus,
11
+ BroadcastNodeRunPatch,
12
+ BroadcastNodeSkipReason,
13
+ FailurePolicy,
14
+ } from "./types.js";
15
+
16
+ export {
17
+ type BroadcastQueueAdapter,
18
+ BroadcastMemoryAdapter,
19
+ } from "./adapters/index.js";
20
+
21
+ export {
22
+ type BroadcastSubscriber,
23
+ ConsoleBroadcastSubscriber,
24
+ } from "./subscribers/index.js";
25
+
26
+ export {
27
+ BroadcastValidationError,
28
+ BroadcastCycleError,
29
+ } from "./errors.js";
30
+
31
+ export { isBroadcast, BROADCAST_BRAND } from "./util.js";
@@ -0,0 +1,66 @@
1
+ import type { BroadcastRun, BroadcastNodeRun } from "../types.js";
2
+ import type { BroadcastSubscriber } from "./index.js";
3
+
4
+ export class ConsoleBroadcastSubscriber implements BroadcastSubscriber {
5
+ private prefix = "[station-broadcast]";
6
+
7
+ onBroadcastDiscovered(event: { broadcastName: string; filePath: string }): void {
8
+ console.log(
9
+ `${this.prefix} Discovered broadcast "${event.broadcastName}" at ${event.filePath}`,
10
+ );
11
+ }
12
+
13
+ onBroadcastQueued(event: { broadcastRun: BroadcastRun }): void {
14
+ console.log(
15
+ `${this.prefix} Queued "${event.broadcastRun.broadcastName}" (${event.broadcastRun.id})`,
16
+ );
17
+ }
18
+
19
+ onBroadcastStarted(event: { broadcastRun: BroadcastRun }): void {
20
+ console.log(
21
+ `${this.prefix} Started "${event.broadcastRun.broadcastName}" (${event.broadcastRun.id})`,
22
+ );
23
+ }
24
+
25
+ onBroadcastCompleted(event: { broadcastRun: BroadcastRun }): void {
26
+ console.log(
27
+ `${this.prefix} Completed "${event.broadcastRun.broadcastName}" (${event.broadcastRun.id})`,
28
+ );
29
+ }
30
+
31
+ onBroadcastFailed(event: { broadcastRun: BroadcastRun; error: string }): void {
32
+ console.error(
33
+ `${this.prefix} Failed "${event.broadcastRun.broadcastName}" (${event.broadcastRun.id}): ${event.error}`,
34
+ );
35
+ }
36
+
37
+ onBroadcastCancelled(event: { broadcastRun: BroadcastRun }): void {
38
+ console.log(
39
+ `${this.prefix} Cancelled "${event.broadcastRun.broadcastName}" (${event.broadcastRun.id})`,
40
+ );
41
+ }
42
+
43
+ onNodeTriggered(event: { broadcastRun: BroadcastRun; nodeRun: BroadcastNodeRun }): void {
44
+ console.log(
45
+ `${this.prefix} → Node "${event.nodeRun.nodeName}" triggered for "${event.broadcastRun.broadcastName}" (${event.broadcastRun.id})`,
46
+ );
47
+ }
48
+
49
+ onNodeCompleted(event: { broadcastRun: BroadcastRun; nodeRun: BroadcastNodeRun }): void {
50
+ console.log(
51
+ `${this.prefix} [ok] Node "${event.nodeRun.nodeName}" completed for "${event.broadcastRun.broadcastName}" (${event.broadcastRun.id})`,
52
+ );
53
+ }
54
+
55
+ onNodeFailed(event: { broadcastRun: BroadcastRun; nodeRun: BroadcastNodeRun; error: string }): void {
56
+ console.error(
57
+ `${this.prefix} [FAIL] Node "${event.nodeRun.nodeName}" failed for "${event.broadcastRun.broadcastName}" (${event.broadcastRun.id}): ${event.error}`,
58
+ );
59
+ }
60
+
61
+ onNodeSkipped(event: { broadcastRun: BroadcastRun; nodeRun: BroadcastNodeRun; reason: string }): void {
62
+ console.log(
63
+ `${this.prefix} [skip] Node "${event.nodeRun.nodeName}" skipped for "${event.broadcastRun.broadcastName}" (${event.broadcastRun.id}): ${event.reason}`,
64
+ );
65
+ }
66
+ }
@@ -0,0 +1,35 @@
1
+ import type { BroadcastRun, BroadcastNodeRun } from "../types.js";
2
+
3
+ export interface BroadcastSubscriber {
4
+ /** Broadcast definition discovered during auto-discovery. */
5
+ onBroadcastDiscovered?(event: { broadcastName: string; filePath: string }): void;
6
+
7
+ /** Broadcast run created and queued. */
8
+ onBroadcastQueued?(event: { broadcastRun: BroadcastRun }): void;
9
+
10
+ /** Broadcast run started (first nodes being triggered). */
11
+ onBroadcastStarted?(event: { broadcastRun: BroadcastRun }): void;
12
+
13
+ /** All nodes completed successfully. */
14
+ onBroadcastCompleted?(event: { broadcastRun: BroadcastRun }): void;
15
+
16
+ /** Broadcast failed (at least one required node failed). */
17
+ onBroadcastFailed?(event: { broadcastRun: BroadcastRun; error: string }): void;
18
+
19
+ /** Broadcast cancelled. */
20
+ onBroadcastCancelled?(event: { broadcastRun: BroadcastRun }): void;
21
+
22
+ /** A node's signal was triggered. */
23
+ onNodeTriggered?(event: { broadcastRun: BroadcastRun; nodeRun: BroadcastNodeRun }): void;
24
+
25
+ /** A node's signal completed. */
26
+ onNodeCompleted?(event: { broadcastRun: BroadcastRun; nodeRun: BroadcastNodeRun }): void;
27
+
28
+ /** A node's signal failed. */
29
+ onNodeFailed?(event: { broadcastRun: BroadcastRun; nodeRun: BroadcastNodeRun; error: string }): void;
30
+
31
+ /** A node was skipped (guard returned false or upstream failed). */
32
+ onNodeSkipped?(event: { broadcastRun: BroadcastRun; nodeRun: BroadcastNodeRun; reason: string }): void;
33
+ }
34
+
35
+ export { ConsoleBroadcastSubscriber } from "./console.js";
package/src/types.ts ADDED
@@ -0,0 +1,47 @@
1
+ export type FailurePolicy = "fail-fast" | "skip-downstream" | "continue";
2
+
3
+ export type BroadcastRunStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
4
+ export type BroadcastNodeStatus = "pending" | "running" | "completed" | "failed" | "skipped";
5
+
6
+ export interface BroadcastRun {
7
+ id: string;
8
+ broadcastName: string;
9
+ /** JSON-serialized input provided when the broadcast was triggered. */
10
+ input: string;
11
+ status: BroadcastRunStatus;
12
+ failurePolicy: FailurePolicy;
13
+ /** Max time (ms) the entire broadcast may run before being auto-failed. */
14
+ timeout?: number;
15
+ /** Recurring interval (e.g. "5m"). */
16
+ interval?: string;
17
+ nextRunAt?: Date;
18
+ createdAt: Date;
19
+ startedAt?: Date;
20
+ completedAt?: Date;
21
+ error?: string;
22
+ }
23
+
24
+ export type BroadcastRunPatch = Partial<Omit<BroadcastRun, "id" | "broadcastName" | "createdAt">>;
25
+
26
+ export type BroadcastNodeSkipReason = "guard" | "upstream-failed" | "cancelled";
27
+
28
+ export interface BroadcastNodeRun {
29
+ id: string;
30
+ broadcastRunId: string;
31
+ nodeName: string;
32
+ signalName: string;
33
+ /** Links to the signal Run record created for this node. */
34
+ signalRunId?: string;
35
+ status: BroadcastNodeStatus;
36
+ /** Why this node was skipped (only set when status is "skipped"). */
37
+ skipReason?: BroadcastNodeSkipReason;
38
+ /** JSON-serialized input passed to the signal. */
39
+ input?: string;
40
+ /** JSON-serialized output from the completed signal. */
41
+ output?: string;
42
+ error?: string;
43
+ startedAt?: Date;
44
+ completedAt?: Date;
45
+ }
46
+
47
+ export type BroadcastNodeRunPatch = Partial<Omit<BroadcastNodeRun, "id" | "broadcastRunId" | "nodeName" | "signalName">>;
package/src/util.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { BroadcastDefinition, BroadcastNode } from "./broadcast.js";
2
+ import { BroadcastCycleError } from "./errors.js";
3
+
4
+ export const BROADCAST_BRAND = Symbol.for("station-broadcast");
5
+
6
+ export function isBroadcast(value: unknown): value is BroadcastDefinition {
7
+ if (typeof value !== "object" || value === null) return false;
8
+ return (value as Record<symbol, unknown>)[BROADCAST_BRAND] === true;
9
+ }
10
+
11
+ /**
12
+ * Topological sort with cycle detection.
13
+ * Returns nodes in dependency order (roots first).
14
+ * Throws BroadcastCycleError if a cycle is found.
15
+ */
16
+ export function topologicalSort(
17
+ broadcastName: string,
18
+ nodes: readonly BroadcastNode[],
19
+ ): BroadcastNode[] {
20
+ const nodeMap = new Map(nodes.map((n) => [n.name, n]));
21
+ const visited = new Set<string>();
22
+ const visiting = new Set<string>();
23
+ const sorted: BroadcastNode[] = [];
24
+
25
+ function visit(name: string, path: string[]): void {
26
+ if (visited.has(name)) return;
27
+ if (visiting.has(name)) {
28
+ const cycleStart = path.indexOf(name);
29
+ const cycle = [...path.slice(cycleStart), name];
30
+ throw new BroadcastCycleError(broadcastName, cycle);
31
+ }
32
+
33
+ visiting.add(name);
34
+ const node = nodeMap.get(name);
35
+ if (!node) return; // Guard: skip unknown nodes (validated elsewhere)
36
+ for (const dep of node.dependsOn) {
37
+ visit(dep, [...path, name]);
38
+ }
39
+ visiting.delete(name);
40
+ visited.add(name);
41
+ sorted.push(node);
42
+ }
43
+
44
+ for (const node of nodes) {
45
+ visit(node.name, []);
46
+ }
47
+
48
+ return sorted;
49
+ }