@wopr-network/defcon 1.11.0 → 1.12.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.
@@ -15,6 +15,7 @@ import { resolveCorsOrigin } from "../cors.js";
15
15
  import { DomainEventPersistAdapter } from "../engine/domain-event-adapter.js";
16
16
  import { Engine } from "../engine/engine.js";
17
17
  import { EventEmitter } from "../engine/event-emitter.js";
18
+ import { buildConfigFromEnv, isLitestreamEnabled, LitestreamManager } from "../litestream/manager.js";
18
19
  import { withTransaction } from "../main.js";
19
20
  import { DrizzleDomainEventRepository } from "../repositories/drizzle/domain-event.repo.js";
20
21
  import { DrizzleEntityRepository } from "../repositories/drizzle/entity.repo.js";
@@ -158,7 +159,18 @@ program
158
159
  .option("--http-host <address>", "Host for HTTP REST API", "127.0.0.1")
159
160
  .option("--ui", "Enable built-in web UI at /ui")
160
161
  .action(async (opts) => {
162
+ // Litestream: restore from replica if DB missing and replication configured
163
+ let litestreamMgr;
164
+ if (isLitestreamEnabled()) {
165
+ const lsConfig = buildConfigFromEnv(opts.db);
166
+ litestreamMgr = new LitestreamManager(lsConfig);
167
+ litestreamMgr.restore();
168
+ }
161
169
  const { db, sqlite } = openDb(opts.db);
170
+ // Litestream: start continuous replication
171
+ if (litestreamMgr) {
172
+ litestreamMgr.start();
173
+ }
162
174
  const entityRepo = new DrizzleEntityRepository(db);
163
175
  const flowRepo = new DrizzleFlowRepository(db);
164
176
  const invocationRepo = new DrizzleInvocationRepository(db);
@@ -359,7 +371,12 @@ program
359
371
  });
360
372
  const shutdown = makeShutdownHandler({
361
373
  stopReaper,
362
- closeables: [{ close: () => restHttpServer?.close() }, httpServer, sqlite],
374
+ closeables: [
375
+ ...(litestreamMgr ? [litestreamMgr] : []),
376
+ { close: () => restHttpServer?.close() },
377
+ httpServer,
378
+ sqlite,
379
+ ],
363
380
  });
364
381
  process.once("SIGINT", shutdown);
365
382
  process.once("SIGTERM", shutdown);
@@ -367,7 +384,10 @@ program
367
384
  else if (startMcp) {
368
385
  // stdio (default)
369
386
  console.error("Starting MCP server on stdio...");
370
- const cleanup = makeShutdownHandler({ stopReaper, closeables: [sqlite] });
387
+ const cleanup = makeShutdownHandler({
388
+ stopReaper,
389
+ closeables: [...(litestreamMgr ? [litestreamMgr] : []), sqlite],
390
+ });
371
391
  process.once("SIGINT", cleanup);
372
392
  process.once("SIGTERM", cleanup);
373
393
  const mcpOpts = { adminToken, workerToken, stdioTrusted: true };
@@ -377,7 +397,7 @@ program
377
397
  // HTTP-only mode — keep process alive
378
398
  const cleanup = makeShutdownHandler({
379
399
  stopReaper,
380
- closeables: [{ close: () => restHttpServer?.close() }, sqlite],
400
+ closeables: [...(litestreamMgr ? [litestreamMgr] : []), { close: () => restHttpServer?.close() }, sqlite],
381
401
  });
382
402
  process.once("SIGINT", cleanup);
383
403
  process.once("SIGTERM", cleanup);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { buildConfigFromEnv } from "./manager.js";
3
+ describe("buildConfigFromEnv", () => {
4
+ const originalEnv = process.env;
5
+ beforeEach(() => {
6
+ process.env = { ...originalEnv };
7
+ });
8
+ afterEach(() => {
9
+ process.env = originalEnv;
10
+ });
11
+ it("builds config from env vars with defaults", () => {
12
+ process.env.DEFCON_LITESTREAM_REPLICA_URL = "s3://bucket/db";
13
+ process.env.DEFCON_LITESTREAM_ACCESS_KEY_ID = "AKIA";
14
+ process.env.DEFCON_LITESTREAM_SECRET_ACCESS_KEY = "secret";
15
+ const config = buildConfigFromEnv("./defcon.db");
16
+ expect(config.replicaUrl).toBe("s3://bucket/db");
17
+ expect(config.region).toBe("us-east-1");
18
+ expect(config.retention).toBe("24h");
19
+ expect(config.syncInterval).toBe("1s");
20
+ expect(config.endpoint).toBeUndefined();
21
+ });
22
+ it("throws if access keys missing", () => {
23
+ process.env.DEFCON_LITESTREAM_REPLICA_URL = "s3://bucket/db";
24
+ delete process.env.DEFCON_LITESTREAM_ACCESS_KEY_ID;
25
+ expect(() => buildConfigFromEnv("./defcon.db")).toThrow("DEFCON_LITESTREAM_ACCESS_KEY_ID");
26
+ });
27
+ it("includes custom endpoint", () => {
28
+ process.env.DEFCON_LITESTREAM_REPLICA_URL = "s3://bucket/db";
29
+ process.env.DEFCON_LITESTREAM_ACCESS_KEY_ID = "AKIA";
30
+ process.env.DEFCON_LITESTREAM_SECRET_ACCESS_KEY = "secret";
31
+ process.env.DEFCON_LITESTREAM_ENDPOINT = "https://r2.example.com";
32
+ const config = buildConfigFromEnv("./defcon.db");
33
+ expect(config.endpoint).toBe("https://r2.example.com");
34
+ });
35
+ });
@@ -0,0 +1,23 @@
1
+ export interface LitestreamConfig {
2
+ dbPath: string;
3
+ replicaUrl: string;
4
+ accessKeyId: string;
5
+ secretAccessKey: string;
6
+ endpoint?: string;
7
+ region: string;
8
+ retention: string;
9
+ syncInterval: string;
10
+ }
11
+ export declare function isLitestreamEnabled(): boolean;
12
+ export declare function buildConfigFromEnv(dbPath: string): LitestreamConfig;
13
+ export declare class LitestreamManager {
14
+ private config;
15
+ private configPath;
16
+ private child;
17
+ constructor(config: LitestreamConfig);
18
+ generateConfig(): string;
19
+ restore(): void;
20
+ start(): void;
21
+ close(): Promise<void>;
22
+ private writeConfig;
23
+ }
@@ -0,0 +1,123 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ export function isLitestreamEnabled() {
6
+ return !!process.env.DEFCON_LITESTREAM_REPLICA_URL?.trim();
7
+ }
8
+ export function buildConfigFromEnv(dbPath) {
9
+ const replicaUrl = process.env.DEFCON_LITESTREAM_REPLICA_URL ?? "";
10
+ const accessKeyId = process.env.DEFCON_LITESTREAM_ACCESS_KEY_ID;
11
+ const secretAccessKey = process.env.DEFCON_LITESTREAM_SECRET_ACCESS_KEY;
12
+ if (!accessKeyId || !secretAccessKey) {
13
+ throw new Error("DEFCON_LITESTREAM_ACCESS_KEY_ID and DEFCON_LITESTREAM_SECRET_ACCESS_KEY must be set when DEFCON_LITESTREAM_REPLICA_URL is configured");
14
+ }
15
+ return {
16
+ dbPath: resolve(dbPath),
17
+ replicaUrl,
18
+ accessKeyId,
19
+ secretAccessKey,
20
+ endpoint: process.env.DEFCON_LITESTREAM_ENDPOINT || undefined,
21
+ region: process.env.DEFCON_LITESTREAM_REGION || "us-east-1",
22
+ retention: process.env.DEFCON_LITESTREAM_RETENTION || "24h",
23
+ syncInterval: process.env.DEFCON_LITESTREAM_SYNC_INTERVAL || "1s",
24
+ };
25
+ }
26
+ export class LitestreamManager {
27
+ config;
28
+ configPath;
29
+ child = null;
30
+ constructor(config) {
31
+ this.config = config;
32
+ this.configPath = join(tmpdir(), `litestream-${process.pid}.yml`);
33
+ }
34
+ generateConfig() {
35
+ const q = (v) => `'${v.replace(/'/g, "''")}'`;
36
+ const endpoint = this.config.endpoint ? ` endpoint: ${q(this.config.endpoint)}\n` : "";
37
+ return `dbs:
38
+ - path: ${q(this.config.dbPath)}
39
+ replicas:
40
+ - type: s3
41
+ url: ${q(this.config.replicaUrl)}
42
+ ${endpoint} region: ${q(this.config.region)}
43
+ retention: ${q(this.config.retention)}
44
+ sync-interval: ${q(this.config.syncInterval)}
45
+ `;
46
+ }
47
+ restore() {
48
+ if (existsSync(this.config.dbPath)) {
49
+ process.stderr.write(`[litestream] DB exists at ${this.config.dbPath}, skipping restore\n`);
50
+ return;
51
+ }
52
+ const dir = dirname(this.config.dbPath);
53
+ if (!existsSync(dir)) {
54
+ mkdirSync(dir, { recursive: true });
55
+ }
56
+ this.writeConfig();
57
+ process.stderr.write(`[litestream] Restoring from ${this.config.replicaUrl}...\n`);
58
+ const result = spawnSync("litestream", ["restore", "-config", this.configPath, this.config.dbPath], {
59
+ stdio: ["ignore", "pipe", "pipe"],
60
+ timeout: 120_000,
61
+ env: {
62
+ ...process.env,
63
+ LITESTREAM_ACCESS_KEY_ID: this.config.accessKeyId,
64
+ LITESTREAM_SECRET_ACCESS_KEY: this.config.secretAccessKey,
65
+ },
66
+ });
67
+ if (result.status !== 0) {
68
+ const stderr = result.stderr?.toString() ?? "";
69
+ if (stderr.includes("no generations found")) {
70
+ process.stderr.write(`[litestream] No replica found, starting fresh\n`);
71
+ }
72
+ else {
73
+ throw new Error(`[litestream] Restore failed: ${stderr}`);
74
+ }
75
+ }
76
+ else {
77
+ process.stderr.write(`[litestream] Restore complete\n`);
78
+ }
79
+ }
80
+ start() {
81
+ this.writeConfig();
82
+ process.stderr.write(`[litestream] Starting replication to ${this.config.replicaUrl}\n`);
83
+ this.child = spawn("litestream", ["replicate", "-config", this.configPath], {
84
+ stdio: ["ignore", "pipe", "pipe"],
85
+ env: {
86
+ ...process.env,
87
+ LITESTREAM_ACCESS_KEY_ID: this.config.accessKeyId,
88
+ LITESTREAM_SECRET_ACCESS_KEY: this.config.secretAccessKey,
89
+ },
90
+ });
91
+ this.child.stdout?.on("data", (chunk) => {
92
+ process.stderr.write(`[litestream] ${chunk.toString()}`);
93
+ });
94
+ this.child.stderr?.on("data", (chunk) => {
95
+ process.stderr.write(`[litestream] ${chunk.toString()}`);
96
+ });
97
+ this.child.on("exit", (code) => {
98
+ process.stderr.write(`[litestream] Process exited with code ${code}\n`);
99
+ this.child = null;
100
+ });
101
+ this.child.on("error", (err) => {
102
+ process.stderr.write(`[litestream] Process error: ${err.message}\n`);
103
+ this.child = null;
104
+ });
105
+ }
106
+ close() {
107
+ if (!this.child) {
108
+ return Promise.resolve();
109
+ }
110
+ const child = this.child;
111
+ return new Promise((resolve) => {
112
+ process.stderr.write(`[litestream] Stopping replication\n`);
113
+ child.once("exit", () => {
114
+ this.child = null;
115
+ resolve();
116
+ });
117
+ child.kill("SIGTERM");
118
+ });
119
+ }
120
+ writeConfig() {
121
+ writeFileSync(this.configPath, this.generateConfig());
122
+ }
123
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isLitestreamEnabled, LitestreamManager } from "./manager.js";
3
+ describe("isLitestreamEnabled", () => {
4
+ it("returns false when DEFCON_LITESTREAM_REPLICA_URL is not set", () => {
5
+ delete process.env.DEFCON_LITESTREAM_REPLICA_URL;
6
+ expect(isLitestreamEnabled()).toBe(false);
7
+ });
8
+ it("returns true when DEFCON_LITESTREAM_REPLICA_URL is set", () => {
9
+ process.env.DEFCON_LITESTREAM_REPLICA_URL = "s3://bucket/defcon.db";
10
+ expect(isLitestreamEnabled()).toBe(true);
11
+ delete process.env.DEFCON_LITESTREAM_REPLICA_URL;
12
+ });
13
+ });
14
+ describe("LitestreamManager", () => {
15
+ it("generates correct YAML config", () => {
16
+ const mgr = new LitestreamManager({
17
+ dbPath: "/data/defcon.db",
18
+ replicaUrl: "s3://bucket/defcon.db",
19
+ accessKeyId: "AKIA...",
20
+ secretAccessKey: "secret",
21
+ region: "us-east-1",
22
+ retention: "24h",
23
+ syncInterval: "1s",
24
+ });
25
+ const yaml = mgr.generateConfig();
26
+ expect(yaml).toContain("'/data/defcon.db'");
27
+ expect(yaml).toContain("'s3://bucket/defcon.db'");
28
+ expect(yaml).not.toContain("AKIA...");
29
+ expect(yaml).toContain("retention: '24h'");
30
+ expect(yaml).toContain("sync-interval: '1s'");
31
+ });
32
+ it("generates config with custom endpoint for R2", () => {
33
+ const mgr = new LitestreamManager({
34
+ dbPath: "/data/defcon.db",
35
+ replicaUrl: "s3://bucket/defcon.db",
36
+ accessKeyId: "key",
37
+ secretAccessKey: "secret",
38
+ endpoint: "https://account.r2.cloudflarestorage.com",
39
+ region: "auto",
40
+ retention: "24h",
41
+ syncInterval: "1s",
42
+ });
43
+ const yaml = mgr.generateConfig();
44
+ expect(yaml).toContain("endpoint: 'https://account.r2.cloudflarestorage.com'");
45
+ });
46
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/defcon",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "engines": {