station-signal 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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +305 -0
  3. package/dist/adapters/http-trigger.d.ts +19 -0
  4. package/dist/adapters/http-trigger.d.ts.map +1 -0
  5. package/dist/adapters/http-trigger.js +63 -0
  6. package/dist/adapters/http-trigger.js.map +1 -0
  7. package/dist/adapters/index.d.ts +49 -0
  8. package/dist/adapters/index.d.ts.map +1 -0
  9. package/dist/adapters/index.js +6 -0
  10. package/dist/adapters/index.js.map +1 -0
  11. package/dist/adapters/memory.d.ts +33 -0
  12. package/dist/adapters/memory.d.ts.map +1 -0
  13. package/dist/adapters/memory.js +144 -0
  14. package/dist/adapters/memory.js.map +1 -0
  15. package/dist/adapters/registry.d.ts +17 -0
  16. package/dist/adapters/registry.d.ts.map +1 -0
  17. package/dist/adapters/registry.js +27 -0
  18. package/dist/adapters/registry.js.map +1 -0
  19. package/dist/adapters/trigger.d.ts +12 -0
  20. package/dist/adapters/trigger.d.ts.map +1 -0
  21. package/dist/adapters/trigger.js +2 -0
  22. package/dist/adapters/trigger.js.map +1 -0
  23. package/dist/bootstrap.d.ts +10 -0
  24. package/dist/bootstrap.d.ts.map +1 -0
  25. package/dist/bootstrap.js +198 -0
  26. package/dist/bootstrap.js.map +1 -0
  27. package/dist/config.d.ts +18 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +52 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/errors.d.ts +28 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +50 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +12 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/interval.d.ts +7 -0
  40. package/dist/interval.d.ts.map +1 -0
  41. package/dist/interval.js +25 -0
  42. package/dist/interval.js.map +1 -0
  43. package/dist/signal-runner.d.ts +91 -0
  44. package/dist/signal-runner.d.ts.map +1 -0
  45. package/dist/signal-runner.js +564 -0
  46. package/dist/signal-runner.js.map +1 -0
  47. package/dist/signal.d.ts +76 -0
  48. package/dist/signal.d.ts.map +1 -0
  49. package/dist/signal.js +209 -0
  50. package/dist/signal.js.map +1 -0
  51. package/dist/subscribers/console.d.ts +64 -0
  52. package/dist/subscribers/console.d.ts.map +1 -0
  53. package/dist/subscribers/console.js +56 -0
  54. package/dist/subscribers/console.js.map +1 -0
  55. package/dist/subscribers/index.d.ts +93 -0
  56. package/dist/subscribers/index.d.ts.map +1 -0
  57. package/dist/subscribers/index.js +2 -0
  58. package/dist/subscribers/index.js.map +1 -0
  59. package/dist/types.d.ts +44 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +3 -0
  62. package/dist/types.js.map +1 -0
  63. package/dist/util.d.ts +6 -0
  64. package/dist/util.d.ts.map +1 -0
  65. package/dist/util.js +9 -0
  66. package/dist/util.js.map +1 -0
  67. package/package.json +43 -0
  68. package/src/adapters/http-trigger.ts +83 -0
  69. package/src/adapters/index.ts +64 -0
  70. package/src/adapters/memory.ts +165 -0
  71. package/src/adapters/registry.ts +35 -0
  72. package/src/adapters/trigger.ts +11 -0
  73. package/src/bootstrap.ts +237 -0
  74. package/src/config.ts +75 -0
  75. package/src/errors.ts +60 -0
  76. package/src/index.ts +37 -0
  77. package/src/interval.ts +29 -0
  78. package/src/signal-runner.ts +659 -0
  79. package/src/signal.ts +294 -0
  80. package/src/subscribers/console.ts +108 -0
  81. package/src/subscribers/index.ts +74 -0
  82. package/src/types.ts +50 -0
  83. package/src/util.ts +10 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 porkytheblack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,305 @@
1
+ # station-signal
2
+
3
+ A lightweight, type-safe background job framework for TypeScript. Define signals with Zod schemas, trigger them from anywhere, and let the runner execute each one in an isolated child process with timeout enforcement and automatic retries.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add station-signal
9
+ ```
10
+
11
+ ## Defining signals
12
+
13
+ Use the `signal()` builder to define a named signal with a Zod input schema and a handler function.
14
+
15
+ ```ts
16
+ import { signal, z } from "station-signal";
17
+
18
+ export const sendEmail = signal("send-email")
19
+ .input(z.object({ to: z.string().email(), subject: z.string(), body: z.string() }))
20
+ .timeout(30_000)
21
+ .retries(3)
22
+ .run(async (input) => {
23
+ await emailService.send(input.to, input.subject, input.body);
24
+ });
25
+ ```
26
+
27
+ The full builder chain is:
28
+
29
+ ```ts
30
+ signal("name")
31
+ .input(schema) // Required. Zod schema that validates the input.
32
+ .timeout(ms) // Optional. Max execution time in milliseconds (default: 300000).
33
+ .retries(n) // Optional. Number of retries after the first failure (default: 0).
34
+ .every("5m") // Optional. Makes this a recurring signal on an interval.
35
+ .run(fn) // Required. The async handler function. Returns a Signal object.
36
+ ```
37
+
38
+ | Method | Required | Description |
39
+ |---|---|---|
40
+ | `.input(schema)` | Yes | A Zod schema used to validate input at trigger time and before execution. |
41
+ | `.timeout(ms)` | No | Override the default 5-minute timeout, in milliseconds. |
42
+ | `.retries(n)` | No | Number of retries after the first attempt fails. Total attempts = `n + 1`. |
43
+ | `.every(interval)` | No | Schedule the signal to run on a recurring interval (e.g. `"every 5m"`). |
44
+ | `.run(fn)` | Yes | The async function that executes the signal. Finalizes and returns the `Signal` object. |
45
+
46
+ ## Triggering signals
47
+
48
+ There are two ways to trigger a signal.
49
+
50
+ ### Type-safe trigger
51
+
52
+ Call `.trigger()` directly on a signal object. The input is validated against the Zod schema before being enqueued.
53
+
54
+ ```ts
55
+ import { sendEmail } from "./signals/send-email.js";
56
+
57
+ const entryId = await sendEmail.trigger({
58
+ to: "alice@example.com",
59
+ subject: "Welcome",
60
+ body: "Thanks for signing up.",
61
+ });
62
+ // entryId is a unique string identifying this queue entry
63
+ ```
64
+
65
+ ### Dynamic trigger
66
+
67
+ Use `SignalQueue` to trigger signals by name. This is useful when the signal name comes from a variable or external source. No schema validation is performed.
68
+
69
+ ```ts
70
+ import { SignalQueue } from "station-signal";
71
+
72
+ const queue = new SignalQueue();
73
+ const entryId = await queue.trigger("send-email", {
74
+ to: "alice@example.com",
75
+ subject: "Welcome",
76
+ body: "Thanks for signing up.",
77
+ });
78
+ ```
79
+
80
+ Both approaches return a `Promise<string>` containing the queue entry ID.
81
+
82
+ ## Running signals
83
+
84
+ `SignalRunner` polls the adapter for due entries and spawns an isolated child process for each one.
85
+
86
+ ### Minimal example
87
+
88
+ ```ts
89
+ import { SignalRunner } from "station-signal";
90
+
91
+ const runner = new SignalRunner({
92
+ signalsDir: "./src/signals",
93
+ });
94
+
95
+ await runner.start();
96
+ ```
97
+
98
+ ### Full options
99
+
100
+ ```ts
101
+ import { SignalRunner } from "station-signal";
102
+ import { SQLiteAdapter } from "station-adapter-sqlite";
103
+
104
+ const runner = new SignalRunner({
105
+ // Auto-discover all .ts/.js files in this directory (recursive).
106
+ signalsDir: "./src/signals",
107
+
108
+ // Custom adapter for persistence. Defaults to MemoryAdapter.
109
+ adapter: new SQLiteAdapter("jobs.db"),
110
+
111
+ // How often to check for due entries, in milliseconds. Default: 1000.
112
+ pollIntervalMs: 2000,
113
+
114
+ // Default max attempts for signals that don't specify their own. Default: 1.
115
+ maxAttempts: 3,
116
+
117
+ // Path to a module that calls configure(). Imported by the runner on startup
118
+ // AND by every spawned child process before the signal file.
119
+ configModule: "/absolute/path/to/adapter.config.ts",
120
+ });
121
+
122
+ await runner.start();
123
+ ```
124
+
125
+ ### Manual registration
126
+
127
+ If you are not using `signalsDir`, you can register signals individually:
128
+
129
+ ```ts
130
+ const runner = new SignalRunner();
131
+ runner.register("send-email", "/absolute/path/to/signals/send-email.ts");
132
+ runner.register("generate-report", "/absolute/path/to/signals/generate-report.ts");
133
+ await runner.start();
134
+ ```
135
+
136
+ The `register()` method takes a signal name and the absolute file path to the module that exports the signal.
137
+
138
+ ## Recurring signals
139
+
140
+ ### Using the builder
141
+
142
+ Add `.every()` to any signal definition to make it recurring. The runner automatically schedules the first run on startup and reschedules after each execution.
143
+
144
+ ```ts
145
+ export const healthCheck = signal("health-check")
146
+ .input(z.object({}))
147
+ .every("every 30s")
148
+ .run(async () => {
149
+ await pingAllServices();
150
+ });
151
+ ```
152
+
153
+ ### Using SignalQueue
154
+
155
+ Schedule a recurring signal dynamically:
156
+
157
+ ```ts
158
+ const queue = new SignalQueue();
159
+ await queue.schedule("cleanup-temp-files", "every 1h", {});
160
+ ```
161
+
162
+ ### Interval format
163
+
164
+ The interval string must match the format `"every <number><unit>"`.
165
+
166
+ | Unit | Meaning | Example |
167
+ |---|---|---|
168
+ | `s` | Seconds | `"every 30s"` |
169
+ | `m` | Minutes | `"every 5m"` |
170
+ | `h` | Hours | `"every 1h"` |
171
+ | `d` | Days | `"every 7d"` |
172
+
173
+ ## Timeout and retries
174
+
175
+ Every signal has a timeout and a maximum number of attempts.
176
+
177
+ | Setting | Default | Builder method |
178
+ |---|---|---|
179
+ | Timeout | 300,000ms (5 minutes) | `.timeout(ms)` |
180
+ | Retries | 0 (1 total attempt) | `.retries(n)` |
181
+
182
+ When a signal times out or throws an error and has remaining retry attempts, the runner resets it to "pending" for another try.
183
+
184
+ **Trigger signals**: After exhausting all attempts, the entry is marked as `"failed"` with a `completedAt` timestamp.
185
+
186
+ **Recurring signals**: After exhausting all attempts for a given run, the entry resets its attempt counter to 0 and reschedules the next run based on its interval. Recurring signals never permanently fail.
187
+
188
+ ## Adapters
189
+
190
+ Adapters control how queue entries are stored and retrieved.
191
+
192
+ ### Setting the global adapter
193
+
194
+ ```ts
195
+ import { configure } from "station-signal";
196
+ import { SQLiteAdapter } from "station-adapter-sqlite";
197
+
198
+ configure({ adapter: new SQLiteAdapter("jobs.db") });
199
+ ```
200
+
201
+ The default adapter is `MemoryAdapter`, which stores entries in-process. It requires no configuration but does not persist data across restarts and cannot share state between the runner and its spawned child processes.
202
+
203
+ ### The configModule pattern
204
+
205
+ Because `SignalRunner` spawns each signal in a separate child process, you need a way to ensure both the runner and every child process use the same adapter. The `configModule` option solves this:
206
+
207
+ ```ts
208
+ // src/adapter.config.ts
209
+ import { configure } from "station-signal";
210
+ import { SQLiteAdapter } from "station-adapter-sqlite";
211
+
212
+ configure({ adapter: new SQLiteAdapter("jobs.db") });
213
+ ```
214
+
215
+ ```ts
216
+ // src/runner.ts
217
+ import { fileURLToPath } from "node:url";
218
+ import { SignalRunner } from "station-signal";
219
+
220
+ const runner = new SignalRunner({
221
+ signalsDir: "./src/signals",
222
+ configModule: fileURLToPath(new URL("./adapter.config.ts", import.meta.url)),
223
+ });
224
+
225
+ await runner.start();
226
+ ```
227
+
228
+ The runner imports `configModule` on startup. Every spawned child process imports it before loading the signal file. This guarantees a consistent adapter everywhere.
229
+
230
+ ## Writing a custom adapter
231
+
232
+ Implement the `SignalQueueAdapter` interface:
233
+
234
+ ```ts
235
+ interface SignalQueueAdapter {
236
+ add(entry: QueueEntry): Promise<void>;
237
+ remove(id: string): Promise<void>;
238
+ getDue(): Promise<QueueEntry[]>;
239
+ getRunning(): Promise<QueueEntry[]>;
240
+ update(id: string, patch: Partial<QueueEntry>): Promise<void>;
241
+ ping(): Promise<boolean>;
242
+ generateId(): string;
243
+ }
244
+ ```
245
+
246
+ | Method | Contract |
247
+ |---|---|
248
+ | `add(entry)` | Store a new queue entry. |
249
+ | `remove(id)` | Delete an entry by its ID. |
250
+ | `getDue()` | Return all pending entries where `nextRunAt` is `null`/`undefined` or `<= now`. |
251
+ | `getRunning()` | Return all entries with status `"running"`. |
252
+ | `update(id, patch)` | Merge the partial patch into the existing entry. |
253
+ | `ping()` | Health check. Return `true` if the adapter is operational. |
254
+ | `generateId()` | Produce a unique string ID for a new queue entry. |
255
+
256
+ ## How it works
257
+
258
+ 1. `SignalRunner` polls the adapter at a configurable interval, calling `getDue()` to find entries that are ready to execute.
259
+ 2. For each due entry, the runner marks it as `"running"` and spawns an isolated child process via `node --import tsx bootstrap.js`.
260
+ 3. The child process first imports the `configModule` (if provided) to set up the shared adapter, then imports the signal file.
261
+ 4. The bootstrap script finds the matching signal export, validates the input against the signal's Zod schema, and rejects with a `"failed"` status if validation fails.
262
+ 5. The signal handler runs under timeout enforcement via `Promise.race`. If it completes in time, the entry is marked `"completed"`. If it throws, the entry is marked `"failed"`.
263
+ 6. Back in the runner, `checkTimeouts()` runs each tick to detect entries that have been `"running"` longer than their configured timeout. Timed-out entries are either reset to `"pending"` for a retry or marked `"failed"` (trigger) / rescheduled (recurring) depending on remaining attempts.
264
+
265
+ ## Types reference
266
+
267
+ ### QueueEntry
268
+
269
+ ```ts
270
+ interface QueueEntry {
271
+ id: string;
272
+ signalName: string;
273
+ kind: QueueEntryKind;
274
+ input: string; // JSON-serialized
275
+ status: EntryStatus;
276
+ attempts: number;
277
+ maxAttempts: number;
278
+ timeout: number; // milliseconds
279
+ interval?: string; // e.g. "every 5m" (recurring only)
280
+ nextRunAt?: Date;
281
+ lastRunAt?: Date;
282
+ startedAt?: Date;
283
+ completedAt?: Date;
284
+ createdAt: Date;
285
+ }
286
+ ```
287
+
288
+ ### EntryStatus
289
+
290
+ ```ts
291
+ type EntryStatus = "pending" | "running" | "completed" | "failed";
292
+ ```
293
+
294
+ ### QueueEntryKind
295
+
296
+ ```ts
297
+ type QueueEntryKind = "trigger" | "recurring";
298
+ ```
299
+
300
+ ### Constants
301
+
302
+ ```ts
303
+ const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes
304
+ const DEFAULT_MAX_ATTEMPTS = 1; // no retry by default
305
+ ```
@@ -0,0 +1,19 @@
1
+ import type { TriggerAdapter } from "./trigger.js";
2
+ export interface HttpTriggerOptions {
3
+ endpoint: string;
4
+ apiKey?: string;
5
+ timeout?: number;
6
+ fetch?: typeof globalThis.fetch;
7
+ }
8
+ export declare class HttpTriggerAdapter implements TriggerAdapter {
9
+ private endpoint;
10
+ private apiKey?;
11
+ private timeout;
12
+ private fetchFn;
13
+ constructor(options: HttpTriggerOptions);
14
+ trigger(signalName: string, input: unknown): Promise<string>;
15
+ triggerBroadcast(broadcastName: string, input: unknown): Promise<string>;
16
+ ping(): Promise<boolean>;
17
+ private headers;
18
+ }
19
+ //# sourceMappingURL=http-trigger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-trigger.d.ts","sourceRoot":"","sources":["../../src/adapters/http-trigger.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAYnD,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACjC;AAED,qBAAa,kBAAmB,YAAW,cAAc;IACvD,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,MAAM,CAAC,CAAS;IACxB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAA0B;gBAE7B,OAAO,EAAE,kBAAkB;IAOjC,OAAO,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC;IAgB5D,gBAAgB,CAAC,aAAa,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC;IAgBxE,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC;IAa9B,OAAO,CAAC,OAAO;CAKhB"}
@@ -0,0 +1,63 @@
1
+ import { StationRemoteError } from "../errors.js";
2
+ export class HttpTriggerAdapter {
3
+ endpoint;
4
+ apiKey;
5
+ timeout;
6
+ fetchFn;
7
+ constructor(options) {
8
+ this.endpoint = options.endpoint.replace(/\/+$/, "");
9
+ this.apiKey = options.apiKey;
10
+ this.timeout = options.timeout ?? 10_000;
11
+ this.fetchFn = options.fetch ?? globalThis.fetch;
12
+ }
13
+ async trigger(signalName, input) {
14
+ const url = `${this.endpoint}/api/v1/trigger`;
15
+ const response = await this.fetchFn(url, {
16
+ method: "POST",
17
+ headers: this.headers(),
18
+ body: JSON.stringify({ signalName, input }),
19
+ signal: AbortSignal.timeout(this.timeout),
20
+ });
21
+ if (!response.ok) {
22
+ const body = await response.json().catch(() => ({}));
23
+ throw new StationRemoteError(response.status, body.error, body.message);
24
+ }
25
+ const body = await response.json();
26
+ return body.data.id;
27
+ }
28
+ async triggerBroadcast(broadcastName, input) {
29
+ const url = `${this.endpoint}/api/v1/trigger-broadcast`;
30
+ const response = await this.fetchFn(url, {
31
+ method: "POST",
32
+ headers: this.headers(),
33
+ body: JSON.stringify({ broadcastName, input }),
34
+ signal: AbortSignal.timeout(this.timeout),
35
+ });
36
+ if (!response.ok) {
37
+ const body = await response.json().catch(() => ({}));
38
+ throw new StationRemoteError(response.status, body.error, body.message);
39
+ }
40
+ const body = await response.json();
41
+ return body.data.id;
42
+ }
43
+ async ping() {
44
+ try {
45
+ const url = `${this.endpoint}/api/v1/health`;
46
+ const response = await this.fetchFn(url, {
47
+ headers: this.headers(),
48
+ signal: AbortSignal.timeout(5000),
49
+ });
50
+ return response.ok;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ headers() {
57
+ const h = { "Content-Type": "application/json" };
58
+ if (this.apiKey)
59
+ h["Authorization"] = `Bearer ${this.apiKey}`;
60
+ return h;
61
+ }
62
+ }
63
+ //# sourceMappingURL=http-trigger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-trigger.js","sourceRoot":"","sources":["../../src/adapters/http-trigger.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAkBlD,MAAM,OAAO,kBAAkB;IACrB,QAAQ,CAAS;IACjB,MAAM,CAAU;IAChB,OAAO,CAAS;IAChB,OAAO,CAA0B;IAEzC,YAAY,OAA2B;QACrC,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QACrD,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;QAC7B,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,MAAM,CAAC;QACzC,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,UAAU,CAAC,KAAK,CAAC;IACnD,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,UAAkB,EAAE,KAAc;QAC9C,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,QAAQ,iBAAiB,CAAC;QAC9C,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE;YACvC,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE;YACvB,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;YAC3C,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC;SAC1C,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAc,CAAC;YAClE,MAAM,IAAI,kBAAkB,CAAC,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;QAC1E,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAiB,CAAC;QAClD,OAAO,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;IACtB,CAAC;IAED,KAAK,CAAC,gBAAgB,CAAC,aAAqB,EAAE,KAAc;QAC1D,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,QAAQ,2BAA2B,CAAC;QACxD,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE;YACvC,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE;YACvB,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,aAAa,EAAE,KAAK,EAAE,CAAC;YAC9C,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC;SAC1C,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAc,CAAC;YAClE,MAAM,IAAI,kBAAkB,CAAC,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;QAC1E,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAiB,CAAC;QAClD,OAAO,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;IACtB,CAAC;IAED,KAAK,CAAC,IAAI;QACR,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,QAAQ,gBAAgB,CAAC;YAC7C,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE;gBACvC,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE;gBACvB,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC;aAClC,CAAC,CAAC;YACH,OAAO,QAAQ,CAAC,EAAE,CAAC;QACrB,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC;IAEO,OAAO;QACb,MAAM,CAAC,GAA2B,EAAE,cAAc,EAAE,kBAAkB,EAAE,CAAC;QACzE,IAAI,IAAI,CAAC,MAAM;YAAE,CAAC,CAAC,eAAe,CAAC,GAAG,UAAU,IAAI,CAAC,MAAM,EAAE,CAAC;QAC9D,OAAO,CAAC,CAAC;IACX,CAAC;CACF"}
@@ -0,0 +1,49 @@
1
+ import type { Run, RunPatch, RunStatus, Step, StepPatch } from "../types.js";
2
+ export interface SignalQueueAdapter {
3
+ addRun(run: Run): Promise<void>;
4
+ removeRun(id: string): Promise<void>;
5
+ getRunsDue(): Promise<Run[]>;
6
+ getRunsRunning(): Promise<Run[]>;
7
+ getRun(id: string): Promise<Run | null>;
8
+ updateRun(id: string, patch: RunPatch): Promise<void>;
9
+ listRuns(signalName: string): Promise<Run[]>;
10
+ /** Check if any run for the given signal has one of the specified statuses. */
11
+ hasRunWithStatus(signalName: string, statuses: RunStatus[]): Promise<boolean>;
12
+ /** Purge runs in terminal statuses older than the given date. Returns count deleted. */
13
+ purgeRuns(olderThan: Date, statuses: RunStatus[]): Promise<number>;
14
+ addStep(step: Step): Promise<void>;
15
+ updateStep(id: string, patch: StepPatch): Promise<void>;
16
+ getSteps(runId: string): Promise<Step[]>;
17
+ removeSteps(runId: string): Promise<void>;
18
+ generateId(): string;
19
+ ping(): Promise<boolean>;
20
+ close?(): Promise<void>;
21
+ }
22
+ /**
23
+ * Metadata an adapter carries so child processes can reconstruct it.
24
+ * Adapters that implement SerializableAdapter are fully automatic —
25
+ * no extra runner config needed.
26
+ */
27
+ export interface AdapterManifest {
28
+ /** Registry name (e.g. "sqlite"). Matches registerAdapter() name. */
29
+ name: string;
30
+ /** Serializable options to pass to the factory. */
31
+ options: Record<string, unknown>;
32
+ /**
33
+ * Resolved absolute path/URL to the module that registers this adapter.
34
+ * Only needed for external (non-built-in) adapters.
35
+ */
36
+ moduleUrl?: string;
37
+ }
38
+ /**
39
+ * Adapters that can be reconstructed in child processes implement this.
40
+ * MemoryAdapter intentionally does NOT implement this since it cannot
41
+ * share state across processes.
42
+ */
43
+ export interface SerializableAdapter extends SignalQueueAdapter {
44
+ toManifest(): AdapterManifest;
45
+ }
46
+ export declare function isSerializableAdapter(adapter: SignalQueueAdapter): adapter is SerializableAdapter;
47
+ export { MemoryAdapter } from "./memory.js";
48
+ export { registerAdapter, createAdapter, hasAdapter } from "./registry.js";
49
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/adapters/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,QAAQ,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7E,MAAM,WAAW,kBAAkB;IAEjC,MAAM,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,UAAU,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IAC7B,cAAc,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC;IACxC,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IAE7C,+EAA+E;IAC/E,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAE9E,wFAAwF;IACxF,SAAS,CAAC,SAAS,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAGnE,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;IACzC,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAG1C,UAAU,IAAI,MAAM,CAAC;IACrB,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IACzB,KAAK,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,mDAAmD;IACnD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,WAAW,mBAAoB,SAAQ,kBAAkB;IAC7D,UAAU,IAAI,eAAe,CAAC;CAC/B;AAED,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,kBAAkB,GAC1B,OAAO,IAAI,mBAAmB,CAEhC;AAED,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,6 @@
1
+ export function isSerializableAdapter(adapter) {
2
+ return typeof adapter.toManifest === "function";
3
+ }
4
+ export { MemoryAdapter } from "./memory.js";
5
+ export { registerAdapter, createAdapter, hasAdapter } from "./registry.js";
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/adapters/index.ts"],"names":[],"mappings":"AAwDA,MAAM,UAAU,qBAAqB,CACnC,OAA2B;IAE3B,OAAO,OAAQ,OAA+B,CAAC,UAAU,KAAK,UAAU,CAAC;AAC3E,CAAC;AAED,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,33 @@
1
+ import type { SignalQueueAdapter } from "./index.js";
2
+ import type { Run, RunPatch, RunStatus, Step, StepPatch } from "../types.js";
3
+ /**
4
+ * In-process memory adapter. Useful for single-process scripts and testing.
5
+ * Does NOT implement SerializableAdapter — child processes get their own
6
+ * independent MemoryAdapter. Use SqliteAdapter for cross-process persistence.
7
+ */
8
+ export declare class MemoryAdapter implements SignalQueueAdapter {
9
+ private runs;
10
+ private steps;
11
+ private maxRuns;
12
+ constructor(options?: {
13
+ maxRuns?: number;
14
+ });
15
+ addRun(run: Run): Promise<void>;
16
+ private evictCompleted;
17
+ removeRun(id: string): Promise<void>;
18
+ getRunsDue(): Promise<Run[]>;
19
+ getRunsRunning(): Promise<Run[]>;
20
+ getRun(id: string): Promise<Run | null>;
21
+ updateRun(id: string, patch: RunPatch): Promise<void>;
22
+ listRuns(signalName: string): Promise<Run[]>;
23
+ hasRunWithStatus(signalName: string, statuses: RunStatus[]): Promise<boolean>;
24
+ purgeRuns(olderThan: Date, statuses: RunStatus[]): Promise<number>;
25
+ addStep(step: Step): Promise<void>;
26
+ updateStep(id: string, patch: StepPatch): Promise<void>;
27
+ getSteps(runId: string): Promise<Step[]>;
28
+ removeSteps(runId: string): Promise<void>;
29
+ ping(): Promise<boolean>;
30
+ generateId(): string;
31
+ close(): Promise<void>;
32
+ }
33
+ //# sourceMappingURL=memory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/adapters/memory.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,KAAK,EAAE,GAAG,EAAE,QAAQ,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAG7E;;;;GAIG;AACH,qBAAa,aAAc,YAAW,kBAAkB;IACtD,OAAO,CAAC,IAAI,CAA0B;IACtC,OAAO,CAAC,KAAK,CAA2B;IACxC,OAAO,CAAC,OAAO,CAAS;gBAEZ,OAAO,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE;IAIpC,MAAM,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;IAOrC,OAAO,CAAC,cAAc;IAwBhB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKpC,UAAU,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;IAW5B,cAAc,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;IAMhC,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,GAAG,IAAI,CAAC;IAIvC,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAcrD,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;IAM5C,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC;IAQ7E,SAAS,CAAC,SAAS,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IAalE,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlC,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAcvD,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;IAMxC,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQzC,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC;IAI9B,UAAU,IAAI,MAAM;IAId,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAI7B"}
@@ -0,0 +1,144 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { registerAdapter } from "./registry.js";
3
+ /**
4
+ * In-process memory adapter. Useful for single-process scripts and testing.
5
+ * Does NOT implement SerializableAdapter — child processes get their own
6
+ * independent MemoryAdapter. Use SqliteAdapter for cross-process persistence.
7
+ */
8
+ export class MemoryAdapter {
9
+ runs = new Map();
10
+ steps = new Map();
11
+ maxRuns;
12
+ constructor(options) {
13
+ this.maxRuns = options?.maxRuns ?? 10_000;
14
+ }
15
+ async addRun(run) {
16
+ this.runs.set(run.id, run);
17
+ if (this.runs.size > this.maxRuns) {
18
+ this.evictCompleted();
19
+ }
20
+ }
21
+ evictCompleted() {
22
+ const terminal = [];
23
+ for (const [id, run] of this.runs) {
24
+ if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") {
25
+ terminal.push(id);
26
+ }
27
+ }
28
+ // Sort oldest first by completedAt
29
+ terminal.sort((a, b) => {
30
+ const ra = this.runs.get(a);
31
+ const rb = this.runs.get(b);
32
+ return (ra.completedAt?.getTime() ?? 0) - (rb.completedAt?.getTime() ?? 0);
33
+ });
34
+ // Evict oldest 10%
35
+ const evictCount = Math.max(1, Math.floor(terminal.length * 0.1));
36
+ for (let i = 0; i < evictCount && i < terminal.length; i++) {
37
+ const id = terminal[i];
38
+ this.runs.delete(id);
39
+ for (const [stepId, step] of this.steps) {
40
+ if (step.runId === id)
41
+ this.steps.delete(stepId);
42
+ }
43
+ }
44
+ }
45
+ async removeRun(id) {
46
+ this.runs.delete(id);
47
+ await this.removeSteps(id);
48
+ }
49
+ async getRunsDue() {
50
+ const now = new Date();
51
+ return Array.from(this.runs.values())
52
+ .filter((run) => {
53
+ if (run.status !== "pending")
54
+ return false;
55
+ if (!run.nextRunAt)
56
+ return true;
57
+ return run.nextRunAt <= now;
58
+ })
59
+ .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
60
+ }
61
+ async getRunsRunning() {
62
+ return Array.from(this.runs.values()).filter((run) => run.status === "running");
63
+ }
64
+ async getRun(id) {
65
+ return this.runs.get(id) ?? null;
66
+ }
67
+ async updateRun(id, patch) {
68
+ const run = this.runs.get(id);
69
+ if (run) {
70
+ const rec = run;
71
+ for (const [key, value] of Object.entries(patch)) {
72
+ if (value === undefined) {
73
+ delete rec[key];
74
+ }
75
+ else {
76
+ rec[key] = value;
77
+ }
78
+ }
79
+ }
80
+ }
81
+ async listRuns(signalName) {
82
+ return Array.from(this.runs.values()).filter((run) => run.signalName === signalName);
83
+ }
84
+ async hasRunWithStatus(signalName, statuses) {
85
+ const statusSet = new Set(statuses);
86
+ for (const run of this.runs.values()) {
87
+ if (run.signalName === signalName && statusSet.has(run.status))
88
+ return true;
89
+ }
90
+ return false;
91
+ }
92
+ async purgeRuns(olderThan, statuses) {
93
+ const statusSet = new Set(statuses);
94
+ let purged = 0;
95
+ for (const [id, run] of this.runs) {
96
+ if (statusSet.has(run.status) && run.completedAt && run.completedAt < olderThan) {
97
+ this.runs.delete(id);
98
+ await this.removeSteps(id);
99
+ purged++;
100
+ }
101
+ }
102
+ return purged;
103
+ }
104
+ async addStep(step) {
105
+ this.steps.set(step.id, step);
106
+ }
107
+ async updateStep(id, patch) {
108
+ const step = this.steps.get(id);
109
+ if (step) {
110
+ const rec = step;
111
+ for (const [key, value] of Object.entries(patch)) {
112
+ if (value === undefined) {
113
+ delete rec[key];
114
+ }
115
+ else {
116
+ rec[key] = value;
117
+ }
118
+ }
119
+ }
120
+ }
121
+ async getSteps(runId) {
122
+ return Array.from(this.steps.values()).filter((step) => step.runId === runId);
123
+ }
124
+ async removeSteps(runId) {
125
+ for (const [id, step] of this.steps) {
126
+ if (step.runId === runId) {
127
+ this.steps.delete(id);
128
+ }
129
+ }
130
+ }
131
+ async ping() {
132
+ return true;
133
+ }
134
+ generateId() {
135
+ return randomUUID();
136
+ }
137
+ async close() {
138
+ this.runs.clear();
139
+ this.steps.clear();
140
+ }
141
+ }
142
+ // Register in the adapter factory for cross-process reconstruction
143
+ registerAdapter("memory", () => new MemoryAdapter());
144
+ //# sourceMappingURL=memory.js.map