station-kit 1.0.9 → 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 (75) hide show
  1. package/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
  2. package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +29 -29
  3. package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +7 -7
  4. package/.next/standalone/packages/station-kit/.next/build-manifest.json +2 -2
  5. package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +12 -12
  6. package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  7. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.html +1 -1
  8. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.rsc +1 -1
  9. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page_client-reference-manifest.js +1 -1
  10. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -1
  13. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -1
  14. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +1 -1
  15. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.html +1 -1
  17. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.rsc +1 -1
  18. package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
  19. package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +1 -1
  20. package/.next/standalone/packages/station-kit/.next/server/app/page_client-reference-manifest.js +1 -1
  21. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -1
  22. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -1
  23. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +1 -1
  24. package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page_client-reference-manifest.js +1 -1
  25. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -1
  26. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -1
  27. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -1
  28. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +1 -1
  29. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
  30. package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -1
  31. package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +1 -1
  32. package/.next/standalone/packages/station-kit/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  33. package/.next/standalone/packages/station-kit/.next/server/app/settings.html +1 -1
  34. package/.next/standalone/packages/station-kit/.next/server/app/settings.rsc +1 -1
  35. package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page_client-reference-manifest.js +1 -1
  36. package/.next/standalone/packages/station-kit/.next/server/app/signals/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/packages/station-kit/.next/server/app/signals.html +1 -1
  38. package/.next/standalone/packages/station-kit/.next/server/app/signals.rsc +1 -1
  39. package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +7 -7
  40. package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
  41. package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
  42. package/.next/standalone/packages/station-kit/package.json +3 -4
  43. package/dist/config/schema.d.ts +13 -3
  44. package/dist/config/schema.d.ts.map +1 -1
  45. package/dist/config/schema.js +1 -0
  46. package/dist/config/schema.js.map +1 -1
  47. package/dist/server/auth/keys.d.ts +41 -6
  48. package/dist/server/auth/keys.d.ts.map +1 -1
  49. package/dist/server/auth/keys.js +143 -10
  50. package/dist/server/auth/keys.js.map +1 -1
  51. package/dist/server/index.d.ts +5 -2
  52. package/dist/server/index.d.ts.map +1 -1
  53. package/dist/server/index.js +34 -6
  54. package/dist/server/index.js.map +1 -1
  55. package/dist/server/log-store.d.ts +102 -6
  56. package/dist/server/log-store.d.ts.map +1 -1
  57. package/dist/server/log-store.js +140 -32
  58. package/dist/server/log-store.js.map +1 -1
  59. package/dist/server/routes/broadcasts.d.ts.map +1 -1
  60. package/dist/server/routes/broadcasts.js +3 -1
  61. package/dist/server/routes/broadcasts.js.map +1 -1
  62. package/dist/server/routes/runs.js +1 -1
  63. package/dist/server/routes/runs.js.map +1 -1
  64. package/dist/server/routes/v1/runs.js +1 -1
  65. package/dist/server/routes/v1/runs.js.map +1 -1
  66. package/package.json +4 -5
  67. package/src/config/schema.ts +14 -3
  68. package/src/server/auth/keys.ts +167 -12
  69. package/src/server/index.ts +48 -5
  70. package/src/server/log-store.ts +196 -45
  71. package/src/server/routes/broadcasts.ts +3 -1
  72. package/src/server/routes/runs.ts +1 -1
  73. package/src/server/routes/v1/runs.ts +1 -1
  74. /package/.next/standalone/packages/station-kit/.next/static/{demLiQWDy62JuUkBw-ILG → THKSkCipW_pj0F6DRXYEG}/_buildManifest.js +0 -0
  75. /package/.next/standalone/packages/station-kit/.next/static/{demLiQWDy62JuUkBw-ILG → THKSkCipW_pj0F6DRXYEG}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "station-kit",
3
- "version": "1.0.9",
3
+ "version": "1.1.0",
4
4
  "description": "Dashboard for station-signal — inspect and control signals and broadcasts",
5
5
  "type": "module",
6
6
  "engines": {
@@ -35,7 +35,6 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@hono/node-server": "^1",
38
- "better-sqlite3": "^11.9.1",
39
38
  "esbuild": "^0.25.12",
40
39
  "hono": "^4",
41
40
  "next": "^15",
@@ -47,20 +46,20 @@
47
46
  "station-schedules": "1.0.4"
48
47
  },
49
48
  "devDependencies": {
50
- "@types/better-sqlite3": "^7.6.13",
51
49
  "@types/node": "^25.3.0",
52
50
  "@types/react": "^19",
53
51
  "@types/react-dom": "^19",
54
52
  "@types/ws": "^8",
53
+ "better-sqlite3": "^11.9.1",
55
54
  "typescript": "^5.9.3",
56
- "station-broadcast": "1.0.4",
57
55
  "station-signal": "1.0.4",
56
+ "station-broadcast": "1.0.4",
58
57
  "station-adapter-sqlite": "1.0.4"
59
58
  },
60
59
  "scripts": {
61
60
  "build": "tsc && next build && cp -r .next/static .next/standalone/packages/station-kit/.next/static",
62
61
  "dev": "tsx src/cli.ts",
63
62
  "typecheck": "tsc --noEmit",
64
- "test": "node --import tsx --test test/**/*.test.ts"
63
+ "test": "node --import tsx --test \"test/**/*.test.ts\""
65
64
  }
66
65
  }
@@ -2,15 +2,17 @@ import type { SignalQueueAdapter } from "station-signal";
2
2
  import type { BroadcastQueueAdapter } from "station-broadcast";
3
3
  import type { ScheduleAdapter } from "station-schedules";
4
4
  import type { ApiKeyStorageAdapter } from "../server/auth/keys.js";
5
+ import type { LogStorageAdapter } from "../server/log-store.js";
5
6
 
6
7
  export interface AuthConfig {
7
8
  username: string;
8
9
  password: string;
9
10
  sessionTtlMs?: number;
10
11
  /**
11
- * Pluggable storage backend for API keys. Defaults to a SQLite store at
12
- * `<dataDir>/station-keys.db`. Provide a custom adapter to host keys in
13
- * Postgres, MySQL, Redis, etc.
12
+ * Pluggable storage backend for API keys. Defaults to a JSON file at
13
+ * `<dataDir>/station-keys.json` (no native dependencies required).
14
+ * Provide a custom adapter to host keys in SQLite, Postgres, MySQL,
15
+ * Redis, etc.
14
16
  */
15
17
  keyStorage?: ApiKeyStorageAdapter;
16
18
  }
@@ -40,6 +42,14 @@ export interface StationConfig {
40
42
  * schedules are persisted here and reconciled by both runners.
41
43
  */
42
44
  scheduleAdapter?: ScheduleAdapter;
45
+ /**
46
+ * Pluggable storage backend for run logs. Defaults to a `FileLogStorage`
47
+ * (append-only JSONL file at `<dataDir>/station-logs.jsonl`, no native
48
+ * dependencies). The default is single-process only — for multi-process
49
+ * deployments or guaranteed durability, implement `LogStorageAdapter`
50
+ * against Postgres, MySQL, Redis, S3, etc., and pass it here.
51
+ */
52
+ logStorage?: LogStorageAdapter;
43
53
  signalsDir?: string;
44
54
  broadcastsDir?: string;
45
55
  stationDir: string;
@@ -99,6 +109,7 @@ export function resolveConfig(input: StationUserConfig): StationConfig {
99
109
  adapter: input.adapter,
100
110
  broadcastAdapter: input.broadcastAdapter,
101
111
  scheduleAdapter: input.scheduleAdapter,
112
+ logStorage: input.logStorage,
102
113
  signalsDir: input.signalsDir,
103
114
  broadcastsDir: input.broadcastsDir,
104
115
  stationDir: input.stationDir ?? DEFAULTS.stationDir,
@@ -1,5 +1,17 @@
1
1
  import crypto from "node:crypto";
2
- import Database from "better-sqlite3";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ fsyncSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ writeFileSync,
11
+ writeSync,
12
+ } from "node:fs";
13
+ import { createRequire } from "node:module";
14
+ import { dirname } from "node:path";
3
15
 
4
16
  export interface ApiKey {
5
17
  id: string;
@@ -29,7 +41,116 @@ export interface ApiKeyStorageAdapter {
29
41
  close?(): Promise<void> | void;
30
42
  }
31
43
 
32
- // ─── SQLite default ─────────────────────────────────────────────────
44
+ // ─── JSON file default ──────────────────────────────────────────────
45
+
46
+ export interface FileKeyStorageOptions {
47
+ filePath: string;
48
+ }
49
+
50
+ /**
51
+ * Default ApiKeyStorageAdapter backed by a JSON file. Used by the Station
52
+ * server when no `keyStorage` is configured. Has no native dependencies —
53
+ * works on any Node 18+ install without compiling bindings.
54
+ *
55
+ * Crash-safety: writes go through a fsync'd tmp-file + rename, with a
56
+ * second fsync on the parent directory so the rename itself survives
57
+ * power loss. The keys file is created with `0o600` and the parent dir
58
+ * with `0o700` so a default umask doesn't expose key metadata.
59
+ *
60
+ * Single-process only: do not point two `createStation` instances at
61
+ * the same file or last-rename-wins will silently clobber writes. For
62
+ * multi-process or high-volume deployments, implement
63
+ * `ApiKeyStorageAdapter` against Postgres / MySQL / Redis.
64
+ */
65
+ export class FileKeyStorage implements ApiKeyStorageAdapter {
66
+ private filePath: string;
67
+ private records = new Map<string, ApiKey>();
68
+
69
+ constructor(options: FileKeyStorageOptions) {
70
+ this.filePath = options.filePath;
71
+ mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
72
+ this.load();
73
+ }
74
+
75
+ private load(): void {
76
+ if (!existsSync(this.filePath)) return;
77
+ try {
78
+ const raw = readFileSync(this.filePath, "utf8");
79
+ const data = JSON.parse(raw) as ApiKey[];
80
+ if (Array.isArray(data)) {
81
+ for (const r of data) this.records.set(r.id, r);
82
+ }
83
+ } catch {
84
+ // Corrupt or unreadable file — start fresh rather than throwing.
85
+ }
86
+ }
87
+
88
+ private flush(): void {
89
+ const tmp = `${this.filePath}.tmp`;
90
+ const body = JSON.stringify(Array.from(this.records.values()), null, 2);
91
+ // Write tmp file with fsync so its bytes are durable before rename.
92
+ const fd = openSync(tmp, "w", 0o600);
93
+ try {
94
+ writeSync(fd, body);
95
+ fsyncSync(fd);
96
+ } finally {
97
+ closeSync(fd);
98
+ }
99
+ renameSync(tmp, this.filePath);
100
+ // fsync the parent directory so the rename's directory entry survives
101
+ // a crash. Best-effort: opening a directory for fsync isn't supported
102
+ // on every platform (notably Windows), so swallow errors.
103
+ try {
104
+ const dirFd = openSync(dirname(this.filePath), "r");
105
+ try {
106
+ fsyncSync(dirFd);
107
+ } finally {
108
+ closeSync(dirFd);
109
+ }
110
+ } catch {
111
+ // Platform doesn't support directory fsync; rename + tmp fsync
112
+ // already give us most of the durability we can offer.
113
+ }
114
+ }
115
+
116
+ insert(record: ApiKey): void {
117
+ this.records.set(record.id, { ...record });
118
+ this.flush();
119
+ }
120
+
121
+ findByHash(keyHash: string): ApiKey | null {
122
+ for (const r of this.records.values()) {
123
+ if (r.keyHash === keyHash) return { ...r };
124
+ }
125
+ return null;
126
+ }
127
+
128
+ list(): ApiKeyPublic[] {
129
+ return Array.from(this.records.values())
130
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
131
+ .map((r) => {
132
+ const { keyHash: _h, ...rest } = r;
133
+ return rest;
134
+ });
135
+ }
136
+
137
+ touch(id: string, lastUsedIso: string): void {
138
+ const r = this.records.get(id);
139
+ if (!r) return;
140
+ r.lastUsed = lastUsedIso;
141
+ this.flush();
142
+ }
143
+
144
+ revoke(id: string): boolean {
145
+ const r = this.records.get(id);
146
+ if (!r) return false;
147
+ r.revoked = true;
148
+ this.flush();
149
+ return true;
150
+ }
151
+ }
152
+
153
+ // ─── SQLite (optional) ──────────────────────────────────────────────
33
154
 
34
155
  export interface SqliteKeyStorageOptions {
35
156
  dbPath: string;
@@ -37,13 +158,42 @@ export interface SqliteKeyStorageOptions {
37
158
  tableName?: string;
38
159
  }
39
160
 
161
+ // Loaded lazily from `better-sqlite3` so the package isn't required at
162
+ // install time. Users who don't construct SqliteKeyStorage never pay for it.
163
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
+ type BetterSqlite3Module = any;
165
+ let cachedBetterSqlite3: BetterSqlite3Module | null = null;
166
+
167
+ function loadBetterSqlite3(): BetterSqlite3Module {
168
+ if (cachedBetterSqlite3) return cachedBetterSqlite3;
169
+ try {
170
+ const requireFn = createRequire(import.meta.url);
171
+ cachedBetterSqlite3 = requireFn("better-sqlite3");
172
+ return cachedBetterSqlite3;
173
+ } catch (err) {
174
+ const reason = err instanceof Error ? err.message : String(err);
175
+ throw new Error(
176
+ `SqliteKeyStorage requires the optional 'better-sqlite3' package, ` +
177
+ `which isn't installed. Install it with:\n` +
178
+ ` npm install better-sqlite3\n` +
179
+ `Or use FileKeyStorage (default) / MemoryKeyStorage instead.\n` +
180
+ `Underlying error: ${reason}`,
181
+ );
182
+ }
183
+ }
184
+
40
185
  /**
41
- * Default ApiKeyStorageAdapter backed by better-sqlite3. Used by the Station
42
- * server when no `keyStorage` is configured. For Postgres / MySQL / Redis,
43
- * implement `ApiKeyStorageAdapter` and pass it to `KeyStore` directly.
186
+ * Optional ApiKeyStorageAdapter backed by better-sqlite3. Requires the
187
+ * `better-sqlite3` package to be installed separately Station Kit no
188
+ * longer ships it as a hard dependency.
189
+ *
190
+ * Prefer `FileKeyStorage` (the default) unless you specifically need
191
+ * sqlite features (concurrent reads from multiple processes, large
192
+ * key catalogs, etc.).
44
193
  */
45
194
  export class SqliteKeyStorage implements ApiKeyStorageAdapter {
46
- private db: Database.Database;
195
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
196
+ private db: any;
47
197
  private table: string;
48
198
 
49
199
  constructor(options: SqliteKeyStorageOptions) {
@@ -52,6 +202,7 @@ export class SqliteKeyStorage implements ApiKeyStorageAdapter {
52
202
  throw new Error(`Invalid table name "${tableName}"`);
53
203
  }
54
204
  this.table = tableName;
205
+ const Database = loadBetterSqlite3();
55
206
  this.db = new Database(options.dbPath);
56
207
  this.db.pragma("journal_mode = WAL");
57
208
  this.db.exec(`
@@ -186,14 +337,18 @@ export class KeyStore {
186
337
 
187
338
  /**
188
339
  * Pass an `ApiKeyStorageAdapter` for any backend. The string overload is
189
- * retained for backwards compatibility — it constructs a SqliteKeyStorage
190
- * at the given path.
340
+ * retained for backwards compatibility — it constructs a FileKeyStorage
341
+ * at the given path. (Previously this returned a SqliteKeyStorage; SQLite
342
+ * is now opt-in to avoid native build dependencies.)
191
343
  */
192
- constructor(storageOrDbPath: ApiKeyStorageAdapter | string) {
193
- if (typeof storageOrDbPath === "string") {
194
- this.storage = new SqliteKeyStorage({ dbPath: storageOrDbPath });
344
+ constructor(storageOrPath: ApiKeyStorageAdapter | string) {
345
+ if (typeof storageOrPath === "string") {
346
+ const filePath = storageOrPath.endsWith(".db")
347
+ ? storageOrPath.replace(/\.db$/, ".json")
348
+ : storageOrPath;
349
+ this.storage = new FileKeyStorage({ filePath });
195
350
  } else {
196
- this.storage = storageOrDbPath;
351
+ this.storage = storageOrPath;
197
352
  }
198
353
  }
199
354
 
@@ -18,13 +18,13 @@ import { ensureStationDir } from "../station-dir.js";
18
18
  import { WebSocketHub } from "./ws.js";
19
19
  import { SSEHub } from "./sse.js";
20
20
  import { LogBuffer } from "./log-buffer.js";
21
- import { LogStore } from "./log-store.js";
21
+ import { LogStore, FileLogStorage } from "./log-store.js";
22
22
  import { StationSignalSubscriber, StationBroadcastSubscriber } from "./subscriber.js";
23
23
  import { healthRoutes } from "./routes/health.js";
24
24
  import { signalRoutes } from "./routes/signals.js";
25
25
  import { runRoutes } from "./routes/runs.js";
26
26
  import { broadcastRoutes } from "./routes/broadcasts.js";
27
- import { KeyStore, SqliteKeyStorage } from "./auth/keys.js";
27
+ import { KeyStore, FileKeyStorage } from "./auth/keys.js";
28
28
  import { verifySessionToken, verifyCredentials, createSessionToken, type SessionConfig } from "./auth/session.js";
29
29
  import { authResolver } from "./middleware/auth.js";
30
30
  import { requireScope } from "./middleware/scope-guard.js";
@@ -43,6 +43,7 @@ import { v1ExpressionRoutes } from "./routes/v1/expressions.js";
43
43
 
44
44
  export {
45
45
  KeyStore,
46
+ FileKeyStorage,
46
47
  SqliteKeyStorage,
47
48
  MemoryKeyStorage,
48
49
  } from "./auth/keys.js";
@@ -50,8 +51,19 @@ export type {
50
51
  ApiKey,
51
52
  ApiKeyPublic,
52
53
  ApiKeyStorageAdapter,
54
+ FileKeyStorageOptions,
53
55
  SqliteKeyStorageOptions,
54
56
  } from "./auth/keys.js";
57
+ export {
58
+ LogStore,
59
+ FileLogStorage,
60
+ MemoryLogStorage,
61
+ } from "./log-store.js";
62
+ export type {
63
+ LogStorageAdapter,
64
+ FileLogStorageOptions,
65
+ } from "./log-store.js";
66
+ export type { LogEntry } from "./log-buffer.js";
55
67
 
56
68
  export interface StationInstance {
57
69
  start(): Promise<void>;
@@ -69,10 +81,17 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
69
81
 
70
82
  const { dataDir } = ensureStationDir(cwd, config.stationDir);
71
83
 
84
+ warnIfLegacySqliteFiles(dataDir);
85
+
72
86
  const wsHub = new WebSocketHub();
73
87
  const sseHub = new SSEHub();
74
88
  const logBuffer = new LogBuffer();
75
- const logStore = new LogStore(resolve(dataDir, "station-logs.db"));
89
+ const logStore = new LogStore(
90
+ config.logStorage ?? new FileLogStorage({
91
+ filePath: resolve(dataDir, "station-logs.jsonl"),
92
+ onError: (err) => console.error("[station] log write failed:", err),
93
+ }),
94
+ );
76
95
 
77
96
  // Auth: create KeyStore and SessionConfig if auth is configured
78
97
  let keyStore: KeyStore | undefined;
@@ -80,7 +99,7 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
80
99
 
81
100
  if (config.auth) {
82
101
  const storage = config.auth.keyStorage
83
- ?? new SqliteKeyStorage({ dbPath: resolve(dataDir, "station-keys.db") });
102
+ ?? new FileKeyStorage({ filePath: resolve(dataDir, "station-keys.json") });
84
103
  keyStore = new KeyStore(storage);
85
104
  sessionConfig = {
86
105
  username: config.auth.username,
@@ -401,7 +420,7 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
401
420
  }
402
421
  wsHub.close();
403
422
  sseHub.close();
404
- logStore.close();
423
+ await logStore.close();
405
424
  await keyStore?.close();
406
425
  if (httpServer) {
407
426
  httpServer.close();
@@ -409,3 +428,27 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
409
428
  },
410
429
  };
411
430
  }
431
+
432
+ // Existing deployments that ran older Station versions persisted keys
433
+ // to `station-keys.db` (SQLite) and run logs to `station-logs.db`. The
434
+ // new defaults are `station-keys.json` and `station-logs.jsonl`; the
435
+ // legacy files are NOT auto-migrated. Emit a one-time warning so an
436
+ // upgrade doesn't silently appear to wipe a user's API keys.
437
+ function warnIfLegacySqliteFiles(dataDir: string): void {
438
+ const legacy: { file: string; replacement: string }[] = [
439
+ { file: "station-keys.db", replacement: "station-keys.json" },
440
+ { file: "station-logs.db", replacement: "station-logs.jsonl" },
441
+ ];
442
+ for (const { file, replacement } of legacy) {
443
+ const legacyPath = resolve(dataDir, file);
444
+ if (!existsSync(legacyPath)) continue;
445
+ const replacementPath = resolve(dataDir, replacement);
446
+ if (existsSync(replacementPath)) continue;
447
+ console.warn(
448
+ `[station] Legacy ${file} detected at ${legacyPath} but no ${replacement} found. ` +
449
+ `Station no longer reads SQLite-backed defaults; data in ${file} will not be loaded. ` +
450
+ `If you need the contents, export them with the better-sqlite3 CLI before upgrading. ` +
451
+ `To suppress this warning, delete or rename ${file}.`,
452
+ );
453
+ }
454
+ }
@@ -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
  }
@@ -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