@wopr-network/defcon 1.10.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.
- package/dist/src/engine/engine.d.ts +4 -1
- package/dist/src/engine/engine.js +24 -1
- package/dist/src/execution/cli.js +24 -3
- package/dist/src/litestream/cli-integration.test.d.ts +1 -0
- package/dist/src/litestream/cli-integration.test.js +35 -0
- package/dist/src/litestream/manager.d.ts +23 -0
- package/dist/src/litestream/manager.js +123 -0
- package/dist/src/litestream/manager.test.d.ts +1 -0
- package/dist/src/litestream/manager.test.js +46 -0
- package/dist/src/repositories/drizzle/domain-event.repo.d.ts +2 -0
- package/dist/src/repositories/drizzle/domain-event.repo.js +37 -0
- package/dist/src/repositories/interfaces.d.ts +5 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Logger } from "../logger.js";
|
|
2
|
-
import type { Artifacts, Entity, IEntityRepository, IFlowRepository, IGateRepository, IInvocationRepository, ITransitionLogRepository } from "../repositories/interfaces.js";
|
|
2
|
+
import type { Artifacts, Entity, IDomainEventRepository, IEntityRepository, IFlowRepository, IGateRepository, IInvocationRepository, ITransitionLogRepository } from "../repositories/interfaces.js";
|
|
3
3
|
import type { IEventBusAdapter } from "./event-types.js";
|
|
4
4
|
export interface ProcessSignalResult {
|
|
5
5
|
newState?: string;
|
|
@@ -41,6 +41,8 @@ export interface EngineDeps {
|
|
|
41
41
|
logger?: Logger;
|
|
42
42
|
/** Optional transaction wrapper from the database layer. When provided, processSignal runs inside a single transaction and events are flushed only after successful commit. */
|
|
43
43
|
withTransaction?: <T>(fn: () => T | Promise<T>) => Promise<T>;
|
|
44
|
+
/** Optional domain event repository. When provided, claimWork uses CAS on the event log to prevent concurrent claim races. */
|
|
45
|
+
domainEvents?: IDomainEventRepository;
|
|
44
46
|
}
|
|
45
47
|
export declare class Engine {
|
|
46
48
|
private entityRepo;
|
|
@@ -52,6 +54,7 @@ export declare class Engine {
|
|
|
52
54
|
private eventEmitter;
|
|
53
55
|
private readonly logger;
|
|
54
56
|
private readonly withTransactionFn;
|
|
57
|
+
private readonly domainEventRepo;
|
|
55
58
|
private drainingWorkers;
|
|
56
59
|
constructor(deps: EngineDeps);
|
|
57
60
|
drainWorker(workerId: string): void;
|
|
@@ -18,6 +18,7 @@ export class Engine {
|
|
|
18
18
|
eventEmitter;
|
|
19
19
|
logger;
|
|
20
20
|
withTransactionFn;
|
|
21
|
+
domainEventRepo;
|
|
21
22
|
drainingWorkers = new Set();
|
|
22
23
|
constructor(deps) {
|
|
23
24
|
this.entityRepo = deps.entityRepo;
|
|
@@ -29,6 +30,7 @@ export class Engine {
|
|
|
29
30
|
this.eventEmitter = deps.eventEmitter;
|
|
30
31
|
this.logger = deps.logger ?? consoleLogger;
|
|
31
32
|
this.withTransactionFn = deps.withTransaction ?? null;
|
|
33
|
+
this.domainEventRepo = deps.domainEvents ?? null;
|
|
32
34
|
}
|
|
33
35
|
drainWorker(workerId) {
|
|
34
36
|
this.drainingWorkers.add(workerId);
|
|
@@ -476,9 +478,30 @@ export class Engine {
|
|
|
476
478
|
if (entity.state !== pending.stage)
|
|
477
479
|
continue;
|
|
478
480
|
const entityClaimToken = worker_id ?? `agent:${role}`;
|
|
481
|
+
// CAS guard: atomically append an invocation.claimed event using optimistic concurrency.
|
|
482
|
+
// Only one writer wins the unique (entityId, sequence) constraint — losers move to the next candidate.
|
|
483
|
+
if (this.domainEventRepo) {
|
|
484
|
+
const lastSeq = await this.domainEventRepo.getLastSequence(entity.id);
|
|
485
|
+
const casResult = await this.domainEventRepo.appendCas("invocation.claim_attempted", entity.id, { agentId: entityClaimToken, invocationId: pending.id, stage: pending.stage }, lastSeq);
|
|
486
|
+
if (!casResult)
|
|
487
|
+
continue; // Another agent won the race
|
|
488
|
+
}
|
|
479
489
|
const claimed = await this.entityRepo.claimById(entity.id, entityClaimToken);
|
|
480
|
-
if (!claimed)
|
|
490
|
+
if (!claimed) {
|
|
491
|
+
// claimById failed after CAS success — record rollback event for observability
|
|
492
|
+
if (this.domainEventRepo) {
|
|
493
|
+
try {
|
|
494
|
+
await this.domainEventRepo.append("invocation.claim_released", entity.id, {
|
|
495
|
+
agentId: entityClaimToken,
|
|
496
|
+
reason: "claimById_failed",
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
this.logger.warn("[engine] CAS claim rollback event failed", { err });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
481
503
|
continue;
|
|
504
|
+
}
|
|
482
505
|
// Post-claim state validation — entity may have transitioned between guard check and claim
|
|
483
506
|
if (claimed.state !== pending.stage) {
|
|
484
507
|
try {
|
|
@@ -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);
|
|
@@ -181,6 +193,7 @@ program
|
|
|
181
193
|
adapters: new Map(),
|
|
182
194
|
eventEmitter,
|
|
183
195
|
withTransaction: (fn) => withTransaction(sqlite, fn),
|
|
196
|
+
domainEvents: domainEventRepo,
|
|
184
197
|
});
|
|
185
198
|
const deps = {
|
|
186
199
|
entities: entityRepo,
|
|
@@ -358,7 +371,12 @@ program
|
|
|
358
371
|
});
|
|
359
372
|
const shutdown = makeShutdownHandler({
|
|
360
373
|
stopReaper,
|
|
361
|
-
closeables: [
|
|
374
|
+
closeables: [
|
|
375
|
+
...(litestreamMgr ? [litestreamMgr] : []),
|
|
376
|
+
{ close: () => restHttpServer?.close() },
|
|
377
|
+
httpServer,
|
|
378
|
+
sqlite,
|
|
379
|
+
],
|
|
362
380
|
});
|
|
363
381
|
process.once("SIGINT", shutdown);
|
|
364
382
|
process.once("SIGTERM", shutdown);
|
|
@@ -366,7 +384,10 @@ program
|
|
|
366
384
|
else if (startMcp) {
|
|
367
385
|
// stdio (default)
|
|
368
386
|
console.error("Starting MCP server on stdio...");
|
|
369
|
-
const cleanup = makeShutdownHandler({
|
|
387
|
+
const cleanup = makeShutdownHandler({
|
|
388
|
+
stopReaper,
|
|
389
|
+
closeables: [...(litestreamMgr ? [litestreamMgr] : []), sqlite],
|
|
390
|
+
});
|
|
370
391
|
process.once("SIGINT", cleanup);
|
|
371
392
|
process.once("SIGTERM", cleanup);
|
|
372
393
|
const mcpOpts = { adminToken, workerToken, stdioTrusted: true };
|
|
@@ -376,7 +397,7 @@ program
|
|
|
376
397
|
// HTTP-only mode — keep process alive
|
|
377
398
|
const cleanup = makeShutdownHandler({
|
|
378
399
|
stopReaper,
|
|
379
|
-
closeables: [{ close: () => restHttpServer?.close() }, sqlite],
|
|
400
|
+
closeables: [...(litestreamMgr ? [litestreamMgr] : []), { close: () => restHttpServer?.close() }, sqlite],
|
|
380
401
|
});
|
|
381
402
|
process.once("SIGINT", cleanup);
|
|
382
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
|
+
});
|
|
@@ -6,6 +6,8 @@ export declare class DrizzleDomainEventRepository implements IDomainEventReposit
|
|
|
6
6
|
private readonly db;
|
|
7
7
|
constructor(db: Db);
|
|
8
8
|
append(type: string, entityId: string, payload: Record<string, unknown>): Promise<DomainEvent>;
|
|
9
|
+
getLastSequence(entityId: string): Promise<number>;
|
|
10
|
+
appendCas(type: string, entityId: string, payload: Record<string, unknown>, expectedSequence: number): Promise<DomainEvent | null>;
|
|
9
11
|
list(entityId: string, opts?: {
|
|
10
12
|
type?: string;
|
|
11
13
|
limit?: number;
|
|
@@ -20,6 +20,43 @@ export class DrizzleDomainEventRepository {
|
|
|
20
20
|
return { id, type, entityId, payload, sequence, emittedAt };
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
|
+
async getLastSequence(entityId) {
|
|
24
|
+
const row = this.db
|
|
25
|
+
.select({ maxSeq: sql `coalesce(max(${domainEvents.sequence}), 0)` })
|
|
26
|
+
.from(domainEvents)
|
|
27
|
+
.where(eq(domainEvents.entityId, entityId))
|
|
28
|
+
.get();
|
|
29
|
+
return row?.maxSeq ?? 0;
|
|
30
|
+
}
|
|
31
|
+
async appendCas(type, entityId, payload, expectedSequence) {
|
|
32
|
+
try {
|
|
33
|
+
return this.db.transaction((tx) => {
|
|
34
|
+
const currentRow = tx
|
|
35
|
+
.select({ maxSeq: sql `coalesce(max(${domainEvents.sequence}), 0)` })
|
|
36
|
+
.from(domainEvents)
|
|
37
|
+
.where(eq(domainEvents.entityId, entityId))
|
|
38
|
+
.get();
|
|
39
|
+
const currentSeq = currentRow?.maxSeq ?? 0;
|
|
40
|
+
if (currentSeq !== expectedSequence) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const newSequence = expectedSequence + 1;
|
|
44
|
+
const id = randomUUID();
|
|
45
|
+
const emittedAt = Date.now();
|
|
46
|
+
tx.insert(domainEvents).values({ id, type, entityId, payload, sequence: newSequence, emittedAt }).run();
|
|
47
|
+
return { id, type, entityId, payload, sequence: newSequence, emittedAt };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err instanceof Error &&
|
|
52
|
+
(("code" in err && err.code === "SQLITE_CONSTRAINT_UNIQUE") ||
|
|
53
|
+
("code" in err && err.code === "23505") ||
|
|
54
|
+
err.message.includes("UNIQUE constraint failed"))) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
23
60
|
async list(entityId, opts) {
|
|
24
61
|
const conditions = [eq(domainEvents.entityId, entityId)];
|
|
25
62
|
if (opts?.type) {
|
|
@@ -374,6 +374,11 @@ export interface IDomainEventRepository {
|
|
|
374
374
|
type?: string;
|
|
375
375
|
limit?: number;
|
|
376
376
|
}): Promise<DomainEvent[]>;
|
|
377
|
+
/** Get the current max sequence number for an entity (0 if no events exist). */
|
|
378
|
+
getLastSequence(entityId: string): Promise<number>;
|
|
379
|
+
/** Append a domain event only if the entity's current max sequence equals expectedSequence.
|
|
380
|
+
* Returns the event on success, or null if another writer won (unique constraint violation). */
|
|
381
|
+
appendCas(type: string, entityId: string, payload: Record<string, unknown>, expectedSequence: number): Promise<DomainEvent | null>;
|
|
377
382
|
}
|
|
378
383
|
/** Data-access contract for gate definitions and result recording. */
|
|
379
384
|
export interface IGateRepository {
|