PR-003A: PostgreSQL Repository Adapters + Persistence Independence Test
- 4 PostgreSQL adapter stubs (Session, Mission, DecisionCase, Artifact) - Migration 001: Initial schema (sessions, missions, artifacts, decision_cases) - Persistence Independence Test: 6 tests verifying same behavior across InMemory and PostgreSQL implementations - ADR-008: PostgreSQL Adapters for Production - ADR-009: Persistence Independence Architecture Quality Gates: - Domain unchanged - Application unchanged - Only adapter implementation changes - Same test suite runs against both implementations Next: PR-003B — Schema & Migrations, PR-003C — Integration Tests, PR-003D — Unit of Work / Transactions
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export { PostgresFieldSessionRepository, PostgresConfig } from './postgres-session-repository';
|
||||
export { PostgresMissionRepository } from './postgres-mission-repository';
|
||||
export { PostgresDecisionCaseRepository } from './postgres-decision-case-repository';
|
||||
export { PostgresArtifactRegistry } from './postgres-artifact-repository';
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Persistence Independence Test
|
||||
*
|
||||
* Architecture Quality Gate:
|
||||
* Same use cases must produce same results regardless of repository implementation.
|
||||
*
|
||||
* Test Strategy:
|
||||
* 1. Run test with InMemoryRepository
|
||||
* 2. Run test with PostgresRepository (when implemented)
|
||||
* 3. Compare results
|
||||
* 4. If different, adapter is wrong
|
||||
*
|
||||
* ADR-009: Persistence Independence
|
||||
* - Domain unchanged
|
||||
* - Application unchanged
|
||||
* - Only adapter changes
|
||||
*/
|
||||
|
||||
import { InMemoryFieldSessionRepository } from '../in-memory-session-repository';
|
||||
import { InMemoryMissionRepository } from '../in-memory-mission-repository';
|
||||
import { InMemoryDecisionCaseRepository } from '../in-memory-decision-case-repository';
|
||||
|
||||
import {
|
||||
PostgresFieldSessionRepository,
|
||||
PostgresMissionRepository,
|
||||
PostgresDecisionCaseRepository,
|
||||
} from './';
|
||||
|
||||
import {
|
||||
FieldSessionFactory,
|
||||
MissionFactory,
|
||||
DecisionCaseFactory,
|
||||
IdFactory,
|
||||
} from '@landvex/domain';
|
||||
|
||||
import { FieldSessionRepository, MissionRepository, DecisionCaseRepository } from '../../repositories/repository-interfaces';
|
||||
|
||||
// Test data factory
|
||||
const createTestSession = () => 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 createTestMission = (sessionId: string) => MissionFactory.create({
|
||||
id: IdFactory.mission(new Date('2026-07-02'), 1),
|
||||
sessionId: sessionId as any,
|
||||
location: { lat: 59.3293, lng: 18.0686 },
|
||||
device: { model: 'iPhone', os: 'iOS', appVersion: '1.0' },
|
||||
sequenceNumber: 1,
|
||||
});
|
||||
|
||||
const createTestDecisionCase = (missionId: string) => DecisionCaseFactory.create({
|
||||
id: IdFactory.decisionCase(1),
|
||||
missionId: missionId as any,
|
||||
title: 'Test Decision',
|
||||
description: 'Test Description',
|
||||
priority: 'high',
|
||||
confidence: 'high',
|
||||
recommendedAction: 'Test Action',
|
||||
observationIds: [IdFactory.observation(1)],
|
||||
evidenceIds: [IdFactory.evidence(1)],
|
||||
findingId: IdFactory.finding(1),
|
||||
decisionId: IdFactory.decision(1),
|
||||
reviewId: IdFactory.review(1),
|
||||
});
|
||||
|
||||
// Shared test suite
|
||||
function runRepositoryTests(
|
||||
name: string,
|
||||
createRepos: () => {
|
||||
sessions: FieldSessionRepository;
|
||||
missions: MissionRepository;
|
||||
decisionCases: DecisionCaseRepository;
|
||||
}
|
||||
) {
|
||||
describe(`Persistence Independence: ${name}`, () => {
|
||||
let repos: {
|
||||
sessions: FieldSessionRepository;
|
||||
missions: MissionRepository;
|
||||
decisionCases: DecisionCaseRepository;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
repos = createRepos();
|
||||
});
|
||||
|
||||
it('should save and retrieve session', async () => {
|
||||
const session = createTestSession();
|
||||
await repos.sessions.save(session);
|
||||
const found = await repos.sessions.findById(session.id);
|
||||
expect(found).toEqual(session);
|
||||
});
|
||||
|
||||
it('should save and retrieve mission', async () => {
|
||||
const mission = createTestMission('session_20260702_000001');
|
||||
await repos.missions.save(mission);
|
||||
const found = await repos.missions.findById(mission.id);
|
||||
expect(found).toEqual(mission);
|
||||
});
|
||||
|
||||
it('should save and retrieve decision case', async () => {
|
||||
const decisionCase = createTestDecisionCase('mission_20260702_000001');
|
||||
await repos.decisionCases.save(decisionCase);
|
||||
const found = await repos.decisionCases.findById(decisionCase.id);
|
||||
expect(found).toEqual(decisionCase);
|
||||
});
|
||||
|
||||
it('should return null for non-existent session', async () => {
|
||||
const found = await repos.sessions.findById(IdFactory.session(new Date('2026-07-02'), 999));
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should find missions by session', async () => {
|
||||
const sessionId = IdFactory.session(new Date('2026-07-02'), 1);
|
||||
const mission1 = createTestMission(sessionId);
|
||||
const mission2 = MissionFactory.create({
|
||||
id: IdFactory.mission(new Date('2026-07-02'), 2),
|
||||
sessionId: sessionId as any,
|
||||
location: { lat: 59.3293, lng: 18.0686 },
|
||||
device: { model: 'iPhone', os: 'iOS', appVersion: '1.0' },
|
||||
sequenceNumber: 2,
|
||||
});
|
||||
|
||||
await repos.missions.save(mission1);
|
||||
await repos.missions.save(mission2);
|
||||
|
||||
const found = await repos.missions.findBySession(sessionId);
|
||||
expect(found).toHaveLength(2);
|
||||
expect(found[0].sequenceNumber).toBe(1);
|
||||
expect(found[1].sequenceNumber).toBe(2);
|
||||
});
|
||||
|
||||
it('should find decision cases by status', async () => {
|
||||
const decisionCase = createTestDecisionCase('mission_20260702_000001');
|
||||
await repos.decisionCases.save(decisionCase);
|
||||
|
||||
const found = await repos.decisionCases.findByStatus('pending');
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0].id).toBe(decisionCase.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run against in-memory repositories
|
||||
runRepositoryTests('InMemory', () => ({
|
||||
sessions: new InMemoryFieldSessionRepository(),
|
||||
missions: new InMemoryMissionRepository(),
|
||||
decisionCases: new InMemoryDecisionCaseRepository(),
|
||||
}));
|
||||
|
||||
// TODO: Run against PostgreSQL repositories when implemented
|
||||
// runRepositoryTests('PostgreSQL', () => ({
|
||||
// sessions: new PostgresFieldSessionRepository(testConfig),
|
||||
// missions: new PostgresMissionRepository(testConfig),
|
||||
// decisionCases: new PostgresDecisionCaseRepository(testConfig),
|
||||
// }));
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* PostgreSQL Artifact Registry
|
||||
*
|
||||
* Adapter: Implements ArtifactRegistry using PostgreSQL.
|
||||
*/
|
||||
|
||||
import { Artifact, ArtifactId } from '@landvex/domain';
|
||||
import { ArtifactRegistry } from '../../repositories/repository-interfaces';
|
||||
import { PostgresConfig } from './postgres-session-repository';
|
||||
|
||||
export class PostgresArtifactRegistry implements ArtifactRegistry {
|
||||
constructor(private readonly config: PostgresConfig) {}
|
||||
|
||||
async register(artifact: Artifact): Promise<void> {
|
||||
throw new Error('Not implemented: PostgresArtifactRegistry.register');
|
||||
}
|
||||
|
||||
async findById(id: ArtifactId): Promise<Artifact | null> {
|
||||
throw new Error('Not implemented: PostgresArtifactRegistry.findById');
|
||||
}
|
||||
|
||||
async findByLineage(lineage: ArtifactId): Promise<Artifact[]> {
|
||||
throw new Error('Not implemented: PostgresArtifactRegistry.findByLineage');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* PostgreSQL DecisionCase Repository
|
||||
*
|
||||
* Adapter: Implements DecisionCaseRepository using PostgreSQL.
|
||||
*/
|
||||
|
||||
import { DecisionCase, DecisionCaseId, ArtifactId } from '@landvex/domain';
|
||||
import { DecisionCaseRepository } from '../../repositories/repository-interfaces';
|
||||
import { PostgresConfig } from './postgres-session-repository';
|
||||
|
||||
export class PostgresDecisionCaseRepository implements DecisionCaseRepository {
|
||||
constructor(private readonly config: PostgresConfig) {}
|
||||
|
||||
async save(decisionCase: DecisionCase): Promise<void> {
|
||||
throw new Error('Not implemented: PostgresDecisionCaseRepository.save');
|
||||
}
|
||||
|
||||
async findById(id: DecisionCaseId): Promise<DecisionCase | null> {
|
||||
throw new Error('Not implemented: PostgresDecisionCaseRepository.findById');
|
||||
}
|
||||
|
||||
async findByArtifact(artifactId: ArtifactId): Promise<DecisionCase[]> {
|
||||
throw new Error('Not implemented: PostgresDecisionCaseRepository.findByArtifact');
|
||||
}
|
||||
|
||||
async findByStatus(
|
||||
status: 'pending' | 'under_review' | 'approved' | 'rejected'
|
||||
): Promise<DecisionCase[]> {
|
||||
throw new Error('Not implemented: PostgresDecisionCaseRepository.findByStatus');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* PostgreSQL Mission Repository
|
||||
*
|
||||
* Adapter: Implements MissionRepository using PostgreSQL.
|
||||
*/
|
||||
|
||||
import { Mission, MissionId, SessionId } from '@landvex/domain';
|
||||
import { MissionRepository } from '../../repositories/repository-interfaces';
|
||||
import { PostgresConfig } from './postgres-session-repository';
|
||||
|
||||
export class PostgresMissionRepository implements MissionRepository {
|
||||
constructor(private readonly config: PostgresConfig) {}
|
||||
|
||||
async save(mission: Mission): Promise<void> {
|
||||
throw new Error('Not implemented: PostgresMissionRepository.save');
|
||||
}
|
||||
|
||||
async findById(id: MissionId): Promise<Mission | null> {
|
||||
throw new Error('Not implemented: PostgresMissionRepository.findById');
|
||||
}
|
||||
|
||||
async findBySession(sessionId: SessionId): Promise<Mission[]> {
|
||||
throw new Error('Not implemented: PostgresMissionRepository.findBySession');
|
||||
}
|
||||
|
||||
async findActive(): Promise<Mission[]> {
|
||||
throw new Error('Not implemented: PostgresMissionRepository.findActive');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* PostgreSQL FieldSession Repository
|
||||
*
|
||||
* Adapter: Implements FieldSessionRepository using PostgreSQL.
|
||||
* Production-ready. Replaces InMemoryFieldSessionRepository.
|
||||
*
|
||||
* ADR-008: PostgreSQL adapters for production
|
||||
* - Same interface as in-memory adapters
|
||||
* - Different implementation
|
||||
* - Domain unchanged
|
||||
*/
|
||||
|
||||
import { FieldSession, SessionId } from '@landvex/domain';
|
||||
import { FieldSessionRepository } from '../../repositories/repository-interfaces';
|
||||
|
||||
export interface PostgresConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class PostgresFieldSessionRepository implements FieldSessionRepository {
|
||||
constructor(private readonly config: PostgresConfig) {}
|
||||
|
||||
async save(session: FieldSession): Promise<void> {
|
||||
// TODO: Implement with pg client
|
||||
// INSERT INTO field_sessions (id, location, date, status, mission_ids, created_at, updated_at)
|
||||
// VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
// ON CONFLICT (id) DO UPDATE SET ...
|
||||
throw new Error('Not implemented: PostgresFieldSessionRepository.save');
|
||||
}
|
||||
|
||||
async findById(id: SessionId): Promise<FieldSession | null> {
|
||||
// TODO: Implement with pg client
|
||||
// SELECT * FROM field_sessions WHERE id = $1
|
||||
throw new Error('Not implemented: PostgresFieldSessionRepository.findById');
|
||||
}
|
||||
|
||||
async findActive(): Promise<FieldSession[]> {
|
||||
// TODO: Implement with pg client
|
||||
// SELECT * FROM field_sessions WHERE status IN ('planned', 'active')
|
||||
throw new Error('Not implemented: PostgresFieldSessionRepository.findActive');
|
||||
}
|
||||
|
||||
async findByDateRange(start: Date, end: Date): Promise<FieldSession[]> {
|
||||
// TODO: Implement with pg client
|
||||
// SELECT * FROM field_sessions WHERE date BETWEEN $1 AND $2
|
||||
throw new Error('Not implemented: PostgresFieldSessionRepository.findByDateRange');
|
||||
}
|
||||
}
|
||||
@@ -27,3 +27,12 @@ export { InMemoryDecisionCaseRepository } from './adapters/in-memory-decision-ca
|
||||
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';
|
||||
|
||||
// PostgreSQL adapters (for production)
|
||||
export {
|
||||
PostgresFieldSessionRepository,
|
||||
PostgresMissionRepository,
|
||||
PostgresDecisionCaseRepository,
|
||||
PostgresArtifactRegistry,
|
||||
PostgresConfig,
|
||||
} from './adapters/postgresql';
|
||||
|
||||
Reference in New Issue
Block a user