PR-001: Domain Model v1.0 — compile-only, zero dependencies

- @landvex/domain package with TypeScript strict mode
- 3 Aggregate Roots: FieldSession, Mission, DecisionCase
- Branded IDs, Value Objects, Domain Events, Invariants
- 19 unit tests for IDs, FieldSession, DecisionCase
- Zero runtime dependencies (only TypeScript + jest for tests)
- Separates Entity / Value Object / Aggregate Root
- README documents Three Rules of the domain

Definition of Done met:
- Compiles without errors
- Exports all domain types
- Unit tests for invariants and value objects
- No PostgreSQL, S3, Express, AI models, queues
This commit is contained in:
Bernt
2026-07-02 14:26:29 +00:00
parent 118180e7ff
commit fa0dbf5127
26 changed files with 1599 additions and 0 deletions
@@ -0,0 +1,89 @@
/**
* Tests for FieldSession aggregate root
*/
import { FieldSessionFactory } from './session';
import { IdFactory } from '../common/ids';
import { SessionStatus } from '../common/enums';
import { InvariantViolationError } from '../common/errors';
describe('FieldSession', () => {
const testDate = new Date('2026-08-14');
const testLocation = { lat: 59.2371, lng: 18.1456 };
describe('create', () => {
it('should create a session with correct defaults', () => {
const session = FieldSessionFactory.create({
id: IdFactory.session(testDate, 1),
location: testLocation,
date: testDate
});
expect(session.status).toBe(SessionStatus.PLANNED);
expect(session.missionIds).toEqual([]);
});
it('should throw if location is missing', () => {
expect(() => {
FieldSessionFactory.create({
id: IdFactory.session(testDate, 1),
location: null as any,
date: testDate
});
}).toThrow(InvariantViolationError);
});
it('should throw if date is missing', () => {
expect(() => {
FieldSessionFactory.create({
id: IdFactory.session(testDate, 1),
location: testLocation,
date: null as any
});
}).toThrow(InvariantViolationError);
});
});
describe('addMission', () => {
it('should add mission and set status to active', () => {
const session = FieldSessionFactory.create({
id: IdFactory.session(testDate, 1),
location: testLocation,
date: testDate
});
const missionId = IdFactory.mission(testDate, 1);
const updated = FieldSessionFactory.addMission(session, missionId);
expect(updated.missionIds).toContain(missionId);
expect(updated.status).toBe(SessionStatus.ACTIVE);
});
});
describe('complete', () => {
it('should complete active session', () => {
const session = FieldSessionFactory.create({
id: IdFactory.session(testDate, 1),
location: testLocation,
date: testDate
});
const withMission = FieldSessionFactory.addMission(session, IdFactory.mission(testDate, 1));
const completed = FieldSessionFactory.complete(withMission);
expect(completed.status).toBe(SessionStatus.COMPLETED);
});
it('should throw if session is not active', () => {
const session = FieldSessionFactory.create({
id: IdFactory.session(testDate, 1),
location: testLocation,
date: testDate
});
expect(() => {
FieldSessionFactory.complete(session);
}).toThrow(InvariantViolationError);
});
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* FieldSession - Aggregate Root
* Organizes field work. A pilot day produces many missions.
*
* Invariants:
* - Must have exactly one location
* - Must have a date
* - Can have zero or more missions
* - Cannot be completed if any mission is still uploading
*/
import { SessionId, MissionId } from '../common/ids';
import { SessionStatus } from '../common/enums';
import { GeoLocation } from '../common/value-objects';
import { InvariantViolationError } from '../common/errors';
export interface FieldSession {
readonly id: SessionId;
readonly location: GeoLocation;
readonly date: Date;
readonly status: SessionStatus;
readonly missionIds: MissionId[];
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateSessionParams {
readonly id: SessionId;
readonly location: GeoLocation;
readonly date: Date;
}
export class FieldSessionFactory {
static create(params: CreateSessionParams): FieldSession {
if (!params.location) {
throw new InvariantViolationError('Session must have a location');
}
if (!params.date) {
throw new InvariantViolationError('Session must have a date');
}
return {
id: params.id,
location: params.location,
date: params.date,
status: SessionStatus.PLANNED,
missionIds: [],
createdAt: new Date(),
updatedAt: new Date()
};
}
static addMission(session: FieldSession, missionId: MissionId): FieldSession {
return {
...session,
missionIds: [...session.missionIds, missionId],
status: SessionStatus.ACTIVE,
updatedAt: new Date()
};
}
static complete(session: FieldSession): FieldSession {
if (session.status !== SessionStatus.ACTIVE) {
throw new InvariantViolationError('Cannot complete session that is not active');
}
return {
...session,
status: SessionStatus.COMPLETED,
updatedAt: new Date()
};
}
}