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:
@@ -56,3 +56,17 @@ export enum DecisionStatus {
|
||||
APPROVED = 'approved',
|
||||
REJECTED = 'rejected'
|
||||
}
|
||||
|
||||
export enum Priority {
|
||||
LOW = 'low',
|
||||
MEDIUM = 'medium',
|
||||
HIGH = 'high',
|
||||
CRITICAL = 'critical'
|
||||
}
|
||||
|
||||
export enum ConfidenceLevel {
|
||||
LOW = 'low',
|
||||
MEDIUM = 'medium',
|
||||
HIGH = 'high',
|
||||
CERTAIN = 'certain'
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type FindingId = string & { readonly __brand: 'FindingId' };
|
||||
export type ReviewId = string & { readonly __brand: 'ReviewId' };
|
||||
export type ActionId = string & { readonly __brand: 'ActionId' };
|
||||
export type OutcomeId = string & { readonly __brand: 'OutcomeId' };
|
||||
export type DecisionCaseId = string & { readonly __brand: 'DecisionCaseId' };
|
||||
|
||||
/**
|
||||
* ID factory functions
|
||||
@@ -59,5 +60,9 @@ export const IdFactory = {
|
||||
|
||||
outcome: (sequence: number): OutcomeId => {
|
||||
return `outcome_${sequence.toString().padStart(6, '0')}` as OutcomeId;
|
||||
},
|
||||
|
||||
decisionCase: (sequence: number): DecisionCaseId => {
|
||||
return `decision_${sequence.toString().padStart(6, '0')}` as DecisionCaseId;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@ export interface ValidationResult {
|
||||
}
|
||||
|
||||
export type Severity = 'low' | 'medium' | 'high' | 'critical';
|
||||
export type Priority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
export type PriorityValue = 'low' | 'medium' | 'high' | 'urgent';
|
||||
|
||||
export const Severity = {
|
||||
LOW: 'low' as Severity,
|
||||
@@ -97,8 +97,8 @@ export const Severity = {
|
||||
};
|
||||
|
||||
export const Priority = {
|
||||
LOW: 'low' as Priority,
|
||||
MEDIUM: 'medium' as Priority,
|
||||
HIGH: 'high' as Priority,
|
||||
URGENT: 'urgent' as Priority
|
||||
LOW: 'low' as PriorityValue,
|
||||
MEDIUM: 'medium' as PriorityValue,
|
||||
HIGH: 'high' as PriorityValue,
|
||||
URGENT: 'urgent' as PriorityValue
|
||||
};
|
||||
|
||||
@@ -10,13 +10,18 @@
|
||||
* - Immutable once created — revisions create new versions
|
||||
*/
|
||||
|
||||
import { MissionId, ObservationId, EvidenceId, FindingId, DecisionId, ReviewId } from '../common/ids';
|
||||
import { MissionId, ObservationId, EvidenceId, FindingId, DecisionId, ReviewId, DecisionCaseId } from '../common/ids';
|
||||
import { DecisionStatus } from '../common/enums';
|
||||
import { InvariantViolationError } from '../common/errors';
|
||||
|
||||
export interface DecisionCase {
|
||||
readonly id: string;
|
||||
readonly id: DecisionCaseId;
|
||||
readonly missionId: MissionId;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly priority: string;
|
||||
readonly confidence: string;
|
||||
readonly recommendedAction: string;
|
||||
readonly observationIds: ObservationId[];
|
||||
readonly evidenceIds: EvidenceId[];
|
||||
readonly findingId: FindingId;
|
||||
@@ -28,29 +33,48 @@ export interface DecisionCase {
|
||||
}
|
||||
|
||||
export interface CreateDecisionCaseParams {
|
||||
readonly id: string;
|
||||
readonly id: DecisionCaseId;
|
||||
readonly missionId: MissionId;
|
||||
readonly observationIds: ObservationId[];
|
||||
readonly evidenceIds: EvidenceId[];
|
||||
readonly findingId: FindingId;
|
||||
readonly decisionId: DecisionId;
|
||||
readonly reviewId: ReviewId;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly priority: string;
|
||||
readonly confidence: string;
|
||||
readonly recommendedAction: string;
|
||||
readonly observationIds?: ObservationId[];
|
||||
readonly evidenceIds?: EvidenceId[];
|
||||
readonly findingId?: FindingId;
|
||||
readonly decisionId?: DecisionId;
|
||||
readonly reviewId?: ReviewId;
|
||||
}
|
||||
|
||||
export class DecisionCaseFactory {
|
||||
static create(params: CreateDecisionCaseParams): DecisionCase {
|
||||
if (params.observationIds.length === 0) {
|
||||
const observationIds = params.observationIds ?? [];
|
||||
const evidenceIds = params.evidenceIds ?? [];
|
||||
|
||||
if (observationIds.length === 0) {
|
||||
throw new InvariantViolationError('DecisionCase must have at least one observation');
|
||||
}
|
||||
if (params.evidenceIds.length === 0) {
|
||||
if (evidenceIds.length === 0) {
|
||||
throw new InvariantViolationError('DecisionCase must have at least one evidence');
|
||||
}
|
||||
if (!params.decisionId) {
|
||||
throw new InvariantViolationError('DecisionCase must have exactly one decision');
|
||||
}
|
||||
if (!params.findingId) {
|
||||
throw new InvariantViolationError('DecisionCase must have a finding');
|
||||
}
|
||||
if (!params.reviewId) {
|
||||
throw new InvariantViolationError('DecisionCase must have a review');
|
||||
}
|
||||
|
||||
return {
|
||||
...params,
|
||||
observationIds,
|
||||
evidenceIds,
|
||||
findingId: params.findingId!,
|
||||
decisionId: params.decisionId!,
|
||||
reviewId: params.reviewId!,
|
||||
status: DecisionStatus.PENDING,
|
||||
version: 1,
|
||||
createdAt: new Date()
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface DomainEvent {
|
||||
readonly type: string;
|
||||
readonly timestamp: Date;
|
||||
readonly aggregateId: string;
|
||||
readonly occurredAt: Date;
|
||||
}
|
||||
|
||||
// Session events
|
||||
|
||||
@@ -12,8 +12,38 @@
|
||||
*/
|
||||
|
||||
// Common
|
||||
export * from './common/ids';
|
||||
export * from './common/value-objects';
|
||||
export {
|
||||
SessionId,
|
||||
MissionId,
|
||||
ArtifactId,
|
||||
ObservationId,
|
||||
DecisionId,
|
||||
EvidenceId,
|
||||
FindingId,
|
||||
ReviewId,
|
||||
ActionId,
|
||||
OutcomeId,
|
||||
DecisionCaseId,
|
||||
IdFactory,
|
||||
} from './common/ids';
|
||||
export {
|
||||
GeoLocation,
|
||||
BoundingBox,
|
||||
Confidence,
|
||||
Hash,
|
||||
StorageUri,
|
||||
Version,
|
||||
TimeRange,
|
||||
DeviceInfo,
|
||||
ExifData,
|
||||
AssetMetadata,
|
||||
BusinessImpact,
|
||||
EvidenceContext,
|
||||
QualityIssue,
|
||||
ValidationResult,
|
||||
Severity,
|
||||
PriorityValue,
|
||||
} from './common/value-objects';
|
||||
export * from './common/enums';
|
||||
export * from './common/errors';
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Mission {
|
||||
readonly device: DeviceInfo;
|
||||
readonly artifactIds: ArtifactId[];
|
||||
readonly observationIds: ObservationId[];
|
||||
readonly sequenceNumber: number;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ export interface CreateMissionParams {
|
||||
readonly sessionId: SessionId;
|
||||
readonly location: GeoLocation;
|
||||
readonly device: DeviceInfo;
|
||||
readonly sequenceNumber?: number;
|
||||
}
|
||||
|
||||
export class MissionFactory {
|
||||
@@ -50,6 +52,7 @@ export class MissionFactory {
|
||||
device: params.device,
|
||||
artifactIds: [],
|
||||
observationIds: [],
|
||||
sequenceNumber: params.sequenceNumber ?? 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
@@ -0,0 +1,53 @@
|
||||
# @landvex/infrastructure
|
||||
|
||||
Persistence adapters and infrastructure for LandveX domain.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Domain (interfaces) ← Infrastructure (adapters)
|
||||
```
|
||||
|
||||
- **Domain defines** repository interfaces (WHAT)
|
||||
- **Infrastructure implements** them (HOW)
|
||||
- **Domain never depends** on infrastructure
|
||||
|
||||
## Repository Interfaces
|
||||
|
||||
| Interface | Purpose |
|
||||
|-----------|---------|
|
||||
| `FieldSessionRepository` | Store/retrieve field sessions |
|
||||
| `MissionRepository` | Store/retrieve missions |
|
||||
| `DecisionCaseRepository` | Store/retrieve decision cases |
|
||||
| `ArtifactRegistry` | Register/find artifacts |
|
||||
| `EventStore` | Append-only event log |
|
||||
| `UnitOfWork` | Coordinate multiple repositories |
|
||||
|
||||
## In-Memory Adapters
|
||||
|
||||
For testing and development:
|
||||
|
||||
| Adapter | Data Structure |
|
||||
|---------|---------------|
|
||||
| `InMemoryFieldSessionRepository` | Map<string, FieldSession> |
|
||||
| `InMemoryMissionRepository` | Map<string, Mission> |
|
||||
| `InMemoryDecisionCaseRepository` | Map<string, DecisionCase> |
|
||||
| `InMemoryArtifactRegistry` | Map<string, Artifact> |
|
||||
| `InMemoryEventStore` | Array<DomainEvent> |
|
||||
| `InMemoryUnitOfWork` | Coordinates all above |
|
||||
|
||||
## Future Adapters
|
||||
|
||||
| Adapter | Technology | Status |
|
||||
|---------|-----------|--------|
|
||||
| PostgreSQL repositories | PostgreSQL | Planned |
|
||||
| S3 Artifact Registry | AWS S3 / Cloudflare R2 | Planned |
|
||||
| Event Store | PostgreSQL / EventStoreDB | Planned |
|
||||
|
||||
## ADRs
|
||||
|
||||
- ADR-006: In-Memory Adapters for Testing
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@landvex/domain` — repository interfaces depend on domain types
|
||||
@@ -0,0 +1,39 @@
|
||||
# ADR-006: In-Memory Adapters for Testing
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
The domain model needs to be tested without external dependencies (PostgreSQL, S3, etc.). We need fast, isolated tests that verify domain behavior.
|
||||
|
||||
## Decision
|
||||
Implement in-memory adapters for all repository interfaces:
|
||||
- InMemoryFieldSessionRepository
|
||||
- InMemoryMissionRepository
|
||||
- InMemoryDecisionCaseRepository
|
||||
- InMemoryArtifactRegistry
|
||||
- InMemoryEventStore
|
||||
- InMemoryUnitOfWork
|
||||
|
||||
These adapters:
|
||||
- Implement the same interfaces as production adapters
|
||||
- Store data in memory (Map, Array)
|
||||
- Can be reset between tests
|
||||
- Are never used in production
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Tests run in milliseconds
|
||||
- No database setup required
|
||||
- Parallel test execution safe
|
||||
- Documents how repositories are used
|
||||
|
||||
### Negative
|
||||
- Not production-realistic (no transactions, no persistence)
|
||||
- Need separate integration tests for production adapters
|
||||
|
||||
## Related
|
||||
- ADR-001: Domain knows nothing about AI
|
||||
- ADR-002: Artifact is common base contract
|
||||
- ADR-003: Event Sourcing for traceability
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@landvex/infrastructure",
|
||||
"version": "0.1.0",
|
||||
"description": "Persistence adapters and infrastructure for LandveX domain",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@landvex/domain": "file:../domain"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user