station-kit 1.0.8 → 1.1.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 (187) hide show
  1. package/.next/standalone/package.json +3 -1
  2. package/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
  3. package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +75 -16
  4. package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +10 -3
  5. package/.next/standalone/packages/station-kit/.next/build-manifest.json +3 -3
  6. package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +108 -12
  7. package/.next/standalone/packages/station-kit/.next/routes-manifest.json +49 -0
  8. package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page.js +2 -2
  9. package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  10. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.html +1 -1
  11. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.rsc +7 -7
  12. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page.js +2 -2
  13. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page.js +2 -0
  15. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page.js.nft.json +1 -0
  16. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -0
  17. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page.js +2 -0
  18. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page.js.nft.json +1 -0
  19. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -0
  20. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page.js +2 -0
  21. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page.js.nft.json +1 -0
  22. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -0
  23. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -0
  24. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.meta +7 -0
  25. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +25 -0
  26. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page.js +2 -2
  27. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page_client-reference-manifest.js +1 -1
  28. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.html +1 -1
  29. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.rsc +8 -8
  30. package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
  31. package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +8 -8
  32. package/.next/standalone/packages/station-kit/.next/server/app/page.js +2 -2
  33. package/.next/standalone/packages/station-kit/.next/server/app/page_client-reference-manifest.js +1 -1
  34. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page.js +2 -0
  35. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page.js.nft.json +1 -0
  36. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -0
  37. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -0
  38. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.meta +7 -0
  39. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +25 -0
  40. package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page.js +2 -2
  41. package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page.js +2 -0
  43. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page.js.nft.json +1 -0
  44. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -0
  45. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page.js +2 -0
  46. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page.js.nft.json +1 -0
  47. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -0
  48. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -0
  49. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.meta +7 -0
  50. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +25 -0
  51. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page.js +2 -0
  52. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page.js.nft.json +1 -0
  53. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -0
  54. package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -0
  55. package/.next/standalone/packages/station-kit/.next/server/app/schedules.meta +7 -0
  56. package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +25 -0
  57. package/.next/standalone/packages/station-kit/.next/server/app/settings/page.js +2 -2
  58. package/.next/standalone/packages/station-kit/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/packages/station-kit/.next/server/app/settings.html +1 -1
  60. package/.next/standalone/packages/station-kit/.next/server/app/settings.rsc +8 -8
  61. package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page.js +2 -2
  62. package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page_client-reference-manifest.js +1 -1
  63. package/.next/standalone/packages/station-kit/.next/server/app/signals/page.js +2 -2
  64. package/.next/standalone/packages/station-kit/.next/server/app/signals/page_client-reference-manifest.js +1 -1
  65. package/.next/standalone/packages/station-kit/.next/server/app/signals.html +1 -1
  66. package/.next/standalone/packages/station-kit/.next/server/app/signals.rsc +8 -8
  67. package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +10 -3
  68. package/.next/standalone/packages/station-kit/.next/server/chunks/102.js +1 -1
  69. package/.next/standalone/packages/station-kit/.next/server/chunks/535.js +2 -0
  70. package/.next/standalone/packages/station-kit/.next/server/chunks/606.js +14 -14
  71. package/.next/standalone/packages/station-kit/.next/server/chunks/783.js +3 -3
  72. package/.next/standalone/packages/station-kit/.next/server/middleware-build-manifest.js +1 -1
  73. package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
  74. package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
  75. package/.next/standalone/packages/station-kit/.next/server/pages/_app.js +1 -1
  76. package/.next/standalone/packages/station-kit/.next/server/pages/_document.js +1 -1
  77. package/.next/standalone/packages/station-kit/.next/server/pages/_error.js +9 -9
  78. package/.next/standalone/packages/station-kit/.next/server/pages-manifest.json +1 -1
  79. package/.next/standalone/packages/station-kit/.next/server/server-reference-manifest.json +1 -1
  80. package/.next/standalone/packages/station-kit/.next/static/THKSkCipW_pj0F6DRXYEG/_buildManifest.js +1 -0
  81. package/.next/standalone/packages/station-kit/.next/static/chunks/145-9e370afd2e5aba39.js +1 -0
  82. package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +1 -0
  83. package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +1 -0
  84. package/.next/standalone/packages/station-kit/.next/static/chunks/935-dff12960528de017.js +1 -0
  85. package/.next/standalone/packages/station-kit/.next/static/chunks/app/_not-found/{page-ce21b4ba9038a5a7.js → page-67ef312aee40cfeb.js} +1 -1
  86. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/[id]/page-fe2f5467a0c68fef.js +1 -0
  87. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/page-0d2505242014f51e.js +1 -0
  88. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +1 -0
  89. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/new/page-3d02707043d24dc7.js +1 -0
  90. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +1 -0
  91. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +1 -0
  92. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +1 -0
  93. package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +1 -0
  94. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +1 -0
  95. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/[id]/page-435f67be180b8e4f.js +1 -0
  96. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/new/page-f697c289c813496a.js +1 -0
  97. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +1 -0
  98. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +1 -0
  99. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +1 -0
  100. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +1 -0
  101. package/.next/standalone/packages/station-kit/.next/static/chunks/pages/_app-a3774a320f58a018.js +1 -0
  102. package/.next/standalone/packages/station-kit/package.json +7 -4
  103. package/dist/config/schema.d.ts +23 -0
  104. package/dist/config/schema.d.ts.map +1 -1
  105. package/dist/config/schema.js +2 -0
  106. package/dist/config/schema.js.map +1 -1
  107. package/dist/server/auth/keys.d.ts +91 -8
  108. package/dist/server/auth/keys.d.ts.map +1 -1
  109. package/dist/server/auth/keys.js +289 -54
  110. package/dist/server/auth/keys.js.map +1 -1
  111. package/dist/server/index.d.ts +5 -2
  112. package/dist/server/index.d.ts.map +1 -1
  113. package/dist/server/index.js +84 -9
  114. package/dist/server/index.js.map +1 -1
  115. package/dist/server/log-store.d.ts +102 -6
  116. package/dist/server/log-store.d.ts.map +1 -1
  117. package/dist/server/log-store.js +140 -32
  118. package/dist/server/log-store.js.map +1 -1
  119. package/dist/server/middleware/auth.js +1 -1
  120. package/dist/server/middleware/auth.js.map +1 -1
  121. package/dist/server/routes/broadcasts.d.ts.map +1 -1
  122. package/dist/server/routes/broadcasts.js +3 -1
  123. package/dist/server/routes/broadcasts.js.map +1 -1
  124. package/dist/server/routes/runs.js +1 -1
  125. package/dist/server/routes/runs.js.map +1 -1
  126. package/dist/server/routes/v1/definitions.d.ts +21 -0
  127. package/dist/server/routes/v1/definitions.d.ts.map +1 -0
  128. package/dist/server/routes/v1/definitions.js +139 -0
  129. package/dist/server/routes/v1/definitions.js.map +1 -0
  130. package/dist/server/routes/v1/expressions.d.ts +3 -0
  131. package/dist/server/routes/v1/expressions.d.ts.map +1 -0
  132. package/dist/server/routes/v1/expressions.js +56 -0
  133. package/dist/server/routes/v1/expressions.js.map +1 -0
  134. package/dist/server/routes/v1/keys.js +3 -3
  135. package/dist/server/routes/v1/keys.js.map +1 -1
  136. package/dist/server/routes/v1/runs.js +1 -1
  137. package/dist/server/routes/v1/runs.js.map +1 -1
  138. package/dist/server/routes/v1/schedules.d.ts +10 -0
  139. package/dist/server/routes/v1/schedules.d.ts.map +1 -0
  140. package/dist/server/routes/v1/schedules.js +169 -0
  141. package/dist/server/routes/v1/schedules.js.map +1 -0
  142. package/dist/server/routes/v1/trigger.d.ts.map +1 -1
  143. package/dist/server/routes/v1/trigger.js +21 -0
  144. package/dist/server/routes/v1/trigger.js.map +1 -1
  145. package/package.json +12 -9
  146. package/src/app/broadcasts/components/broadcast-builder.tsx +535 -0
  147. package/src/app/broadcasts/components/dag-editor.tsx +510 -0
  148. package/src/app/broadcasts/dyn/[name]/dynamic-detail.tsx +243 -0
  149. package/src/app/broadcasts/dyn/[name]/page.tsx +10 -0
  150. package/src/app/broadcasts/dyn/[name]/v/[n]/page.tsx +10 -0
  151. package/src/app/broadcasts/dyn/[name]/v/[n]/version-view.tsx +285 -0
  152. package/src/app/broadcasts/new/page.tsx +102 -0
  153. package/src/app/broadcasts/page.tsx +176 -91
  154. package/src/app/components/api-panel.tsx +151 -0
  155. package/src/app/components/shell.tsx +23 -0
  156. package/src/app/hooks/use-api.ts +117 -0
  157. package/src/app/playground/expression/page.tsx +245 -0
  158. package/src/app/schedules/[id]/page.tsx +10 -0
  159. package/src/app/schedules/[id]/schedule-editor.tsx +195 -0
  160. package/src/app/schedules/components/schedule-form.tsx +140 -0
  161. package/src/app/schedules/new/page.tsx +166 -0
  162. package/src/app/schedules/page.tsx +126 -0
  163. package/src/config/schema.ts +25 -0
  164. package/src/server/auth/keys.ts +348 -58
  165. package/src/server/index.ts +118 -11
  166. package/src/server/log-store.ts +196 -45
  167. package/src/server/middleware/auth.ts +1 -1
  168. package/src/server/routes/broadcasts.ts +3 -1
  169. package/src/server/routes/runs.ts +1 -1
  170. package/src/server/routes/v1/definitions.ts +164 -0
  171. package/src/server/routes/v1/expressions.ts +76 -0
  172. package/src/server/routes/v1/keys.ts +3 -3
  173. package/src/server/routes/v1/runs.ts +1 -1
  174. package/src/server/routes/v1/schedules.ts +176 -0
  175. package/src/server/routes/v1/trigger.ts +27 -0
  176. package/.next/standalone/packages/station-kit/.next/static/chunks/580-f007f4d4c050db4e.js +0 -1
  177. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/[id]/page-a0a20cccda13a0e9.js +0 -1
  178. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-937eb876f9087bc9.js +0 -1
  179. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-68cd71116ba65cd8.js +0 -1
  180. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-70b0c0958c03459a.js +0 -1
  181. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-01f8040619fe56c5.js +0 -1
  182. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-beac11049f90da31.js +0 -1
  183. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-931e6a38a4a53d25.js +0 -1
  184. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-6a123a355d93fec5.js +0 -1
  185. package/.next/standalone/packages/station-kit/.next/static/chunks/pages/_app-0a7b2e66ecbe3f0a.js +0 -1
  186. package/.next/standalone/packages/station-kit/.next/static/xYd6dn0Ox68DaamIrH_pB/_buildManifest.js +0 -1
  187. /package/.next/standalone/packages/station-kit/.next/static/{xYd6dn0Ox68DaamIrH_pB → THKSkCipW_pj0F6DRXYEG}/_ssgManifest.js +0 -0
@@ -1,56 +1,207 @@
1
- import Database from "better-sqlite3";
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { appendFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
2
4
  import type { LogEntry } from "./log-buffer.js";
3
5
 
4
- export class LogStore {
5
- private db: Database.Database;
6
- private insertStmt: Database.Statement;
7
- private selectStmt: Database.Statement;
8
-
9
- constructor(dbPath: string) {
10
- this.db = new Database(dbPath);
11
- this.db.pragma("journal_mode = WAL");
12
- this.db.exec(`
13
- CREATE TABLE IF NOT EXISTS logs (
14
- id INTEGER PRIMARY KEY AUTOINCREMENT,
15
- run_id TEXT NOT NULL,
16
- signal_name TEXT NOT NULL,
17
- level TEXT NOT NULL,
18
- message TEXT NOT NULL,
19
- timestamp TEXT NOT NULL
20
- )
21
- `);
22
- this.db.exec(`CREATE INDEX IF NOT EXISTS idx_logs_run_id ON logs(run_id)`);
23
-
24
- this.insertStmt = this.db.prepare(
25
- `INSERT INTO logs (run_id, signal_name, level, message, timestamp) VALUES (?, ?, ?, ?, ?)`,
26
- );
27
- this.selectStmt = this.db.prepare(
28
- `SELECT run_id, signal_name, level, message, timestamp FROM logs WHERE run_id = ? ORDER BY id`,
6
+ /**
7
+ * Pluggable storage backend for run logs. Implementations only persist
8
+ * and query records — bounded in-memory buffering for live UI streams
9
+ * lives in `LogBuffer`. May be sync or async; the LogStore wrapper
10
+ * normalizes both.
11
+ *
12
+ * Contract for implementers:
13
+ *
14
+ * - **`add(entry)`** is treated as fire-and-forget at the LogStore
15
+ * boundary. Signal runners never block on log writes. Adapters that
16
+ * need durability guarantees (queues, retries, batching) should
17
+ * implement that internally; thrown errors and rejected promises are
18
+ * caught and surfaced via the LogStore's `onError` hook (if set) but
19
+ * never rethrown to the caller.
20
+ * - **`get(runId)`** must return entries for that run in append order
21
+ * (oldest first). Routes that aggregate across runs may re-sort by
22
+ * timestamp, but per-run ordering is the adapter's responsibility.
23
+ * - **`close?()`** is called once on graceful shutdown. Use it to flush
24
+ * any in-flight buffers. It is NOT called on `SIGKILL` / OOM kill —
25
+ * adapters that must guarantee durability per write should not rely
26
+ * on it.
27
+ *
28
+ * Single-process semantics: the built-in `FileLogStorage` is safe for a
29
+ * single Node process. Running multiple processes against the same file
30
+ * path WILL produce interleaved bytes and lost entries use a real
31
+ * database adapter (Postgres, MySQL, Redis, etc.) for multi-process or
32
+ * distributed deployments.
33
+ */
34
+ export interface LogStorageAdapter {
35
+ add(entry: LogEntry): Promise<void> | void;
36
+ get(runId: string): Promise<LogEntry[]> | LogEntry[];
37
+ close?(): Promise<void> | void;
38
+ }
39
+
40
+ // ─── File-backed default ────────────────────────────────────────────
41
+
42
+ export interface FileLogStorageOptions {
43
+ filePath: string;
44
+ /**
45
+ * Called when a background write to the underlying file fails. Use
46
+ * this to surface persistence problems (disk full, permission denied,
47
+ * etc.) to your monitoring system. If unset, write failures are
48
+ * silently dropped — acceptable for local dev, NOT for production.
49
+ */
50
+ onError?: (err: unknown) => void;
51
+ }
52
+
53
+ /**
54
+ * File-backed log storage using append-only JSONL framing. Each line is
55
+ * a JSON-serialized `LogEntry`; existing entries are loaded into memory
56
+ * on construction; appends are serialized through an async write queue
57
+ * so concurrent writers can't interleave bytes within one process.
58
+ *
59
+ * No native dependencies — works on any Node 18+ install.
60
+ *
61
+ * **Production caveats** (in order of severity):
62
+ *
63
+ * 1. **Single-process only.** Two Node processes appending to the same
64
+ * file WILL interleave bytes once individual JSON lines exceed the
65
+ * OS pipe buffer (4 KB on Linux), corrupting the file.
66
+ * 2. **Best-effort durability.** Writes are queued and flushed via
67
+ * `fs.appendFile`; on `SIGKILL` / OOM kill, in-flight writes are lost.
68
+ * Set `onError` to surface fs failures.
69
+ * 3. **Unbounded memory on replay.** The whole file is loaded into a
70
+ * Map on startup. For high-volume deployments (gigabytes of logs)
71
+ * use a database-backed adapter instead.
72
+ *
73
+ * For multi-process, distributed, or high-durability deployments,
74
+ * implement `LogStorageAdapter` against Postgres / MySQL / Redis / S3.
75
+ */
76
+ export class FileLogStorage implements LogStorageAdapter {
77
+ private path: string;
78
+ private byRunId = new Map<string, LogEntry[]>();
79
+ private writeQueue: Promise<void> = Promise.resolve();
80
+ private onError: (err: unknown) => void;
81
+
82
+ constructor(options: FileLogStorageOptions) {
83
+ // Backwards compat: callers used to pass `.db` paths for the sqlite store.
84
+ // Transparently swap to `.jsonl` so existing config files keep working.
85
+ this.path = options.filePath.endsWith(".db")
86
+ ? options.filePath.replace(/\.db$/, ".jsonl")
87
+ : options.filePath;
88
+ this.onError = options.onError ?? (() => {});
89
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
90
+ this.replay();
91
+ }
92
+
93
+ private replay(): void {
94
+ if (!existsSync(this.path)) return;
95
+ let content: string;
96
+ try {
97
+ content = readFileSync(this.path, "utf8");
98
+ } catch (err) {
99
+ this.onError(err);
100
+ return;
101
+ }
102
+ const lines = content.split("\n");
103
+ for (const line of lines) {
104
+ if (!line) continue;
105
+ try {
106
+ const entry = JSON.parse(line) as LogEntry;
107
+ this.indexEntry(entry);
108
+ } catch {
109
+ // Skip malformed line; a partial write may have left a truncated tail.
110
+ }
111
+ }
112
+ }
113
+
114
+ private indexEntry(entry: LogEntry): void {
115
+ let entries = this.byRunId.get(entry.runId);
116
+ if (!entries) {
117
+ entries = [];
118
+ this.byRunId.set(entry.runId, entries);
119
+ }
120
+ entries.push(entry);
121
+ }
122
+
123
+ add(entry: LogEntry): void {
124
+ this.indexEntry(entry);
125
+ const line = JSON.stringify(entry) + "\n";
126
+ this.writeQueue = this.writeQueue.then(
127
+ () => appendFile(this.path, line, { mode: 0o600 }).catch((err) => {
128
+ this.onError(err);
129
+ }),
29
130
  );
30
131
  }
31
132
 
133
+ get(runId: string): LogEntry[] {
134
+ return this.byRunId.get(runId) ?? [];
135
+ }
136
+
137
+ async close(): Promise<void> {
138
+ await this.writeQueue;
139
+ }
140
+ }
141
+
142
+ // ─── In-memory storage for tests / ephemeral deployments ────────────
143
+
144
+ export class MemoryLogStorage implements LogStorageAdapter {
145
+ private byRunId = new Map<string, LogEntry[]>();
146
+
32
147
  add(entry: LogEntry): void {
33
- this.insertStmt.run(entry.runId, entry.signalName, entry.level, entry.message, entry.timestamp);
148
+ let entries = this.byRunId.get(entry.runId);
149
+ if (!entries) {
150
+ entries = [];
151
+ this.byRunId.set(entry.runId, entries);
152
+ }
153
+ entries.push(entry);
34
154
  }
35
155
 
36
156
  get(runId: string): LogEntry[] {
37
- const rows = this.selectStmt.all(runId) as Array<{
38
- run_id: string;
39
- signal_name: string;
40
- level: string;
41
- message: string;
42
- timestamp: string;
43
- }>;
44
- return rows.map((row) => ({
45
- runId: row.run_id,
46
- signalName: row.signal_name,
47
- level: row.level as "stdout" | "stderr",
48
- message: row.message,
49
- timestamp: row.timestamp,
50
- }));
51
- }
52
-
53
- close(): void {
54
- this.db.close();
157
+ return this.byRunId.get(runId) ?? [];
158
+ }
159
+ }
160
+
161
+ // ─── LogStore — thin wrapper that delegates to an adapter ───────────
162
+
163
+ /**
164
+ * LogStore is the consumer-facing handle that wraps a `LogStorageAdapter`.
165
+ * It exists so signal runners and route handlers can interact with a
166
+ * single concrete type, while the underlying persistence is swappable.
167
+ *
168
+ * `add` is fire-and-forget — adapter promises are caught at this boundary
169
+ * so a slow or failing log backend can never block (or crash) a signal
170
+ * runner. `get` always returns a Promise so callers can transparently
171
+ * support async backends (Postgres, Redis, etc.).
172
+ */
173
+ export class LogStore {
174
+ private storage: LogStorageAdapter;
175
+
176
+ /**
177
+ * Pass a `LogStorageAdapter` for any backend. The string overload is
178
+ * a shortcut for `new FileLogStorage({ filePath })` — useful for
179
+ * local dev and the default Station data directory.
180
+ */
181
+ constructor(storageOrPath: LogStorageAdapter | string) {
182
+ if (typeof storageOrPath === "string") {
183
+ this.storage = new FileLogStorage({ filePath: storageOrPath });
184
+ } else {
185
+ this.storage = storageOrPath;
186
+ }
187
+ }
188
+
189
+ add(entry: LogEntry): void {
190
+ try {
191
+ const result = this.storage.add(entry);
192
+ if (result && typeof (result as Promise<void>).catch === "function") {
193
+ (result as Promise<void>).catch(() => {});
194
+ }
195
+ } catch {
196
+ // Swallow sync throws; a broken log adapter must not crash signal runs.
197
+ }
198
+ }
199
+
200
+ async get(runId: string): Promise<LogEntry[]> {
201
+ return await this.storage.get(runId);
202
+ }
203
+
204
+ async close(): Promise<void> {
205
+ if (this.storage.close) await this.storage.close();
55
206
  }
56
207
  }
@@ -15,7 +15,7 @@ export function authResolver(deps: AuthDeps) {
15
15
  if (authHeader?.startsWith("Bearer ") && deps.keyStore) {
16
16
  const token = authHeader.slice(7);
17
17
  if (token.startsWith("sk_")) {
18
- const key = deps.keyStore.verify(token);
18
+ const key = await deps.keyStore.verify(token);
19
19
  if (key) {
20
20
  c.set("authType", "api-key");
21
21
  c.set("apiKeyId", key.id);
@@ -123,7 +123,9 @@ export function broadcastRoutes(deps: BroadcastDeps) {
123
123
  const allLogs: Array<{ runId: string; signalName: string; level: string; message: string; timestamp: string; nodeName: string }> = [];
124
124
  for (const nr of nodes) {
125
125
  if (nr.signalRunId) {
126
- const logs = deps.logStore?.get(nr.signalRunId) ?? deps.logBuffer?.get(nr.signalRunId) ?? [];
126
+ const logs = deps.logStore
127
+ ? await deps.logStore.get(nr.signalRunId)
128
+ : deps.logBuffer?.get(nr.signalRunId) ?? [];
127
129
  for (const log of logs) {
128
130
  allLogs.push({ ...log, nodeName: nr.nodeName });
129
131
  }
@@ -120,7 +120,7 @@ export function runRoutes(deps: RunDeps) {
120
120
 
121
121
  app.get("/runs/:id/logs", async (c) => {
122
122
  const id = c.req.param("id");
123
- const logs = deps.logStore?.get(id) ?? deps.logBuffer.get(id);
123
+ const logs = deps.logStore ? await deps.logStore.get(id) : deps.logBuffer.get(id);
124
124
  return c.json({ data: logs });
125
125
  });
126
126
 
@@ -0,0 +1,164 @@
1
+ import { Hono } from "hono";
2
+ import type {
3
+ BroadcastRunner,
4
+ BroadcastQueueAdapter,
5
+ DynamicBroadcastSpec,
6
+ } from "station-broadcast";
7
+ import { validateDynamicSpec, type DynamicValidationContext } from "station-broadcast";
8
+ import type { SignalRunner } from "station-signal";
9
+ import type { SchemaField } from "station-expressions";
10
+ import type { StationSignalSubscriber } from "../../subscriber.js";
11
+
12
+ export interface V1DefinitionDeps {
13
+ broadcastRunner?: BroadcastRunner;
14
+ broadcastAdapter?: BroadcastQueueAdapter;
15
+ signalRunner?: SignalRunner;
16
+ signalSubscriber?: StationSignalSubscriber;
17
+ }
18
+
19
+ /**
20
+ * Read-scope routes for dynamic broadcast definitions: list, get, version
21
+ * history, get-by-version, plus the read-only `validate` endpoint.
22
+ */
23
+ export function v1DefinitionReadRoutes(deps: V1DefinitionDeps) {
24
+ const app = new Hono();
25
+
26
+ app.get("/broadcast-definitions", async (c) => {
27
+ if (!deps.broadcastAdapter?.listDefinitions) return c.json({ data: [] });
28
+ const list = await deps.broadcastAdapter.listDefinitions();
29
+ return c.json({ data: list.map(serializeSpec) });
30
+ });
31
+
32
+ app.get("/broadcast-definitions/:name", async (c) => {
33
+ if (!deps.broadcastAdapter?.getDefinition) return c.json({ error: "unavailable" }, 503);
34
+ const spec = await deps.broadcastAdapter.getDefinition(c.req.param("name"));
35
+ if (!spec) return c.json({ error: "not_found" }, 404);
36
+ return c.json({ data: serializeSpec(spec) });
37
+ });
38
+
39
+ app.get("/broadcast-definitions/:name/versions", async (c) => {
40
+ if (!deps.broadcastAdapter?.listDefinitionVersions) return c.json({ data: [] });
41
+ const versions = await deps.broadcastAdapter.listDefinitionVersions(c.req.param("name"));
42
+ return c.json({ data: versions.map(serializeSpec) });
43
+ });
44
+
45
+ app.get("/broadcast-definitions/:name/versions/:n", async (c) => {
46
+ if (!deps.broadcastAdapter?.getDefinition) return c.json({ error: "unavailable" }, 503);
47
+ const version = parseInt(c.req.param("n"), 10);
48
+ if (Number.isNaN(version)) {
49
+ return c.json({ error: "bad_request", message: "Version must be a number." }, 400);
50
+ }
51
+ const spec = await deps.broadcastAdapter.getDefinition(c.req.param("name"), version);
52
+ if (!spec) return c.json({ error: "not_found" }, 404);
53
+ return c.json({ data: serializeSpec(spec) });
54
+ });
55
+
56
+ app.post("/broadcast-definitions/validate", async (c) => {
57
+ const body = await c.req.json().catch(() => ({}));
58
+ const spec = body as DynamicBroadcastSpec;
59
+ if (!spec || typeof spec !== "object" || typeof spec.name !== "string") {
60
+ return c.json({ error: "bad_request", message: "Spec is missing required fields." }, 400);
61
+ }
62
+ const ctx = buildValidationContext(deps);
63
+ const result = validateDynamicSpec(spec, ctx);
64
+ return c.json({ data: result });
65
+ });
66
+
67
+ return app;
68
+ }
69
+
70
+ /**
71
+ * Admin-scope routes for mutating dynamic broadcast definitions. GETs are
72
+ * mounted under read scope in server/index.ts.
73
+ */
74
+ export function v1DefinitionRoutes(deps: V1DefinitionDeps) {
75
+ const app = new Hono();
76
+
77
+ app.post("/broadcast-definitions", async (c) => {
78
+ if (!deps.broadcastAdapter?.saveDefinition) {
79
+ return c.json({ error: "unavailable", message: "Broadcast adapter does not support dynamic definitions." }, 503);
80
+ }
81
+ const body = await c.req.json().catch(() => ({}));
82
+ const incoming = body as DynamicBroadcastSpec;
83
+ if (!incoming?.name || !Array.isArray(incoming.nodes)) {
84
+ return c.json({ error: "bad_request", message: "Spec is missing required fields." }, 400);
85
+ }
86
+
87
+ const ctx = buildValidationContext(deps);
88
+ const validation = validateDynamicSpec(incoming, ctx);
89
+ if (!validation.ok) {
90
+ return c.json({ error: "validation_failed", data: validation }, 422);
91
+ }
92
+
93
+ const apiKeyId = c.get("apiKeyId" as never) as string | undefined;
94
+ const now = new Date();
95
+ const toSave: DynamicBroadcastSpec = {
96
+ ...incoming,
97
+ version: 0,
98
+ createdAt: incoming.createdAt ?? now,
99
+ updatedAt: now,
100
+ createdBy: apiKeyId,
101
+ failurePolicy: incoming.failurePolicy ?? "fail-fast",
102
+ };
103
+
104
+ const saved = await deps.broadcastAdapter.saveDefinition(toSave);
105
+
106
+ // Trigger an eager reconciliation so the new version is live immediately.
107
+ if (deps.broadcastRunner) {
108
+ void deps.broadcastRunner.reconcileDynamicDefinitions().catch(() => {});
109
+ }
110
+
111
+ return c.json({ data: serializeSpec(saved) }, 201);
112
+ });
113
+
114
+ // GET routes for definitions live in the read-scope mount in server/index.ts;
115
+ // this router only exposes mutating endpoints under admin scope.
116
+
117
+ app.delete("/broadcast-definitions/:name", async (c) => {
118
+ if (!deps.broadcastAdapter?.deleteDefinition) {
119
+ return c.json({ error: "unavailable" }, 503);
120
+ }
121
+ const success = await deps.broadcastAdapter.deleteDefinition(c.req.param("name"));
122
+ if (!success) return c.json({ error: "not_found" }, 404);
123
+ if (deps.broadcastRunner) {
124
+ void deps.broadcastRunner.reconcileDynamicDefinitions().catch(() => {});
125
+ }
126
+ return c.json({ data: { deleted: true } });
127
+ });
128
+
129
+ return app;
130
+ }
131
+
132
+ function serializeSpec(spec: DynamicBroadcastSpec): Record<string, unknown> {
133
+ return {
134
+ ...spec,
135
+ createdAt: spec.createdAt?.toISOString?.() ?? spec.createdAt,
136
+ updatedAt: spec.updatedAt?.toISOString?.() ?? spec.updatedAt,
137
+ deletedAt: spec.deletedAt?.toISOString?.() ?? spec.deletedAt,
138
+ };
139
+ }
140
+
141
+ function buildValidationContext(deps: V1DefinitionDeps): DynamicValidationContext {
142
+ const signalSchemas = new Map<string, { inputSchema: SchemaField; outputSchema: SchemaField }>();
143
+ // Best-effort schema reflection: we don't know the precise SchemaField for
144
+ // each signal here without traversing Zod schemas. Use `any` for now — the
145
+ // structural checks (signal exists, deps exist, no cycles, expression
146
+ // wellformedness) still run. Zod input validation runs at trigger time.
147
+ const sigs = deps.signalRunner?.getAllSignals();
148
+ if (sigs) {
149
+ for (const name of sigs.keys()) {
150
+ signalSchemas.set(name, {
151
+ inputSchema: { type: "any" },
152
+ outputSchema: { type: "any" },
153
+ });
154
+ }
155
+ } else if (deps.signalSubscriber) {
156
+ for (const meta of deps.signalSubscriber.getAllSignalMeta()) {
157
+ signalSchemas.set(meta.name, {
158
+ inputSchema: { type: "any" },
159
+ outputSchema: { type: "any" },
160
+ });
161
+ }
162
+ }
163
+ return { signalSchemas };
164
+ }
@@ -0,0 +1,76 @@
1
+ import { Hono } from "hono";
2
+ import {
3
+ evaluate,
4
+ validate,
5
+ parse,
6
+ ExpressionEvalError,
7
+ ExpressionParseError,
8
+ type ExprNode,
9
+ type SchemaField,
10
+ } from "station-expressions";
11
+
12
+ export function v1ExpressionRoutes() {
13
+ const app = new Hono();
14
+
15
+ app.post("/expressions/evaluate", async (c) => {
16
+ const body = await c.req.json().catch(() => ({}));
17
+ const { node, context } = body as {
18
+ node?: ExprNode;
19
+ context?: { input?: unknown; upstream?: Record<string, unknown> };
20
+ };
21
+ if (!node) {
22
+ return c.json({ error: "bad_request", message: "Missing `node`." }, 400);
23
+ }
24
+ try {
25
+ const result = evaluate(node, {
26
+ input: context?.input,
27
+ upstream: context?.upstream ?? {},
28
+ });
29
+ return c.json({ data: { value: result } });
30
+ } catch (err) {
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ const status = err instanceof ExpressionEvalError ? 400 : 500;
33
+ return c.json({ error: "evaluation_failed", message }, status);
34
+ }
35
+ });
36
+
37
+ app.post("/expressions/validate", async (c) => {
38
+ const body = await c.req.json().catch(() => ({}));
39
+ const { node, schemaContext } = body as {
40
+ node?: ExprNode;
41
+ schemaContext?: {
42
+ inputSchema?: SchemaField;
43
+ upstreamSchemas?: Record<string, SchemaField>;
44
+ expectedSchema?: SchemaField;
45
+ };
46
+ };
47
+ if (!node) {
48
+ return c.json({ error: "bad_request", message: "Missing `node`." }, 400);
49
+ }
50
+ const result = validate(node, {
51
+ inputSchema: schemaContext?.inputSchema ?? { type: "any" },
52
+ upstreamSchemas: schemaContext?.upstreamSchemas ?? {},
53
+ expectedSchema: schemaContext?.expectedSchema,
54
+ });
55
+ return c.json({ data: result });
56
+ });
57
+
58
+ app.post("/expressions/parse", async (c) => {
59
+ const body = await c.req.json().catch(() => ({}));
60
+ const { source } = body as { source?: string };
61
+ if (typeof source !== "string") {
62
+ return c.json({ error: "bad_request", message: "Missing `source` string." }, 400);
63
+ }
64
+ try {
65
+ const node = parse(source);
66
+ return c.json({ data: { node } });
67
+ } catch (err) {
68
+ if (err instanceof ExpressionParseError) {
69
+ return c.json({ error: "parse_error", message: err.message, position: err.position }, 400);
70
+ }
71
+ return c.json({ error: "parse_error", message: err instanceof Error ? err.message : String(err) }, 400);
72
+ }
73
+ });
74
+
75
+ return app;
76
+ }
@@ -17,7 +17,7 @@ export function v1KeyRoutes(deps: V1KeyDeps) {
17
17
  const name = body.name || "Unnamed key";
18
18
  const scopes = Array.isArray(body.scopes) ? body.scopes : ["trigger", "read"];
19
19
 
20
- const { key, record } = deps.keyStore.create(name, scopes);
20
+ const { key, record } = await deps.keyStore.create(name, scopes);
21
21
  return c.json(
22
22
  {
23
23
  data: {
@@ -37,7 +37,7 @@ export function v1KeyRoutes(deps: V1KeyDeps) {
37
37
  if (!deps.keyStore) {
38
38
  return c.json({ error: "unavailable", message: "Auth not configured." }, 503);
39
39
  }
40
- const keys = deps.keyStore.list();
40
+ const keys = await deps.keyStore.list();
41
41
  return c.json({ data: keys });
42
42
  });
43
43
 
@@ -46,7 +46,7 @@ export function v1KeyRoutes(deps: V1KeyDeps) {
46
46
  return c.json({ error: "unavailable", message: "Auth not configured." }, 503);
47
47
  }
48
48
  const id = c.req.param("id");
49
- const success = deps.keyStore.revoke(id);
49
+ const success = await deps.keyStore.revoke(id);
50
50
  if (!success) {
51
51
  return c.json({ error: "not_found", message: "Key not found." }, 404);
52
52
  }
@@ -78,7 +78,7 @@ export function v1RunRoutes(deps: V1RunDeps) {
78
78
 
79
79
  app.get("/runs/:id/logs", async (c) => {
80
80
  const id = c.req.param("id");
81
- const logs = deps.logStore?.get(id) ?? deps.logBuffer.get(id);
81
+ const logs = deps.logStore ? await deps.logStore.get(id) : deps.logBuffer.get(id);
82
82
  return c.json({ data: logs });
83
83
  });
84
84