PR-002: Persistence Adapters — in-memory, zero external dependencies
- Repository interfaces defined by domain (@landvex/domain) - 6 in-memory adapters: Session, Mission, DecisionCase, Artifact, EventStore, UnitOfWork - 9 tests verifying adapter contracts - Domain unchanged — infrastructure depends on domain, never reverse - ADR-006: In-Memory Adapters for Testing Definition of Done met: - All adapters compile against domain interfaces - Unit tests pass (9/9) - No PostgreSQL, S3, Express, AI in this PR - Ready for PR-003: PostgreSQL adapters
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Tests for in-memory adapters.
|
||||
*
|
||||
* Verifies that adapters implement repository contracts correctly.
|
||||
* These tests document HOW the domain uses repositories.
|
||||
*/
|
||||
|
||||
import {
|
||||
IdFactory,
|
||||
FieldSessionFactory,
|
||||
MissionFactory,
|
||||
DecisionCaseFactory,
|
||||
ArtifactType,
|
||||
Priority,
|
||||
ConfidenceLevel,
|
||||
} from '@landvex/domain';
|
||||
|
||||
import {
|
||||
InMemoryFieldSessionRepository,
|
||||
InMemoryMissionRepository,
|
||||
InMemoryDecisionCaseRepository,
|
||||
InMemoryArtifactRegistry,
|
||||
InMemoryEventStore,
|
||||
InMemoryUnitOfWork,
|
||||
} from '../index';
|
||||
|
||||
describe('InMemoryFieldSessionRepository', () => {
|
||||
let repo: InMemoryFieldSessionRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = new InMemoryFieldSessionRepository();
|
||||
});
|
||||
|
||||
it('should save and find session by id', async () => {
|
||||
const session = FieldSessionFactory.create({
|
||||
id: IdFactory.session(new Date('2026-07-02'), 1),
|
||||
location: { lat: 59.3293, lng: 18.0686 },
|
||||
date: new Date('2026-07-02'),
|
||||
});
|
||||
|
||||
await repo.save(session);
|
||||
const found = await repo.findById(session.id);
|
||||
|
||||
expect(found).toEqual(session);
|
||||
});
|
||||
|
||||
it('should return null for non-existent session', async () => {
|
||||
const found = await repo.findById(IdFactory.session(new Date('2026-07-02'), 999));
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should find active sessions', async () => {
|
||||
const active = FieldSessionFactory.create({
|
||||
id: IdFactory.session(new Date('2026-07-02'), 1),
|
||||
location: { lat: 59.3293, lng: 18.0686 },
|
||||
date: new Date('2026-07-02'),
|
||||
});
|
||||
|
||||
const completed = FieldSessionFactory.create({
|
||||
id: IdFactory.session(new Date('2026-07-01'), 1),
|
||||
location: { lat: 59.3293, lng: 18.0686 },
|
||||
date: new Date('2026-07-01'),
|
||||
});
|
||||
(completed as any).status = 'completed';
|
||||
|
||||
await repo.save(active);
|
||||
await repo.save(completed);
|
||||
|
||||
const actives = await repo.findActive();
|
||||
expect(actives).toHaveLength(1);
|
||||
expect(actives[0].id).toBe(active.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InMemoryMissionRepository', () => {
|
||||
let repo: InMemoryMissionRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = new InMemoryMissionRepository();
|
||||
});
|
||||
|
||||
it('should find missions by session ordered by sequence', async () => {
|
||||
const sessionId = IdFactory.session(new Date('2026-07-02'), 1);
|
||||
|
||||
const mission1 = MissionFactory.create({
|
||||
id: IdFactory.mission(new Date('2026-07-02'), 1),
|
||||
sessionId,
|
||||
location: { lat: 59.3293, lng: 18.0686 },
|
||||
device: { model: 'iPhone', os: 'iOS', appVersion: '1.0' },
|
||||
sequenceNumber: 1,
|
||||
});
|
||||
|
||||
const mission2 = MissionFactory.create({
|
||||
id: IdFactory.mission(new Date('2026-07-02'), 2),
|
||||
sessionId,
|
||||
location: { lat: 59.3293, lng: 18.0686 },
|
||||
device: { model: 'iPhone', os: 'iOS', appVersion: '1.0' },
|
||||
sequenceNumber: 2,
|
||||
});
|
||||
|
||||
await repo.save(mission1);
|
||||
await repo.save(mission2);
|
||||
|
||||
const found = await repo.findBySession(sessionId);
|
||||
expect(found).toHaveLength(2);
|
||||
expect(found[0].sequenceNumber).toBe(1);
|
||||
expect(found[1].sequenceNumber).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InMemoryDecisionCaseRepository', () => {
|
||||
let repo: InMemoryDecisionCaseRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = new InMemoryDecisionCaseRepository();
|
||||
});
|
||||
|
||||
it('should find cases by status', async () => {
|
||||
const draft = DecisionCaseFactory.create({
|
||||
id: IdFactory.decisionCase(1),
|
||||
missionId: IdFactory.mission(new Date('2026-07-02'), 1),
|
||||
title: 'Crack in bridge',
|
||||
description: 'Structural crack detected',
|
||||
priority: 'high',
|
||||
confidence: 'high',
|
||||
recommendedAction: 'Inspect immediately',
|
||||
observationIds: [IdFactory.observation(1)],
|
||||
evidenceIds: [IdFactory.evidence(1)],
|
||||
findingId: IdFactory.finding(1),
|
||||
decisionId: IdFactory.decision(1),
|
||||
reviewId: IdFactory.review(1),
|
||||
});
|
||||
|
||||
const approved = DecisionCaseFactory.create({
|
||||
id: IdFactory.decisionCase(2),
|
||||
missionId: IdFactory.mission(new Date('2026-07-02'), 1),
|
||||
title: 'Pothole repair',
|
||||
description: 'Repair pothole on road A',
|
||||
priority: 'medium',
|
||||
confidence: 'medium',
|
||||
recommendedAction: 'Schedule repair',
|
||||
observationIds: [IdFactory.observation(2)],
|
||||
evidenceIds: [IdFactory.evidence(2)],
|
||||
findingId: IdFactory.finding(2),
|
||||
decisionId: IdFactory.decision(2),
|
||||
reviewId: IdFactory.review(2),
|
||||
});
|
||||
(approved as any).status = 'approved';
|
||||
|
||||
await repo.save(draft);
|
||||
await repo.save(approved);
|
||||
|
||||
const drafts = await repo.findByStatus('pending');
|
||||
expect(drafts).toHaveLength(1);
|
||||
expect(drafts[0].id).toBe(draft.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InMemoryEventStore', () => {
|
||||
let store: InMemoryEventStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new InMemoryEventStore();
|
||||
});
|
||||
|
||||
it('should append and retrieve events', async () => {
|
||||
const event = {
|
||||
type: 'SessionStarted',
|
||||
aggregateId: 'session_001',
|
||||
occurredAt: new Date('2026-07-02T10:00:00Z'),
|
||||
payload: { inspector: 'inspector_001' },
|
||||
};
|
||||
|
||||
await store.append(event as any);
|
||||
const events = await store.getEvents('session_001');
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].type).toBe('SessionStarted');
|
||||
});
|
||||
|
||||
it('should return events ordered by time', async () => {
|
||||
const event1 = {
|
||||
type: 'SessionStarted',
|
||||
aggregateId: 'session_001',
|
||||
occurredAt: new Date('2026-07-02T10:00:00Z'),
|
||||
payload: {},
|
||||
};
|
||||
const event2 = {
|
||||
type: 'MissionAdded',
|
||||
aggregateId: 'session_001',
|
||||
occurredAt: new Date('2026-07-02T10:05:00Z'),
|
||||
payload: {},
|
||||
};
|
||||
|
||||
await store.append(event2 as any);
|
||||
await store.append(event1 as any);
|
||||
|
||||
const events = await store.getEvents('session_001');
|
||||
expect(events[0].type).toBe('SessionStarted');
|
||||
expect(events[1].type).toBe('MissionAdded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InMemoryUnitOfWork', () => {
|
||||
let uow: InMemoryUnitOfWork;
|
||||
|
||||
beforeEach(() => {
|
||||
uow = new InMemoryUnitOfWork();
|
||||
});
|
||||
|
||||
it('should coordinate multiple repositories', async () => {
|
||||
const session = FieldSessionFactory.create({
|
||||
id: IdFactory.session(new Date('2026-07-02'), 1),
|
||||
location: { lat: 59.3293, lng: 18.0686 },
|
||||
date: new Date('2026-07-02'),
|
||||
});
|
||||
|
||||
const mission = MissionFactory.create({
|
||||
id: IdFactory.mission(new Date('2026-07-02'), 1),
|
||||
sessionId: session.id,
|
||||
location: { lat: 59.3293, lng: 18.0686 },
|
||||
device: { model: 'iPhone', os: 'iOS', appVersion: '1.0' },
|
||||
sequenceNumber: 1,
|
||||
});
|
||||
|
||||
await uow.sessions.save(session);
|
||||
await uow.missions.save(mission);
|
||||
await uow.commit();
|
||||
|
||||
const foundSession = await uow.sessions.findById(session.id);
|
||||
const foundMission = await uow.missions.findById(mission.id);
|
||||
|
||||
expect(foundSession).toEqual(session);
|
||||
expect(foundMission).toEqual(mission);
|
||||
});
|
||||
|
||||
it('should rollback to previous state', async () => {
|
||||
const session = FieldSessionFactory.create({
|
||||
id: IdFactory.session(new Date('2026-07-02'), 1),
|
||||
location: { lat: 59.3293, lng: 18.0686 },
|
||||
date: new Date('2026-07-02'),
|
||||
});
|
||||
|
||||
await uow.sessions.save(session);
|
||||
uow.begin();
|
||||
|
||||
const session2 = FieldSessionFactory.create({
|
||||
id: IdFactory.session(new Date('2026-07-03'), 1),
|
||||
location: { lat: 59.3293, lng: 18.0686 },
|
||||
date: new Date('2026-07-03'),
|
||||
});
|
||||
|
||||
await uow.sessions.save(session2);
|
||||
await uow.rollback();
|
||||
|
||||
const found = await uow.sessions.findById(session2.id);
|
||||
expect(found).toBeNull();
|
||||
|
||||
const original = await uow.sessions.findById(session.id);
|
||||
expect(original).toEqual(session);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* In-Memory Artifact Registry
|
||||
*
|
||||
* Adapter: Implements ArtifactRegistry using a Map.
|
||||
* Purpose: Testing, development, CI/CD pipelines.
|
||||
* NOT for production.
|
||||
*
|
||||
* ADR-002: Artifact is common base contract
|
||||
* - Artifacts are immutable
|
||||
* - Registry stores metadata
|
||||
* - Content lives in object storage, accessed via storageUri
|
||||
*/
|
||||
|
||||
import { Artifact, ArtifactId } from '@landvex/domain';
|
||||
import { ArtifactRegistry } from '../repositories/repository-interfaces';
|
||||
|
||||
export class InMemoryArtifactRegistry implements ArtifactRegistry {
|
||||
private artifacts = new Map<string, Artifact>();
|
||||
|
||||
async register(artifact: Artifact): Promise<void> {
|
||||
this.artifacts.set(artifact.id, artifact);
|
||||
}
|
||||
|
||||
async findById(id: ArtifactId): Promise<Artifact | null> {
|
||||
return this.artifacts.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByLineage(lineage: ArtifactId): Promise<Artifact[]> {
|
||||
return Array.from(this.artifacts.values())
|
||||
.filter(a => a.lineage.includes(lineage));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.artifacts.clear();
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.artifacts.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* In-Memory DecisionCase Repository
|
||||
*
|
||||
* Adapter: Implements DecisionCaseRepository using a Map.
|
||||
* Purpose: Testing, development, CI/CD pipelines.
|
||||
* NOT for production.
|
||||
*/
|
||||
|
||||
import {
|
||||
DecisionCase,
|
||||
DecisionCaseId,
|
||||
ArtifactId,
|
||||
EvidenceId,
|
||||
} from '@landvex/domain';
|
||||
import { DecisionCaseRepository } from '../repositories/repository-interfaces';
|
||||
|
||||
export class InMemoryDecisionCaseRepository implements DecisionCaseRepository {
|
||||
private cases = new Map<string, DecisionCase>();
|
||||
|
||||
async save(decisionCase: DecisionCase): Promise<void> {
|
||||
this.cases.set(decisionCase.id, decisionCase);
|
||||
}
|
||||
|
||||
async findById(id: DecisionCaseId): Promise<DecisionCase | null> {
|
||||
return this.cases.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByArtifact(artifactId: ArtifactId): Promise<DecisionCase[]> {
|
||||
return Array.from(this.cases.values())
|
||||
.filter(c => c.evidenceIds.includes(artifactId as any));
|
||||
}
|
||||
|
||||
async findByStatus(
|
||||
status: 'pending' | 'under_review' | 'approved' | 'rejected'
|
||||
): Promise<DecisionCase[]> {
|
||||
return Array.from(this.cases.values())
|
||||
.filter(c => c.status === status);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cases.clear();
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.cases.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* In-Memory Event Store
|
||||
*
|
||||
* Adapter: Implements EventStore using an array.
|
||||
* Purpose: Testing, development, CI/CD pipelines.
|
||||
* NOT for production.
|
||||
*
|
||||
* ADR-003: Event Sourcing for traceability
|
||||
* - Events are immutable facts
|
||||
* - State is a projection of event history
|
||||
* - Replay reconstructs any past state
|
||||
*/
|
||||
|
||||
import { DomainEvent } from '@landvex/domain';
|
||||
import { EventStore } from '../repositories/repository-interfaces';
|
||||
|
||||
export class InMemoryEventStore implements EventStore {
|
||||
private events: DomainEvent[] = [];
|
||||
|
||||
async append(event: DomainEvent): Promise<void> {
|
||||
this.events.push(event);
|
||||
}
|
||||
|
||||
async getEvents(aggregateId: string): Promise<DomainEvent[]> {
|
||||
return this.events
|
||||
.filter(e => e.aggregateId === aggregateId)
|
||||
.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
|
||||
}
|
||||
|
||||
async getAllEvents(since?: Date): Promise<DomainEvent[]> {
|
||||
let filtered = this.events;
|
||||
if (since) {
|
||||
filtered = filtered.filter(e => e.occurredAt >= since);
|
||||
}
|
||||
return filtered.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.events = [];
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.events.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* In-Memory Mission Repository
|
||||
*
|
||||
* Adapter: Implements MissionRepository using a Map.
|
||||
* Purpose: Testing, development, CI/CD pipelines.
|
||||
* NOT for production.
|
||||
*/
|
||||
|
||||
import {
|
||||
Mission,
|
||||
MissionId,
|
||||
SessionId,
|
||||
} from '@landvex/domain';
|
||||
import { MissionRepository } from '../repositories/repository-interfaces';
|
||||
|
||||
export class InMemoryMissionRepository implements MissionRepository {
|
||||
private missions = new Map<string, Mission>();
|
||||
|
||||
async save(mission: Mission): Promise<void> {
|
||||
this.missions.set(mission.id, mission);
|
||||
}
|
||||
|
||||
async findById(id: MissionId): Promise<Mission | null> {
|
||||
return this.missions.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findBySession(sessionId: SessionId): Promise<Mission[]> {
|
||||
return Array.from(this.missions.values())
|
||||
.filter(m => m.sessionId === sessionId)
|
||||
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
}
|
||||
|
||||
async findActive(): Promise<Mission[]> {
|
||||
return Array.from(this.missions.values())
|
||||
.filter(m => m.status === 'created' || m.status === 'uploading' || m.status === 'processing');
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.missions.clear();
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.missions.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* In-Memory FieldSession Repository
|
||||
*
|
||||
* Adapter: Implements FieldSessionRepository using a Map.
|
||||
* Purpose: Testing, development, CI/CD pipelines.
|
||||
* NOT for production.
|
||||
*
|
||||
* ADR-006: In-memory adapters for testing
|
||||
* - Fast, isolated, no external dependencies
|
||||
* - Reset between tests
|
||||
* - Never used in production
|
||||
*/
|
||||
|
||||
import {
|
||||
FieldSession,
|
||||
SessionId,
|
||||
} from '@landvex/domain';
|
||||
import { FieldSessionRepository } from '../repositories/repository-interfaces';
|
||||
|
||||
export class InMemoryFieldSessionRepository implements FieldSessionRepository {
|
||||
private sessions = new Map<string, FieldSession>();
|
||||
|
||||
async save(session: FieldSession): Promise<void> {
|
||||
this.sessions.set(session.id, session);
|
||||
}
|
||||
|
||||
async findById(id: SessionId): Promise<FieldSession | null> {
|
||||
return this.sessions.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findActive(): Promise<FieldSession[]> {
|
||||
return Array.from(this.sessions.values())
|
||||
.filter(s => s.status === 'planned' || s.status === 'active');
|
||||
}
|
||||
|
||||
async findByDateRange(start: Date, end: Date): Promise<FieldSession[]> {
|
||||
return Array.from(this.sessions.values())
|
||||
.filter(s => s.date >= start && s.date <= end);
|
||||
}
|
||||
|
||||
/** Reset for testing */
|
||||
clear(): void {
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
/** Count for assertions */
|
||||
count(): number {
|
||||
return this.sessions.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* In-Memory Unit of Work
|
||||
*
|
||||
* Coordinates multiple in-memory repositories.
|
||||
* Simulates transaction boundaries for testing.
|
||||
*
|
||||
* ADR-006: In-memory adapters for testing
|
||||
* - commit() persists all changes
|
||||
* - rollback() discards all changes
|
||||
* - NOT atomic in production sense, but sufficient for tests
|
||||
*/
|
||||
|
||||
import {
|
||||
UnitOfWork,
|
||||
FieldSessionRepository,
|
||||
MissionRepository,
|
||||
DecisionCaseRepository,
|
||||
ArtifactRegistry,
|
||||
EventStore,
|
||||
} from '../repositories/repository-interfaces';
|
||||
import { InMemoryFieldSessionRepository } from './in-memory-session-repository';
|
||||
import { InMemoryMissionRepository } from './in-memory-mission-repository';
|
||||
import { InMemoryDecisionCaseRepository } from './in-memory-decision-case-repository';
|
||||
import { InMemoryArtifactRegistry } from './in-memory-artifact-registry';
|
||||
import { InMemoryEventStore } from './in-memory-event-store';
|
||||
|
||||
export class InMemoryUnitOfWork implements UnitOfWork {
|
||||
sessions: FieldSessionRepository;
|
||||
missions: MissionRepository;
|
||||
decisionCases: DecisionCaseRepository;
|
||||
artifacts: ArtifactRegistry;
|
||||
events: EventStore;
|
||||
|
||||
private snapshots: {
|
||||
sessions: Map<string, unknown>;
|
||||
missions: Map<string, unknown>;
|
||||
cases: Map<string, unknown>;
|
||||
artifacts: Map<string, unknown>;
|
||||
events: unknown[];
|
||||
} | null = null;
|
||||
|
||||
constructor() {
|
||||
this.sessions = new InMemoryFieldSessionRepository();
|
||||
this.missions = new InMemoryMissionRepository();
|
||||
this.decisionCases = new InMemoryDecisionCaseRepository();
|
||||
this.artifacts = new InMemoryArtifactRegistry();
|
||||
this.events = new InMemoryEventStore();
|
||||
}
|
||||
|
||||
async commit(): Promise<void> {
|
||||
// In-memory: changes are already applied
|
||||
// Real implementation would flush to database
|
||||
this.snapshots = null;
|
||||
}
|
||||
|
||||
async rollback(): Promise<void> {
|
||||
if (!this.snapshots) {
|
||||
throw new Error('No transaction in progress');
|
||||
}
|
||||
|
||||
// Restore from snapshots
|
||||
(this.sessions as InMemoryFieldSessionRepository).clear();
|
||||
(this.missions as InMemoryMissionRepository).clear();
|
||||
(this.decisionCases as InMemoryDecisionCaseRepository).clear();
|
||||
(this.artifacts as InMemoryArtifactRegistry).clear();
|
||||
(this.events as InMemoryEventStore).clear();
|
||||
|
||||
// Re-populate from snapshots
|
||||
for (const [id, session] of this.snapshots.sessions) {
|
||||
(this.sessions as InMemoryFieldSessionRepository).save(session as any);
|
||||
}
|
||||
for (const [id, mission] of this.snapshots.missions) {
|
||||
(this.missions as InMemoryMissionRepository).save(mission as any);
|
||||
}
|
||||
for (const [id, c] of this.snapshots.cases) {
|
||||
(this.decisionCases as InMemoryDecisionCaseRepository).save(c as any);
|
||||
}
|
||||
for (const [id, artifact] of this.snapshots.artifacts) {
|
||||
(this.artifacts as InMemoryArtifactRegistry).register(artifact as any);
|
||||
}
|
||||
for (const event of this.snapshots.events) {
|
||||
(this.events as InMemoryEventStore).append(event as any);
|
||||
}
|
||||
|
||||
this.snapshots = null;
|
||||
}
|
||||
|
||||
/** Begin transaction: capture snapshot */
|
||||
begin(): void {
|
||||
this.snapshots = {
|
||||
sessions: new Map((this.sessions as InMemoryFieldSessionRepository as any).sessions),
|
||||
missions: new Map((this.missions as InMemoryMissionRepository as any).missions),
|
||||
cases: new Map((this.decisionCases as InMemoryDecisionCaseRepository as any).cases),
|
||||
artifacts: new Map((this.artifacts as InMemoryArtifactRegistry as any).artifacts),
|
||||
events: [...(this.events as InMemoryEventStore as any).events],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @landvex/infrastructure
|
||||
*
|
||||
* Persistence adapters and infrastructure for LandveX domain.
|
||||
*
|
||||
* Architecture:
|
||||
* - Domain defines interfaces (WHAT)
|
||||
* - Infrastructure implements them (HOW)
|
||||
* - Domain never depends on infrastructure
|
||||
*/
|
||||
|
||||
// Repository interfaces (defined by domain, exported for convenience)
|
||||
export {
|
||||
Repository,
|
||||
FieldSessionRepository,
|
||||
MissionRepository,
|
||||
DecisionCaseRepository,
|
||||
ArtifactRegistry,
|
||||
EventStore,
|
||||
UnitOfWork,
|
||||
} from './repositories/repository-interfaces';
|
||||
|
||||
// In-memory adapters (for testing)
|
||||
export { InMemoryFieldSessionRepository } from './adapters/in-memory-session-repository';
|
||||
export { InMemoryMissionRepository } from './adapters/in-memory-mission-repository';
|
||||
export { InMemoryDecisionCaseRepository } from './adapters/in-memory-decision-case-repository';
|
||||
export { InMemoryArtifactRegistry } from './adapters/in-memory-artifact-registry';
|
||||
export { InMemoryEventStore } from './adapters/in-memory-event-store';
|
||||
export { InMemoryUnitOfWork } from './adapters/in-memory-unit-of-work';
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Repository interfaces for LandveX domain.
|
||||
*
|
||||
* These interfaces define WHAT the domain needs from persistence.
|
||||
* Adapters decide HOW to provide it.
|
||||
*
|
||||
* Domain depends on these interfaces.
|
||||
* Infrastructure implements them.
|
||||
* Domain never depends on infrastructure.
|
||||
*/
|
||||
|
||||
import {
|
||||
FieldSession,
|
||||
Mission,
|
||||
DecisionCase,
|
||||
DomainEvent,
|
||||
Artifact,
|
||||
SessionId,
|
||||
MissionId,
|
||||
DecisionCaseId,
|
||||
ArtifactId,
|
||||
} from '@landvex/domain';
|
||||
|
||||
/**
|
||||
* Base repository contract.
|
||||
* All repositories share save/load by ID.
|
||||
*/
|
||||
export interface Repository<T, ID> {
|
||||
save(entity: T): Promise<void>;
|
||||
findById(id: ID): Promise<T | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* FieldSession repository.
|
||||
*
|
||||
* Invariants:
|
||||
* - findById returns null if session does not exist
|
||||
* - save overwrites previous state (last write wins for now)
|
||||
*/
|
||||
export interface FieldSessionRepository extends Repository<FieldSession, SessionId> {
|
||||
findActive(): Promise<FieldSession[]>;
|
||||
findByDateRange(start: Date, end: Date): Promise<FieldSession[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mission repository.
|
||||
*
|
||||
* Invariants:
|
||||
* - findById returns null if mission does not exist
|
||||
* - findBySession returns missions ordered by sequence number
|
||||
*/
|
||||
export interface MissionRepository extends Repository<Mission, MissionId> {
|
||||
findBySession(sessionId: SessionId): Promise<Mission[]>;
|
||||
findActive(): Promise<Mission[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DecisionCase repository.
|
||||
*
|
||||
* Invariants:
|
||||
* - findById returns null if case does not exist
|
||||
* - findByArtifact returns cases linked to an artifact
|
||||
*/
|
||||
export interface DecisionCaseRepository extends Repository<DecisionCase, DecisionCaseId> {
|
||||
findByArtifact(artifactId: ArtifactId): Promise<DecisionCase[]>;
|
||||
findByStatus(status: 'pending' | 'under_review' | 'approved' | 'rejected'): Promise<DecisionCase[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifact registry.
|
||||
*
|
||||
* Artifacts are immutable. Registry stores metadata.
|
||||
* Content lives in object storage (S3/R2), accessed via storageUri.
|
||||
*/
|
||||
export interface ArtifactRegistry {
|
||||
register(artifact: Artifact): Promise<void>;
|
||||
findById(id: ArtifactId): Promise<Artifact | null>;
|
||||
findByLineage(lineage: ArtifactId): Promise<Artifact[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event store.
|
||||
*
|
||||
* Append-only log of domain events.
|
||||
* Supports replay for state reconstruction.
|
||||
*/
|
||||
export interface EventStore {
|
||||
append(event: DomainEvent): Promise<void>;
|
||||
getEvents(aggregateId: string): Promise<DomainEvent[]>;
|
||||
getAllEvents(since?: Date): Promise<DomainEvent[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit of work.
|
||||
*
|
||||
* Coordinates multiple repositories in a single transaction.
|
||||
* Commit = all changes persisted together.
|
||||
* Rollback = all changes discarded.
|
||||
*/
|
||||
export interface UnitOfWork {
|
||||
sessions: FieldSessionRepository;
|
||||
missions: MissionRepository;
|
||||
decisionCases: DecisionCaseRepository;
|
||||
artifacts: ArtifactRegistry;
|
||||
events: EventStore;
|
||||
|
||||
commit(): Promise<void>;
|
||||
rollback(): Promise<void>;
|
||||
}
|
||||
Reference in New Issue
Block a user