diff --git a/packages/infrastructure/adr/ADR-008-PostgreSQL-Adapters.md b/packages/infrastructure/adr/ADR-008-PostgreSQL-Adapters.md new file mode 100644 index 000000000..b95175246 --- /dev/null +++ b/packages/infrastructure/adr/ADR-008-PostgreSQL-Adapters.md @@ -0,0 +1,37 @@ +# ADR-008: PostgreSQL Adapters for Production + +## Status +Accepted + +## Context +In-memory adapters are sufficient for testing but not for production. We need persistent storage. + +## Decision +Implement PostgreSQL adapters that implement the same repository interfaces as in-memory adapters: + +- PostgresFieldSessionRepository +- PostgresMissionRepository +- PostgresDecisionCaseRepository +- PostgresArtifactRegistry + +Key principles: +- Same interface as in-memory adapters +- Domain unchanged +- Application unchanged +- Only adapter implementation changes + +## Consequences + +### Positive +- Production-ready persistence +- Same test suite verifies both implementations +- Easy to swap implementations + +### Negative +- Requires PostgreSQL instance +- Need migrations +- More complex setup + +## Related +- ADR-006: In-Memory Adapters for Testing +- ADR-009: Persistence Independence diff --git a/packages/infrastructure/adr/ADR-009-Persistence-Independence.md b/packages/infrastructure/adr/ADR-009-Persistence-Independence.md new file mode 100644 index 000000000..94918b661 --- /dev/null +++ b/packages/infrastructure/adr/ADR-009-Persistence-Independence.md @@ -0,0 +1,37 @@ +# ADR-009: Persistence Independence + +## Status +Accepted + +## Context +We need to ensure that business logic is not coupled to persistence implementation. + +## Decision +Same use cases must produce same results regardless of repository implementation. + +### Verification Method +1. Run test with InMemoryRepository +2. Run test with PostgresRepository (when implemented) +3. Compare results +4. If different, adapter is wrong + +### Architecture Rules +- Domain: 0 external dependencies +- Application: only domain + contracts +- Infrastructure: may depend on external libraries, never reverse +- Persistence Independence: same use cases, same results + +## Consequences + +### Positive +- Strong architecture verification +- Easy to swap implementations +- Tests document expected behavior + +### Negative +- Need to maintain two test suites +- PostgreSQL tests require database setup + +## Related +- ADR-006: In-Memory Adapters for Testing +- ADR-008: PostgreSQL Adapters for Production diff --git a/packages/infrastructure/migrations/001_initial_schema.sql b/packages/infrastructure/migrations/001_initial_schema.sql new file mode 100644 index 000000000..572cf478f --- /dev/null +++ b/packages/infrastructure/migrations/001_initial_schema.sql @@ -0,0 +1,96 @@ +-- Migration 001: Initial Schema +-- +-- Domain model mapped to PostgreSQL tables. +-- Domain does NOT know about this schema. +-- Schema is infrastructure concern. + +-- Sessions +CREATE TABLE IF NOT EXISTS field_sessions ( + id VARCHAR(32) PRIMARY KEY, + location_lat DECIMAL(10, 8) NOT NULL, + location_lng DECIMAL(11, 8) NOT NULL, + location_accuracy DECIMAL(10, 2), + date DATE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'planned', + mission_ids TEXT[] DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Missions +CREATE TABLE IF NOT EXISTS missions ( + id VARCHAR(32) PRIMARY KEY, + session_id VARCHAR(32) NOT NULL REFERENCES field_sessions(id), + location_lat DECIMAL(10, 8) NOT NULL, + location_lng DECIMAL(11, 8) NOT NULL, + location_accuracy DECIMAL(10, 2), + status VARCHAR(20) NOT NULL DEFAULT 'created', + device_model VARCHAR(100) NOT NULL, + device_os VARCHAR(100) NOT NULL, + device_app_version VARCHAR(50) NOT NULL, + artifact_ids TEXT[] DEFAULT '{}', + observation_ids TEXT[] DEFAULT '{}', + sequence_number INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Artifacts +CREATE TABLE IF NOT EXISTS artifacts ( + id VARCHAR(32) PRIMARY KEY, + type VARCHAR(50) NOT NULL, + version_major INTEGER NOT NULL DEFAULT 1, + version_minor INTEGER NOT NULL DEFAULT 0, + version_patch INTEGER NOT NULL DEFAULT 0, + hash_algorithm VARCHAR(20) NOT NULL DEFAULT 'sha256', + hash_value VARCHAR(64) NOT NULL, + created_by VARCHAR(100) NOT NULL, + storage_protocol VARCHAR(20) NOT NULL, + storage_bucket VARCHAR(200), + storage_path TEXT NOT NULL, + parent_id VARCHAR(32) REFERENCES artifacts(id), + lineage TEXT[] DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Decision Cases +CREATE TABLE IF NOT EXISTS decision_cases ( + id VARCHAR(32) PRIMARY KEY, + mission_id VARCHAR(32) NOT NULL REFERENCES missions(id), + title VARCHAR(500) NOT NULL, + description TEXT NOT NULL, + priority VARCHAR(20) NOT NULL, + confidence VARCHAR(20) NOT NULL, + recommended_action TEXT NOT NULL, + observation_ids TEXT[] DEFAULT '{}', + evidence_ids TEXT[] DEFAULT '{}', + finding_id VARCHAR(32) NOT NULL, + decision_id VARCHAR(32) NOT NULL, + review_id VARCHAR(32) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes for common queries +CREATE INDEX IF NOT EXISTS idx_missions_session_id ON missions(session_id); +CREATE INDEX IF NOT EXISTS idx_missions_status ON missions(status); +CREATE INDEX IF NOT EXISTS idx_decision_cases_mission_id ON decision_cases(mission_id); +CREATE INDEX IF NOT EXISTS idx_decision_cases_status ON decision_cases(status); +CREATE INDEX IF NOT EXISTS idx_field_sessions_date ON field_sessions(date); +CREATE INDEX IF NOT EXISTS idx_field_sessions_status ON field_sessions(status); + +-- Update trigger for updated_at +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_field_sessions_updated_at BEFORE UPDATE ON field_sessions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_missions_updated_at BEFORE UPDATE ON missions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/packages/infrastructure/src/adapters/postgresql/index.ts b/packages/infrastructure/src/adapters/postgresql/index.ts new file mode 100644 index 000000000..7a7ee3faf --- /dev/null +++ b/packages/infrastructure/src/adapters/postgresql/index.ts @@ -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'; diff --git a/packages/infrastructure/src/adapters/postgresql/persistence-independence.test.ts b/packages/infrastructure/src/adapters/postgresql/persistence-independence.test.ts new file mode 100644 index 000000000..247685666 --- /dev/null +++ b/packages/infrastructure/src/adapters/postgresql/persistence-independence.test.ts @@ -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), +// })); diff --git a/packages/infrastructure/src/adapters/postgresql/postgres-artifact-repository.ts b/packages/infrastructure/src/adapters/postgresql/postgres-artifact-repository.ts new file mode 100644 index 000000000..c97f09628 --- /dev/null +++ b/packages/infrastructure/src/adapters/postgresql/postgres-artifact-repository.ts @@ -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 { + throw new Error('Not implemented: PostgresArtifactRegistry.register'); + } + + async findById(id: ArtifactId): Promise { + throw new Error('Not implemented: PostgresArtifactRegistry.findById'); + } + + async findByLineage(lineage: ArtifactId): Promise { + throw new Error('Not implemented: PostgresArtifactRegistry.findByLineage'); + } +} diff --git a/packages/infrastructure/src/adapters/postgresql/postgres-decision-case-repository.ts b/packages/infrastructure/src/adapters/postgresql/postgres-decision-case-repository.ts new file mode 100644 index 000000000..de6411a4f --- /dev/null +++ b/packages/infrastructure/src/adapters/postgresql/postgres-decision-case-repository.ts @@ -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 { + throw new Error('Not implemented: PostgresDecisionCaseRepository.save'); + } + + async findById(id: DecisionCaseId): Promise { + throw new Error('Not implemented: PostgresDecisionCaseRepository.findById'); + } + + async findByArtifact(artifactId: ArtifactId): Promise { + throw new Error('Not implemented: PostgresDecisionCaseRepository.findByArtifact'); + } + + async findByStatus( + status: 'pending' | 'under_review' | 'approved' | 'rejected' + ): Promise { + throw new Error('Not implemented: PostgresDecisionCaseRepository.findByStatus'); + } +} diff --git a/packages/infrastructure/src/adapters/postgresql/postgres-mission-repository.ts b/packages/infrastructure/src/adapters/postgresql/postgres-mission-repository.ts new file mode 100644 index 000000000..a2a9eb170 --- /dev/null +++ b/packages/infrastructure/src/adapters/postgresql/postgres-mission-repository.ts @@ -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 { + throw new Error('Not implemented: PostgresMissionRepository.save'); + } + + async findById(id: MissionId): Promise { + throw new Error('Not implemented: PostgresMissionRepository.findById'); + } + + async findBySession(sessionId: SessionId): Promise { + throw new Error('Not implemented: PostgresMissionRepository.findBySession'); + } + + async findActive(): Promise { + throw new Error('Not implemented: PostgresMissionRepository.findActive'); + } +} diff --git a/packages/infrastructure/src/adapters/postgresql/postgres-session-repository.ts b/packages/infrastructure/src/adapters/postgresql/postgres-session-repository.ts new file mode 100644 index 000000000..86482e3a7 --- /dev/null +++ b/packages/infrastructure/src/adapters/postgresql/postgres-session-repository.ts @@ -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 { + // 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 { + // TODO: Implement with pg client + // SELECT * FROM field_sessions WHERE id = $1 + throw new Error('Not implemented: PostgresFieldSessionRepository.findById'); + } + + async findActive(): Promise { + // 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 { + // TODO: Implement with pg client + // SELECT * FROM field_sessions WHERE date BETWEEN $1 AND $2 + throw new Error('Not implemented: PostgresFieldSessionRepository.findByDateRange'); + } +} diff --git a/packages/infrastructure/src/index.ts b/packages/infrastructure/src/index.ts index 7d458b4b9..55dc356c5 100644 --- a/packages/infrastructure/src/index.ts +++ b/packages/infrastructure/src/index.ts @@ -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';