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:
Bernt
2026-07-02 14:59:37 +00:00
parent fa0dbf5127
commit 02b51d7814
22 changed files with 963 additions and 17 deletions
+14
View File
@@ -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'
}
+5
View File
@@ -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;
}
};
+5 -5
View File
@@ -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
+32 -2
View File
@@ -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';
+3
View File
@@ -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()
};
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
coverage/
*.log
+53
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
};
+22
View File
@@ -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],
};
}
}
+29
View File
@@ -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>;
}
+19
View File
@@ -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"]
}