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
@@ -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>;
}