@wopr-network/defcon 1.9.0 → 1.11.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/domain-event-adapter.d.ts +8 -0
- package/dist/src/engine/domain-event-adapter.js +14 -0
- package/dist/src/engine/engine.d.ts +4 -1
- package/dist/src/engine/engine.js +24 -1
- package/dist/src/execution/admin-schemas.d.ts +5 -0
- package/dist/src/execution/admin-schemas.js +5 -0
- package/dist/src/execution/cli.js +6 -0
- package/dist/src/execution/mcp-server.d.ts +2 -1
- package/dist/src/execution/mcp-server.js +29 -1
- package/dist/src/repositories/drizzle/domain-event.repo.d.ts +16 -0
- package/dist/src/repositories/drizzle/domain-event.repo.js +81 -0
- package/dist/src/repositories/drizzle/index.d.ts +1 -0
- package/dist/src/repositories/drizzle/index.js +1 -0
- package/dist/src/repositories/drizzle/schema.d.ts +115 -0
- package/dist/src/repositories/drizzle/schema.js +11 -0
- package/dist/src/repositories/interfaces.d.ts +24 -0
- package/drizzle/0016_domain_events.sql +11 -0
- package/drizzle/meta/0015_snapshot.json +1169 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IDomainEventRepository } from "../repositories/interfaces.js";
|
|
2
|
+
import type { EngineEvent, IEventBusAdapter } from "./event-types.js";
|
|
3
|
+
/** IEventBusAdapter that persists every engine event (except definition.changed) to the domain_events table. */
|
|
4
|
+
export declare class DomainEventPersistAdapter implements IEventBusAdapter {
|
|
5
|
+
private readonly repo;
|
|
6
|
+
constructor(repo: IDomainEventRepository);
|
|
7
|
+
emit(event: EngineEvent): Promise<void>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** IEventBusAdapter that persists every engine event (except definition.changed) to the domain_events table. */
|
|
2
|
+
export class DomainEventPersistAdapter {
|
|
3
|
+
repo;
|
|
4
|
+
constructor(repo) {
|
|
5
|
+
this.repo = repo;
|
|
6
|
+
}
|
|
7
|
+
async emit(event) {
|
|
8
|
+
// Skip events that have no entityId (e.g. definition.changed)
|
|
9
|
+
if (!("entityId" in event) || !event.entityId)
|
|
10
|
+
return;
|
|
11
|
+
const { type, entityId, emittedAt, ...rest } = event;
|
|
12
|
+
await this.repo.append(type, entityId, rest);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -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 {
|
|
@@ -136,3 +136,8 @@ export declare const AdminGateRerunSchema: z.ZodObject<{
|
|
|
136
136
|
entity_id: z.ZodString;
|
|
137
137
|
gate_name: z.ZodString;
|
|
138
138
|
}, z.core.$strip>;
|
|
139
|
+
export declare const AdminEventsListSchema: z.ZodObject<{
|
|
140
|
+
entity_id: z.ZodString;
|
|
141
|
+
type: z.ZodOptional<z.ZodString>;
|
|
142
|
+
limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
143
|
+
}, z.core.$strip>;
|
|
@@ -145,3 +145,8 @@ export const AdminGateRerunSchema = z.object({
|
|
|
145
145
|
entity_id: z.string().min(1),
|
|
146
146
|
gate_name: z.string().min(1),
|
|
147
147
|
});
|
|
148
|
+
export const AdminEventsListSchema = z.object({
|
|
149
|
+
entity_id: z.string().min(1),
|
|
150
|
+
type: z.string().min(1).optional(),
|
|
151
|
+
limit: z.coerce.number().int().min(1).max(500).default(100),
|
|
152
|
+
});
|
|
@@ -12,9 +12,11 @@ import { createHttpServer } from "../api/server.js";
|
|
|
12
12
|
import { exportSeed } from "../config/exporter.js";
|
|
13
13
|
import { loadSeed } from "../config/seed-loader.js";
|
|
14
14
|
import { resolveCorsOrigin } from "../cors.js";
|
|
15
|
+
import { DomainEventPersistAdapter } from "../engine/domain-event-adapter.js";
|
|
15
16
|
import { Engine } from "../engine/engine.js";
|
|
16
17
|
import { EventEmitter } from "../engine/event-emitter.js";
|
|
17
18
|
import { withTransaction } from "../main.js";
|
|
19
|
+
import { DrizzleDomainEventRepository } from "../repositories/drizzle/domain-event.repo.js";
|
|
18
20
|
import { DrizzleEntityRepository } from "../repositories/drizzle/entity.repo.js";
|
|
19
21
|
import { DrizzleEventRepository } from "../repositories/drizzle/event.repo.js";
|
|
20
22
|
import { DrizzleFlowRepository } from "../repositories/drizzle/flow.repo.js";
|
|
@@ -162,12 +164,14 @@ program
|
|
|
162
164
|
const invocationRepo = new DrizzleInvocationRepository(db);
|
|
163
165
|
const gateRepo = new DrizzleGateRepository(db);
|
|
164
166
|
const transitionLogRepo = new DrizzleTransitionLogRepository(db);
|
|
167
|
+
const domainEventRepo = new DrizzleDomainEventRepository(db);
|
|
165
168
|
const eventEmitter = new EventEmitter();
|
|
166
169
|
eventEmitter.register({
|
|
167
170
|
emit: async (event) => {
|
|
168
171
|
process.stderr.write(`[event] ${event.type} ${JSON.stringify(event)}\n`);
|
|
169
172
|
},
|
|
170
173
|
});
|
|
174
|
+
eventEmitter.register(new DomainEventPersistAdapter(domainEventRepo));
|
|
171
175
|
const engine = new Engine({
|
|
172
176
|
entityRepo,
|
|
173
177
|
flowRepo,
|
|
@@ -177,6 +181,7 @@ program
|
|
|
177
181
|
adapters: new Map(),
|
|
178
182
|
eventEmitter,
|
|
179
183
|
withTransaction: (fn) => withTransaction(sqlite, fn),
|
|
184
|
+
domainEvents: domainEventRepo,
|
|
180
185
|
});
|
|
181
186
|
const deps = {
|
|
182
187
|
entities: entityRepo,
|
|
@@ -185,6 +190,7 @@ program
|
|
|
185
190
|
gates: gateRepo,
|
|
186
191
|
transitions: transitionLogRepo,
|
|
187
192
|
eventRepo: new DrizzleEventRepository(db),
|
|
193
|
+
domainEvents: domainEventRepo,
|
|
188
194
|
engine,
|
|
189
195
|
withTransaction: (fn) => withTransaction(sqlite, fn),
|
|
190
196
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
2
|
import type { Engine } from "../engine/engine.js";
|
|
3
3
|
import type { Logger } from "../logger.js";
|
|
4
|
-
import type { IEntityRepository, IEventRepository, IFlowRepository, IGateRepository, IInvocationRepository, ITransitionLogRepository } from "../repositories/interfaces.js";
|
|
4
|
+
import type { IDomainEventRepository, IEntityRepository, IEventRepository, IFlowRepository, IGateRepository, IInvocationRepository, ITransitionLogRepository } from "../repositories/interfaces.js";
|
|
5
5
|
export interface McpServerDeps {
|
|
6
6
|
entities: IEntityRepository;
|
|
7
7
|
flows: IFlowRepository;
|
|
@@ -9,6 +9,7 @@ export interface McpServerDeps {
|
|
|
9
9
|
gates: IGateRepository;
|
|
10
10
|
transitions: ITransitionLogRepository;
|
|
11
11
|
eventRepo: IEventRepository;
|
|
12
|
+
domainEvents?: IDomainEventRepository;
|
|
12
13
|
engine?: Engine;
|
|
13
14
|
logger?: Logger;
|
|
14
15
|
withTransaction?: <T>(fn: () => T | Promise<T>) => Promise<T>;
|
|
@@ -6,7 +6,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
6
6
|
import { DEFAULT_TIMEOUT_PROMPT } from "../engine/constants.js";
|
|
7
7
|
import { isTerminal } from "../engine/state-machine.js";
|
|
8
8
|
import { consoleLogger } from "../logger.js";
|
|
9
|
-
import { AdminEntityCancelSchema, AdminEntityMigrateSchema, AdminEntityResetSchema, AdminFlowCreateSchema, AdminFlowPauseSchema, AdminFlowRestoreSchema, AdminFlowSnapshotSchema, AdminFlowUpdateSchema, AdminGateAttachSchema, AdminGateCreateSchema, AdminGateRerunSchema, AdminStateCreateSchema, AdminStateUpdateSchema, AdminTransitionCreateSchema, AdminTransitionUpdateSchema, AdminWorkerDrainSchema, } from "./admin-schemas.js";
|
|
9
|
+
import { AdminEntityCancelSchema, AdminEntityMigrateSchema, AdminEntityResetSchema, AdminEventsListSchema, AdminFlowCreateSchema, AdminFlowPauseSchema, AdminFlowRestoreSchema, AdminFlowSnapshotSchema, AdminFlowUpdateSchema, AdminGateAttachSchema, AdminGateCreateSchema, AdminGateRerunSchema, AdminStateCreateSchema, AdminStateUpdateSchema, AdminTransitionCreateSchema, AdminTransitionUpdateSchema, AdminWorkerDrainSchema, } from "./admin-schemas.js";
|
|
10
10
|
import { handleFlowClaim } from "./handlers/flow.js";
|
|
11
11
|
import { FlowFailSchema, FlowGetPromptSchema, FlowReportSchema, FlowSeedSchema, QueryEntitiesSchema, QueryEntitySchema, QueryFlowSchema, QueryInvocationsSchema, } from "./tool-schemas.js";
|
|
12
12
|
function getSystemDefaultGateTimeoutMs() {
|
|
@@ -394,6 +394,19 @@ const TOOL_DEFINITIONS = [
|
|
|
394
394
|
required: ["entity_id", "gate_name"],
|
|
395
395
|
},
|
|
396
396
|
},
|
|
397
|
+
{
|
|
398
|
+
name: "admin.events.list",
|
|
399
|
+
description: "List domain events for an entity. Returns append-only audit log entries ordered by sequence.",
|
|
400
|
+
inputSchema: {
|
|
401
|
+
type: "object",
|
|
402
|
+
properties: {
|
|
403
|
+
entity_id: { type: "string", description: "Entity ID to list events for" },
|
|
404
|
+
type: { type: "string", description: "Optional event type filter (e.g. 'entity.transitioned')" },
|
|
405
|
+
limit: { type: "number", description: "Max events to return (default 100, max 500)" },
|
|
406
|
+
},
|
|
407
|
+
required: ["entity_id"],
|
|
408
|
+
},
|
|
409
|
+
},
|
|
397
410
|
];
|
|
398
411
|
export function createMcpServer(deps, opts) {
|
|
399
412
|
const server = new Server({ name: "defcon", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
@@ -486,6 +499,8 @@ export async function callToolHandler(deps, name, safeArgs, opts) {
|
|
|
486
499
|
return await handleAdminWorkerUndrain(deps, safeArgs);
|
|
487
500
|
case "admin.gate.rerun":
|
|
488
501
|
return await handleAdminGateRerun(deps, safeArgs);
|
|
502
|
+
case "admin.events.list":
|
|
503
|
+
return await handleAdminEventsList(deps, safeArgs);
|
|
489
504
|
default:
|
|
490
505
|
return errorResult(`Unknown tool: ${name}`);
|
|
491
506
|
}
|
|
@@ -1121,3 +1136,16 @@ async function handleAdminFlowRestore(deps, args) {
|
|
|
1121
1136
|
emitDefinitionChanged(deps.eventRepo, flow.id, "admin.flow.restore", { version: v.data.version });
|
|
1122
1137
|
return jsonResult({ restored: true, version: v.data.version });
|
|
1123
1138
|
}
|
|
1139
|
+
async function handleAdminEventsList(deps, args) {
|
|
1140
|
+
if (!deps.domainEvents) {
|
|
1141
|
+
return errorResult("Domain events repository not available");
|
|
1142
|
+
}
|
|
1143
|
+
const parsed = validateInput(AdminEventsListSchema, args);
|
|
1144
|
+
if (!parsed.ok)
|
|
1145
|
+
return parsed.result;
|
|
1146
|
+
const events = await deps.domainEvents.list(parsed.data.entity_id, {
|
|
1147
|
+
type: parsed.data.type,
|
|
1148
|
+
limit: parsed.data.limit,
|
|
1149
|
+
});
|
|
1150
|
+
return { content: [{ type: "text", text: JSON.stringify(events, null, 2) }] };
|
|
1151
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
|
2
|
+
import type { DomainEvent, IDomainEventRepository } from "../interfaces.js";
|
|
3
|
+
import type * as schema from "./schema.js";
|
|
4
|
+
type Db = BetterSQLite3Database<typeof schema>;
|
|
5
|
+
export declare class DrizzleDomainEventRepository implements IDomainEventRepository {
|
|
6
|
+
private readonly db;
|
|
7
|
+
constructor(db: Db);
|
|
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>;
|
|
11
|
+
list(entityId: string, opts?: {
|
|
12
|
+
type?: string;
|
|
13
|
+
limit?: number;
|
|
14
|
+
}): Promise<DomainEvent[]>;
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
3
|
+
import { domainEvents } from "./schema.js";
|
|
4
|
+
export class DrizzleDomainEventRepository {
|
|
5
|
+
db;
|
|
6
|
+
constructor(db) {
|
|
7
|
+
this.db = db;
|
|
8
|
+
}
|
|
9
|
+
async append(type, entityId, payload) {
|
|
10
|
+
return this.db.transaction((tx) => {
|
|
11
|
+
const maxRow = tx
|
|
12
|
+
.select({ maxSeq: sql `coalesce(max(${domainEvents.sequence}), 0)` })
|
|
13
|
+
.from(domainEvents)
|
|
14
|
+
.where(eq(domainEvents.entityId, entityId))
|
|
15
|
+
.get();
|
|
16
|
+
const sequence = (maxRow?.maxSeq ?? 0) + 1;
|
|
17
|
+
const id = randomUUID();
|
|
18
|
+
const emittedAt = Date.now();
|
|
19
|
+
tx.insert(domainEvents).values({ id, type, entityId, payload, sequence, emittedAt }).run();
|
|
20
|
+
return { id, type, entityId, payload, sequence, emittedAt };
|
|
21
|
+
});
|
|
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
|
+
}
|
|
60
|
+
async list(entityId, opts) {
|
|
61
|
+
const conditions = [eq(domainEvents.entityId, entityId)];
|
|
62
|
+
if (opts?.type) {
|
|
63
|
+
conditions.push(eq(domainEvents.type, opts.type));
|
|
64
|
+
}
|
|
65
|
+
const rows = this.db
|
|
66
|
+
.select()
|
|
67
|
+
.from(domainEvents)
|
|
68
|
+
.where(and(...conditions))
|
|
69
|
+
.orderBy(domainEvents.sequence)
|
|
70
|
+
.limit(opts?.limit ?? 100)
|
|
71
|
+
.all();
|
|
72
|
+
return rows.map((r) => ({
|
|
73
|
+
id: r.id,
|
|
74
|
+
type: r.type,
|
|
75
|
+
entityId: r.entityId,
|
|
76
|
+
payload: r.payload,
|
|
77
|
+
sequence: r.sequence,
|
|
78
|
+
emittedAt: r.emittedAt,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Drizzle ORM implementations of repository interfaces
|
|
2
|
+
export { DrizzleDomainEventRepository } from "./domain-event.repo.js";
|
|
2
3
|
export { DrizzleEntityRepository } from "./entity.repo.js";
|
|
3
4
|
export { DrizzleEventRepository } from "./event.repo.js";
|
|
4
5
|
export { DrizzleFlowRepository } from "./flow.repo.js";
|
|
@@ -2034,3 +2034,118 @@ export declare const events: import("drizzle-orm/sqlite-core").SQLiteTableWithCo
|
|
|
2034
2034
|
};
|
|
2035
2035
|
dialect: "sqlite";
|
|
2036
2036
|
}>;
|
|
2037
|
+
export declare const domainEvents: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
2038
|
+
name: "domain_events";
|
|
2039
|
+
schema: undefined;
|
|
2040
|
+
columns: {
|
|
2041
|
+
id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
2042
|
+
name: "id";
|
|
2043
|
+
tableName: "domain_events";
|
|
2044
|
+
dataType: "string";
|
|
2045
|
+
columnType: "SQLiteText";
|
|
2046
|
+
data: string;
|
|
2047
|
+
driverParam: string;
|
|
2048
|
+
notNull: true;
|
|
2049
|
+
hasDefault: false;
|
|
2050
|
+
isPrimaryKey: true;
|
|
2051
|
+
isAutoincrement: false;
|
|
2052
|
+
hasRuntimeDefault: false;
|
|
2053
|
+
enumValues: [string, ...string[]];
|
|
2054
|
+
baseColumn: never;
|
|
2055
|
+
identity: undefined;
|
|
2056
|
+
generated: undefined;
|
|
2057
|
+
}, {}, {
|
|
2058
|
+
length: number | undefined;
|
|
2059
|
+
}>;
|
|
2060
|
+
type: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
2061
|
+
name: "type";
|
|
2062
|
+
tableName: "domain_events";
|
|
2063
|
+
dataType: "string";
|
|
2064
|
+
columnType: "SQLiteText";
|
|
2065
|
+
data: string;
|
|
2066
|
+
driverParam: string;
|
|
2067
|
+
notNull: true;
|
|
2068
|
+
hasDefault: false;
|
|
2069
|
+
isPrimaryKey: false;
|
|
2070
|
+
isAutoincrement: false;
|
|
2071
|
+
hasRuntimeDefault: false;
|
|
2072
|
+
enumValues: [string, ...string[]];
|
|
2073
|
+
baseColumn: never;
|
|
2074
|
+
identity: undefined;
|
|
2075
|
+
generated: undefined;
|
|
2076
|
+
}, {}, {
|
|
2077
|
+
length: number | undefined;
|
|
2078
|
+
}>;
|
|
2079
|
+
entityId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
2080
|
+
name: "entity_id";
|
|
2081
|
+
tableName: "domain_events";
|
|
2082
|
+
dataType: "string";
|
|
2083
|
+
columnType: "SQLiteText";
|
|
2084
|
+
data: string;
|
|
2085
|
+
driverParam: string;
|
|
2086
|
+
notNull: true;
|
|
2087
|
+
hasDefault: false;
|
|
2088
|
+
isPrimaryKey: false;
|
|
2089
|
+
isAutoincrement: false;
|
|
2090
|
+
hasRuntimeDefault: false;
|
|
2091
|
+
enumValues: [string, ...string[]];
|
|
2092
|
+
baseColumn: never;
|
|
2093
|
+
identity: undefined;
|
|
2094
|
+
generated: undefined;
|
|
2095
|
+
}, {}, {
|
|
2096
|
+
length: number | undefined;
|
|
2097
|
+
}>;
|
|
2098
|
+
payload: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
2099
|
+
name: "payload";
|
|
2100
|
+
tableName: "domain_events";
|
|
2101
|
+
dataType: "json";
|
|
2102
|
+
columnType: "SQLiteTextJson";
|
|
2103
|
+
data: unknown;
|
|
2104
|
+
driverParam: string;
|
|
2105
|
+
notNull: true;
|
|
2106
|
+
hasDefault: false;
|
|
2107
|
+
isPrimaryKey: false;
|
|
2108
|
+
isAutoincrement: false;
|
|
2109
|
+
hasRuntimeDefault: false;
|
|
2110
|
+
enumValues: undefined;
|
|
2111
|
+
baseColumn: never;
|
|
2112
|
+
identity: undefined;
|
|
2113
|
+
generated: undefined;
|
|
2114
|
+
}, {}, {}>;
|
|
2115
|
+
sequence: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
2116
|
+
name: "sequence";
|
|
2117
|
+
tableName: "domain_events";
|
|
2118
|
+
dataType: "number";
|
|
2119
|
+
columnType: "SQLiteInteger";
|
|
2120
|
+
data: number;
|
|
2121
|
+
driverParam: number;
|
|
2122
|
+
notNull: true;
|
|
2123
|
+
hasDefault: false;
|
|
2124
|
+
isPrimaryKey: false;
|
|
2125
|
+
isAutoincrement: false;
|
|
2126
|
+
hasRuntimeDefault: false;
|
|
2127
|
+
enumValues: undefined;
|
|
2128
|
+
baseColumn: never;
|
|
2129
|
+
identity: undefined;
|
|
2130
|
+
generated: undefined;
|
|
2131
|
+
}, {}, {}>;
|
|
2132
|
+
emittedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
2133
|
+
name: "emitted_at";
|
|
2134
|
+
tableName: "domain_events";
|
|
2135
|
+
dataType: "number";
|
|
2136
|
+
columnType: "SQLiteInteger";
|
|
2137
|
+
data: number;
|
|
2138
|
+
driverParam: number;
|
|
2139
|
+
notNull: true;
|
|
2140
|
+
hasDefault: false;
|
|
2141
|
+
isPrimaryKey: false;
|
|
2142
|
+
isAutoincrement: false;
|
|
2143
|
+
hasRuntimeDefault: false;
|
|
2144
|
+
enumValues: undefined;
|
|
2145
|
+
baseColumn: never;
|
|
2146
|
+
identity: undefined;
|
|
2147
|
+
generated: undefined;
|
|
2148
|
+
}, {}, {}>;
|
|
2149
|
+
};
|
|
2150
|
+
dialect: "sqlite";
|
|
2151
|
+
}>;
|
|
@@ -162,3 +162,14 @@ export const events = sqliteTable("events", {
|
|
|
162
162
|
entityIdIdx: index("events_entity_id_idx").on(table.entityId),
|
|
163
163
|
emittedAtIdx: index("events_emitted_at_idx").on(table.emittedAt),
|
|
164
164
|
}));
|
|
165
|
+
export const domainEvents = sqliteTable("domain_events", {
|
|
166
|
+
id: text("id").primaryKey(),
|
|
167
|
+
type: text("type").notNull(),
|
|
168
|
+
entityId: text("entity_id").notNull(),
|
|
169
|
+
payload: text("payload", { mode: "json" }).notNull(),
|
|
170
|
+
sequence: integer("sequence").notNull(),
|
|
171
|
+
emittedAt: integer("emitted_at").notNull(),
|
|
172
|
+
}, (table) => ({
|
|
173
|
+
entitySeqIdx: uniqueIndex("domain_events_entity_seq_idx").on(table.entityId, table.sequence),
|
|
174
|
+
typeIdx: index("domain_events_type_idx").on(table.type, table.emittedAt),
|
|
175
|
+
}));
|
|
@@ -356,6 +356,30 @@ export interface IEventRepository {
|
|
|
356
356
|
/** Get the most recent events across all entities, ordered by emittedAt descending. */
|
|
357
357
|
findRecent(limit?: number): Promise<EventRow[]>;
|
|
358
358
|
}
|
|
359
|
+
/** A persisted domain event from the append-only audit log. */
|
|
360
|
+
export interface DomainEvent {
|
|
361
|
+
id: string;
|
|
362
|
+
type: string;
|
|
363
|
+
entityId: string;
|
|
364
|
+
payload: Record<string, unknown>;
|
|
365
|
+
sequence: number;
|
|
366
|
+
emittedAt: number;
|
|
367
|
+
}
|
|
368
|
+
/** Data-access contract for the append-only domain_events table. */
|
|
369
|
+
export interface IDomainEventRepository {
|
|
370
|
+
/** Append a domain event. Sequence is computed as max(sequence)+1 for the entity. */
|
|
371
|
+
append(type: string, entityId: string, payload: Record<string, unknown>): Promise<DomainEvent>;
|
|
372
|
+
/** List domain events for an entity, optionally filtered by type, ordered by sequence ascending. */
|
|
373
|
+
list(entityId: string, opts?: {
|
|
374
|
+
type?: string;
|
|
375
|
+
limit?: number;
|
|
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>;
|
|
382
|
+
}
|
|
359
383
|
/** Data-access contract for gate definitions and result recording. */
|
|
360
384
|
export interface IGateRepository {
|
|
361
385
|
/** Create a new gate definition. */
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
CREATE TABLE `domain_events` (
|
|
2
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
3
|
+
`type` text NOT NULL,
|
|
4
|
+
`entity_id` text NOT NULL,
|
|
5
|
+
`payload` text NOT NULL,
|
|
6
|
+
`sequence` integer NOT NULL,
|
|
7
|
+
`emitted_at` integer NOT NULL
|
|
8
|
+
);
|
|
9
|
+
--> statement-breakpoint
|
|
10
|
+
CREATE UNIQUE INDEX `domain_events_entity_seq_idx` ON `domain_events` (`entity_id`,`sequence`);--> statement-breakpoint
|
|
11
|
+
CREATE INDEX `domain_events_type_idx` ON `domain_events` (`type`,`emitted_at`);
|