diff --git a/CHANGELOG.md b/CHANGELOG.md index 209142eb..378a0ae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Domain primitive identities**: Newly created audiences, audience pains, components, decisions, dependencies, goals, guidelines, invariants, projects, relations, sessions, value propositions, and workers now receive domain-owned UUID identities. Existing persisted identifiers remain supported during replay and through CLI operations without migration. + ## [3.16.1] - 2026-07-10 ### Fixed diff --git a/src/application/context/audience-pains/add/AddAudiencePainCommandHandler.ts b/src/application/context/audience-pains/add/AddAudiencePainCommandHandler.ts index 24792c6a..ebec6977 100644 --- a/src/application/context/audience-pains/add/AddAudiencePainCommandHandler.ts +++ b/src/application/context/audience-pains/add/AddAudiencePainCommandHandler.ts @@ -12,7 +12,6 @@ import { AddAudiencePainCommand } from "./AddAudiencePainCommand.js"; import { IAudiencePainAddedEventWriter } from "./IAudiencePainAddedEventWriter.js"; import { IEventBus } from "../../../messaging/IEventBus.js"; import { AudiencePain } from "../../../../domain/audience-pains/AudiencePain.js"; -import { IdGenerator } from "../../../identity/IdGenerator.js"; export class AddAudiencePainCommandHandler { constructor( @@ -21,11 +20,9 @@ export class AddAudiencePainCommandHandler { ) {} async execute(command: AddAudiencePainCommand): Promise<{ painId: string }> { - // Generate unique ID for new pain - const painId = IdGenerator.generate(); - // 1. Create new aggregate - const pain = AudiencePain.create(painId); + const pain = AudiencePain.create(); + const painId = pain.snapshot.id; // 2. Domain logic produces event const event = pain.add(command.title, command.description); diff --git a/src/application/context/audiences/add/AddAudienceCommandHandler.ts b/src/application/context/audiences/add/AddAudienceCommandHandler.ts index 81b13681..92fdc998 100644 --- a/src/application/context/audiences/add/AddAudienceCommandHandler.ts +++ b/src/application/context/audiences/add/AddAudienceCommandHandler.ts @@ -12,7 +12,6 @@ import { AddAudienceCommand } from "./AddAudienceCommand.js"; import { IAudienceAddedEventWriter } from "./IAudienceAddedEventWriter.js"; import { IEventBus } from "../../../messaging/IEventBus.js"; import { Audience } from "../../../../domain/audiences/Audience.js"; -import { IdGenerator } from "../../../identity/IdGenerator.js"; export class AddAudienceCommandHandler { constructor( @@ -21,11 +20,9 @@ export class AddAudienceCommandHandler { ) {} async execute(command: AddAudienceCommand): Promise<{ audienceId: string }> { - // Generate unique ID for new audience - const audienceId = IdGenerator.generate(); - // 1. Create new aggregate - const audience = Audience.create(audienceId); + const audience = Audience.create(); + const audienceId = audience.snapshot.id; // 2. Domain logic produces event const event = audience.add( diff --git a/src/application/context/components/add/AddComponentCommandHandler.ts b/src/application/context/components/add/AddComponentCommandHandler.ts index 7ac3b26d..ba0f2238 100644 --- a/src/application/context/components/add/AddComponentCommandHandler.ts +++ b/src/application/context/components/add/AddComponentCommandHandler.ts @@ -4,7 +4,6 @@ import { IComponentAddReader } from "./IComponentAddReader.js"; import { IEventBus } from "../../../messaging/IEventBus.js"; import { Component } from "../../../../domain/components/Component.js"; import { ComponentEvent } from "../../../../domain/components/EventIndex.js"; -import { IdGenerator } from "../../../identity/IdGenerator.js"; export class AddComponentCommandHandler { constructor( @@ -34,8 +33,8 @@ export class AddComponentCommandHandler { ); } else { // Create new component - componentId = IdGenerator.generate(); - const component = Component.create(componentId); + const component = Component.create(); + componentId = component.snapshot.id; event = component.add( command.name, diff --git a/src/application/context/decisions/add/AddDecisionCommandHandler.ts b/src/application/context/decisions/add/AddDecisionCommandHandler.ts index 57321b8f..c47ec6a2 100644 --- a/src/application/context/decisions/add/AddDecisionCommandHandler.ts +++ b/src/application/context/decisions/add/AddDecisionCommandHandler.ts @@ -1,4 +1,3 @@ -import { IdGenerator } from "../../../identity/IdGenerator.js"; import { AddDecisionCommand } from "./AddDecisionCommand.js"; import { IDecisionAddedEventWriter } from "./IDecisionAddedEventWriter.js"; import { IEventBus } from "../../../messaging/IEventBus.js"; @@ -17,8 +16,8 @@ export class AddDecisionCommandHandler { async execute(command: AddDecisionCommand): Promise<{ decisionId: string }> { // 1. Create new aggregate with unique ID - const decisionId = IdGenerator.generate(); - const decision = Decision.create(decisionId); + const decision = Decision.create(); + const decisionId = decision.snapshot.id; // 2. Domain logic produces event const event = decision.add( diff --git a/src/application/context/dependencies/add/AddDependencyCommandHandler.ts b/src/application/context/dependencies/add/AddDependencyCommandHandler.ts index eec434bc..b1ee0bbd 100644 --- a/src/application/context/dependencies/add/AddDependencyCommandHandler.ts +++ b/src/application/context/dependencies/add/AddDependencyCommandHandler.ts @@ -17,17 +17,18 @@ export class AddDependencyCommandHandler { async execute(command: AddDependencyCommand): Promise<{ dependencyId: string }> { const identity = this.resolveIdentity(command); - const dependencyId = `dep_${identity.ecosystem}_${identity.packageName}`.toLowerCase().replace(/[^a-z0-9_]/g, '_'); - // Check if dependency already exists (idempotent behavior) - const existingDependency = await this.dependencyReader.findById(dependencyId); + const existingDependency = await this.dependencyReader.findByIdentity( + identity.ecosystem, + identity.packageName, + ); if (existingDependency) { - // Silently succeed - idempotent operation - return { dependencyId }; + return { dependencyId: existingDependency.dependencyId }; } // 1. Create new aggregate - const dependency = Dependency.create(dependencyId); + const dependency = Dependency.create(); + const dependencyId = dependency.snapshot.id; // 2. Domain logic produces event const event = dependency.add( diff --git a/src/application/context/dependencies/add/IDependencyAddReader.ts b/src/application/context/dependencies/add/IDependencyAddReader.ts index f4e66452..14f9f976 100644 --- a/src/application/context/dependencies/add/IDependencyAddReader.ts +++ b/src/application/context/dependencies/add/IDependencyAddReader.ts @@ -6,4 +6,8 @@ import { DependencyView } from "../DependencyView.js"; */ export interface IDependencyAddReader { findById(dependencyId: string): Promise; + findByIdentity( + ecosystem: string, + packageName: string, + ): Promise; } diff --git a/src/application/context/goals/add/AddGoalCommandHandler.ts b/src/application/context/goals/add/AddGoalCommandHandler.ts index 413e4def..3defca54 100644 --- a/src/application/context/goals/add/AddGoalCommandHandler.ts +++ b/src/application/context/goals/add/AddGoalCommandHandler.ts @@ -1,4 +1,3 @@ -import { IdGenerator } from "../../../identity/IdGenerator.js"; import { AddGoalCommand } from "./AddGoalCommand.js"; import { IGoalAddedEventWriter } from "./IGoalAddedEventWriter.js"; import { IGoalUpdatedEventWriter } from "../update/IGoalUpdatedEventWriter.js"; @@ -26,11 +25,9 @@ export class AddGoalCommandHandler { ) {} async execute(command: AddGoalCommand): Promise<{ goalId: string }> { - // Generate new goal ID (handler owns ID generation) - const goalId = IdGenerator.generate(); - // Create new aggregate - const goal = Goal.create(goalId); + const goal = Goal.create(); + const goalId = goal.snapshot.id; // Domain logic produces event const event = goal.add( diff --git a/src/application/context/guidelines/add/AddGuidelineCommandHandler.ts b/src/application/context/guidelines/add/AddGuidelineCommandHandler.ts index 6972dd5e..10aa7496 100644 --- a/src/application/context/guidelines/add/AddGuidelineCommandHandler.ts +++ b/src/application/context/guidelines/add/AddGuidelineCommandHandler.ts @@ -9,7 +9,6 @@ import { AddGuidelineCommand } from "./AddGuidelineCommand.js"; import { IGuidelineAddedEventWriter } from "./IGuidelineAddedEventWriter.js"; import { IEventBus } from "../../../messaging/IEventBus.js"; import { Guideline } from "../../../../domain/guidelines/Guideline.js"; -import { IdGenerator } from "../../../identity/IdGenerator.js"; export class AddGuidelineCommandHandler { constructor( @@ -19,8 +18,8 @@ export class AddGuidelineCommandHandler { async execute(command: AddGuidelineCommand): Promise<{ guidelineId: string }> { // 1. Create new aggregate - const guidelineId = IdGenerator.generate(); - const guideline = Guideline.create(guidelineId); + const guideline = Guideline.create(); + const guidelineId = guideline.snapshot.id; // 2. Domain logic produces event const event = guideline.add( diff --git a/src/application/context/invariants/add/AddInvariantCommandHandler.ts b/src/application/context/invariants/add/AddInvariantCommandHandler.ts index 74b5a276..bf2e2f24 100644 --- a/src/application/context/invariants/add/AddInvariantCommandHandler.ts +++ b/src/application/context/invariants/add/AddInvariantCommandHandler.ts @@ -14,7 +14,6 @@ import { IInvariantAddedEventWriter } from "./IInvariantAddedEventWriter.js"; import { IInvariantAddReader } from "./IInvariantAddReader.js"; import { IEventBus } from "../../../messaging/IEventBus.js"; import { Invariant } from "../../../../domain/invariants/Invariant.js"; -import { IdGenerator } from "../../../identity/IdGenerator.js"; export class AddInvariantCommandHandler { constructor( @@ -31,8 +30,8 @@ export class AddInvariantCommandHandler { } // 1. Create new aggregate - const invariantId = IdGenerator.generate(); - const invariant = Invariant.create(invariantId); + const invariant = Invariant.create(); + const invariantId = invariant.snapshot.id; // 2. Domain logic produces event const event = invariant.add( diff --git a/src/application/context/project/init/InitializeProjectCommandHandler.ts b/src/application/context/project/init/InitializeProjectCommandHandler.ts index 8b18b6a3..9d1baebc 100644 --- a/src/application/context/project/init/InitializeProjectCommandHandler.ts +++ b/src/application/context/project/init/InitializeProjectCommandHandler.ts @@ -47,8 +47,8 @@ export class InitializeProjectCommandHandler { } // 1. Create new aggregate - const projectId = this.projectIdentityResolver.generateProjectId(); - const project = Project.create(projectId); + const project = Project.create(); + const projectId = project.snapshot.id; // 2. Domain logic produces event const event = project.initialize( diff --git a/src/application/context/relations/add/AddRelationCommandHandler.ts b/src/application/context/relations/add/AddRelationCommandHandler.ts index 8145bfee..3d92d9cd 100644 --- a/src/application/context/relations/add/AddRelationCommandHandler.ts +++ b/src/application/context/relations/add/AddRelationCommandHandler.ts @@ -1,4 +1,3 @@ -import { IdGenerator } from "../../../identity/IdGenerator.js"; import { AddRelationCommand } from "./AddRelationCommand.js"; import { IRelationAddedEventWriter } from "./IRelationAddedEventWriter.js"; import { IRelationAddedReader } from "./IRelationAddedReader.js"; @@ -32,11 +31,9 @@ export class AddRelationCommandHandler { return { relationId: existingRelation.relationId }; } - // Generate new relation ID - const relationId = IdGenerator.generate(); - // Create new aggregate - const relation = Relation.create(relationId); + const relation = Relation.create(); + const relationId = relation.snapshot.id; // Domain logic produces event const event = relation.add( diff --git a/src/application/context/sessions/start/StartSessionCommandHandler.ts b/src/application/context/sessions/start/StartSessionCommandHandler.ts index a41d934d..b39921c8 100644 --- a/src/application/context/sessions/start/StartSessionCommandHandler.ts +++ b/src/application/context/sessions/start/StartSessionCommandHandler.ts @@ -1,4 +1,3 @@ -import { IdGenerator } from "../../../identity/IdGenerator.js"; import { StartSessionCommand } from "./StartSessionCommand.js"; import { ISessionStartedEventWriter } from "./ISessionStartedEventWriter.js"; import { IEventBus } from "../../../messaging/IEventBus.js"; @@ -17,11 +16,9 @@ export class StartSessionCommandHandler { async execute( command: StartSessionCommand ): Promise<{ sessionId: string }> { - // Generate new session ID - const sessionId = IdGenerator.generate(); - // Create new aggregate - const session = Session.create(sessionId); + const session = Session.create(); + const sessionId = session.snapshot.id; // Domain logic produces event const event = session.start(); diff --git a/src/application/context/value-propositions/add/AddValuePropositionCommandHandler.ts b/src/application/context/value-propositions/add/AddValuePropositionCommandHandler.ts index b8a8d756..2e33590a 100644 --- a/src/application/context/value-propositions/add/AddValuePropositionCommandHandler.ts +++ b/src/application/context/value-propositions/add/AddValuePropositionCommandHandler.ts @@ -3,7 +3,6 @@ import { IValuePropositionAddedEventWriter } from "./IValuePropositionAddedEvent import { IEventBus } from "../../../messaging/IEventBus.js"; import { ValueProposition } from "../../../../domain/value-propositions/ValueProposition.js"; import { ValuePropositionAddedEvent } from "../../../../domain/value-propositions/add/ValuePropositionAddedEvent.js"; -import { IdGenerator } from "../../../identity/IdGenerator.js"; /** * Handler for adding a new value proposition @@ -19,8 +18,8 @@ export class AddValuePropositionCommandHandler { command: AddValuePropositionCommand ): Promise<{ valuePropositionId: string }> { // 1. Create new aggregate with generated ID - const valuePropositionId = IdGenerator.generate(); - const valueProposition = ValueProposition.create(valuePropositionId); + const valueProposition = ValueProposition.create(); + const valuePropositionId = valueProposition.snapshot.id; // 2. Domain logic produces event const event = valueProposition.add( diff --git a/src/application/host/workers/WorkerId.ts b/src/application/host/workers/WorkerId.ts index e1c17381..b26b5da1 100644 --- a/src/application/host/workers/WorkerId.ts +++ b/src/application/host/workers/WorkerId.ts @@ -1,14 +1,8 @@ -/** - * WorkerId - Branded type for worker identity - * - * Represents a unique identifier for a worker (LLM agent session). - * Each terminal/IDE session gets a unique workerId that persists - * for the lifetime of that session. - * - * This is a branded type to ensure type safety and prevent - * accidental mixing with regular strings. - */ -export type WorkerId = string & { readonly __brand: "WorkerId" }; +import { + WorkerId, +} from "../../../domain/workers/WorkerId.js"; + +export type { WorkerId } from "../../../domain/workers/WorkerId.js"; /** * Creates a WorkerId from a string value. @@ -17,5 +11,5 @@ export type WorkerId = string & { readonly __brand: "WorkerId" }; * @returns The branded WorkerId */ export function createWorkerId(value: string): WorkerId { - return value as WorkerId; + return WorkerId.fromLegacy(value); } diff --git a/src/domain/audience-pains/AudiencePain.ts b/src/domain/audience-pains/AudiencePain.ts index 119f3dea..9be717e5 100644 --- a/src/domain/audience-pains/AudiencePain.ts +++ b/src/domain/audience-pains/AudiencePain.ts @@ -1,5 +1,6 @@ import { BaseAggregate, AggregateState } from "../BaseAggregate.js"; import { UUID } from "../BaseEvent.js"; +import { AudiencePainId } from "./AudiencePainId.js"; import { ValidationRuleSet } from "../validation/ValidationRule.js"; import { AudiencePainEvent, AudiencePainAddedEvent, AudiencePainUpdatedEvent } from "./EventIndex.js"; import { AudiencePainEventType, AudiencePainErrorMessages, AudiencePainStatus, AudiencePainStatusType } from "./Constants.js"; @@ -7,7 +8,7 @@ import { TITLE_RULES } from "./rules/TitleRules.js"; import { DESCRIPTION_RULES } from "./rules/DescriptionRules.js"; export interface AudiencePainState extends AggregateState { - id: UUID; + id: AudiencePainId; title: string; description: string; status: AudiencePainStatusType; @@ -47,7 +48,7 @@ export class AudiencePain extends BaseAggregate; +export const AudiencePainId = defineDomainIdentity("AudiencePainId"); diff --git a/src/domain/audiences/Audience.ts b/src/domain/audiences/Audience.ts index ee094443..87fa9a66 100644 --- a/src/domain/audiences/Audience.ts +++ b/src/domain/audiences/Audience.ts @@ -10,6 +10,7 @@ import { AggregateState, } from "../BaseAggregate.js"; import { UUID, BaseEvent } from "../BaseEvent.js"; +import { AudienceId } from "./AudienceId.js"; import { ValidationRuleSet } from "../validation/ValidationRule.js"; import { AudienceEvent, @@ -30,7 +31,7 @@ import { PRIORITY_RULES } from "./rules/PriorityRules.js"; * Domain state: business properties + aggregate metadata */ export interface AudienceState extends AggregateState { - id: UUID; // Aggregate identity + id: AudienceId; // Aggregate identity name: string; // Required: audience name description: string; // Required: who they are priority: AudiencePriorityType; // Required: primary/secondary/tertiary @@ -80,7 +81,7 @@ export class Audience extends BaseAggregate { * Creates a new Audience aggregate. * Use this when starting a new aggregate that will emit its first event. */ - static create(id: UUID): Audience { + static create(id: AudienceId = AudienceId.create()): Audience { const state: AudienceState = { id, name: "", @@ -98,7 +99,7 @@ export class Audience extends BaseAggregate { */ static rehydrate(id: UUID, history: AudienceEvent[]): Audience { const state: AudienceState = { - id, + id: AudienceId.fromLegacy(id), name: "", description: "", priority: "primary", diff --git a/src/domain/audiences/AudienceId.ts b/src/domain/audiences/AudienceId.ts new file mode 100644 index 00000000..4646545d --- /dev/null +++ b/src/domain/audiences/AudienceId.ts @@ -0,0 +1,4 @@ +import { defineDomainIdentity, DomainIdentity } from "../identity/DomainIdentity.js"; + +export type AudienceId = DomainIdentity<"AudienceId">; +export const AudienceId = defineDomainIdentity("AudienceId"); diff --git a/src/domain/components/Component.ts b/src/domain/components/Component.ts index b4235db6..3b451011 100644 --- a/src/domain/components/Component.ts +++ b/src/domain/components/Component.ts @@ -1,5 +1,6 @@ import { BaseAggregate, AggregateState } from "../BaseAggregate.js"; import { UUID, ISO8601 } from "../BaseEvent.js"; +import { ComponentId } from "./ComponentId.js"; import { ValidationRuleSet } from "../validation/ValidationRule.js"; import { ComponentEvent, @@ -25,7 +26,7 @@ import { PATH_RULES } from "./rules/PathRules.js"; import { DEPRECATION_REASON_RULES } from "./rules/DeprecationReasonRules.js"; export interface ComponentState extends AggregateState { - id: UUID; + id: ComponentId; name: string; type: ComponentTypeValue; description: string; @@ -96,7 +97,7 @@ export class Component extends BaseAggregate { } } - static create(id: UUID): Component { + static create(id: ComponentId = ComponentId.create()): Component { const state: ComponentState = { id, name: "", @@ -117,7 +118,7 @@ export class Component extends BaseAggregate { */ static rehydrate(id: UUID, history: ComponentEvent[]): Component { const state: ComponentState = { - id, + id: ComponentId.fromLegacy(id), name: "", type: "service" as ComponentTypeValue, description: "", diff --git a/src/domain/components/ComponentId.ts b/src/domain/components/ComponentId.ts new file mode 100644 index 00000000..1068d00e --- /dev/null +++ b/src/domain/components/ComponentId.ts @@ -0,0 +1,4 @@ +import { defineDomainIdentity, DomainIdentity } from "../identity/DomainIdentity.js"; + +export type ComponentId = DomainIdentity<"ComponentId">; +export const ComponentId = defineDomainIdentity("ComponentId"); diff --git a/src/domain/decisions/Decision.ts b/src/domain/decisions/Decision.ts index 73b236df..89fff1cb 100644 --- a/src/domain/decisions/Decision.ts +++ b/src/domain/decisions/Decision.ts @@ -1,5 +1,6 @@ import { BaseAggregate, AggregateState } from "../BaseAggregate.js"; import { UUID, ISO8601 } from "../BaseEvent.js"; +import { DecisionId } from "./DecisionId.js"; import { ValidationRuleSet } from "../validation/ValidationRule.js"; import { DecisionEvent, DecisionAddedEvent, DecisionUpdatedEvent, DecisionReversedEvent, DecisionSupersededEvent, DecisionRestoredEvent } from "./EventIndex.js"; import { DecisionEventType, DecisionStatus, DecisionStatusType, DecisionErrorMessages } from "./Constants.js"; @@ -13,7 +14,7 @@ import { SUPERSEDED_BY_RULES } from "./rules/SupersededByRules.js"; // Domain state: business properties + aggregate metadata export interface DecisionState extends AggregateState { - id: UUID; + id: DecisionId; title: string; context: string; rationale: string | null; @@ -85,7 +86,7 @@ export class Decision extends BaseAggregate { } } - static create(id: UUID): Decision { + static create(id: DecisionId = DecisionId.create()): Decision { const state: DecisionState = { id, title: "", @@ -108,7 +109,7 @@ export class Decision extends BaseAggregate { */ static rehydrate(id: UUID, history: DecisionEvent[]): Decision { const state: DecisionState = { - id, + id: DecisionId.fromLegacy(id), title: "", context: "", rationale: null, diff --git a/src/domain/decisions/DecisionId.ts b/src/domain/decisions/DecisionId.ts new file mode 100644 index 00000000..cb33afe3 --- /dev/null +++ b/src/domain/decisions/DecisionId.ts @@ -0,0 +1,4 @@ +import { defineDomainIdentity, DomainIdentity } from "../identity/DomainIdentity.js"; + +export type DecisionId = DomainIdentity<"DecisionId">; +export const DecisionId = defineDomainIdentity("DecisionId"); diff --git a/src/domain/dependencies/Dependency.ts b/src/domain/dependencies/Dependency.ts index 6271443c..54a776c0 100644 --- a/src/domain/dependencies/Dependency.ts +++ b/src/domain/dependencies/Dependency.ts @@ -1,5 +1,6 @@ import { BaseAggregate, AggregateState } from "../BaseAggregate.js"; import { UUID } from "../BaseEvent.js"; +import { DependencyId } from "./DependencyId.js"; import { ValidationRuleSet } from "../validation/ValidationRule.js"; import { DependencyEvent, @@ -20,7 +21,7 @@ import { STATUS_RULES } from "./rules/StatusRules.js"; // Domain state: business properties + aggregate metadata export interface DependencyState extends AggregateState { - id: UUID; + id: DependencyId; name: string; ecosystem: string; packageName: string; @@ -71,7 +72,7 @@ export class Dependency extends BaseAggregate } } - static create(id: UUID): Dependency { + static create(id: DependencyId = DependencyId.create()): Dependency { const state: DependencyState = { id, name: "", @@ -92,7 +93,7 @@ export class Dependency extends BaseAggregate */ static rehydrate(id: UUID, history: DependencyEvent[]): Dependency { const state: DependencyState = { - id, + id: DependencyId.fromLegacy(id), name: "", ecosystem: "", packageName: "", diff --git a/src/domain/dependencies/DependencyId.ts b/src/domain/dependencies/DependencyId.ts new file mode 100644 index 00000000..c9dd77b3 --- /dev/null +++ b/src/domain/dependencies/DependencyId.ts @@ -0,0 +1,4 @@ +import { defineDomainIdentity, DomainIdentity } from "../identity/DomainIdentity.js"; + +export type DependencyId = DomainIdentity<"DependencyId">; +export const DependencyId = defineDomainIdentity("DependencyId"); diff --git a/src/domain/goals/Goal.ts b/src/domain/goals/Goal.ts index 0026726b..576d524a 100644 --- a/src/domain/goals/Goal.ts +++ b/src/domain/goals/Goal.ts @@ -1,5 +1,6 @@ import { BaseAggregate, AggregateState } from "../BaseAggregate.js"; import { UUID } from "../BaseEvent.js"; +import { GoalId } from "./GoalId.js"; import { ValidationRuleSet } from "../validation/ValidationRule.js"; import { GoalEvent, GoalAddedEvent, GoalRefinedEvent, GoalStartedEvent, GoalUpdatedEvent, GoalBlockedEvent, GoalUnblockedEvent, GoalCompletedEvent, GoalResetEvent, GoalRemovedEvent, GoalPausedEvent, GoalResumedEvent, GoalProgressUpdatedEvent, GoalSubmittedForReviewEvent, GoalQualifiedEvent, GoalRefinementStartedEvent, GoalCommittedEvent, GoalRejectedEvent, GoalSubmittedEvent, GoalCodifyingStartedEvent, GoalClosedEvent, GoalApprovedEvent, GoalStatusMigratedEvent } from "./EventIndex.js"; import { GoalEventType, GoalStatus, GoalStatusType, WAITING_STATES, IN_PROGRESS_STATES, TERMINAL_STATES, DETERMINISTIC_RESET_TARGETS } from "./Constants.js"; @@ -32,7 +33,7 @@ import { CanCloseRule } from "./rules/CanCloseRule.js"; // Domain state: business properties + aggregate metadata export interface GoalState extends AggregateState { - id: UUID; + id: GoalId; title: string; objective: string; successCriteria: string[]; @@ -283,7 +284,7 @@ export class Goal extends BaseAggregate { } } - static create(id: UUID): Goal { + static create(id: GoalId = GoalId.create()): Goal { const state: GoalState = { id, title: "", @@ -304,7 +305,7 @@ export class Goal extends BaseAggregate { */ static rehydrate(id: UUID, history: GoalEvent[]): Goal { const state: GoalState = { - id, + id: GoalId.fromLegacy(id), title: "", objective: "", successCriteria: [], diff --git a/src/domain/goals/GoalId.ts b/src/domain/goals/GoalId.ts new file mode 100644 index 00000000..5d1d1f28 --- /dev/null +++ b/src/domain/goals/GoalId.ts @@ -0,0 +1,4 @@ +import { defineDomainIdentity, DomainIdentity } from "../identity/DomainIdentity.js"; + +export type GoalId = DomainIdentity<"GoalId">; +export const GoalId = defineDomainIdentity("GoalId"); diff --git a/src/domain/guidelines/Guideline.ts b/src/domain/guidelines/Guideline.ts index 96826884..de61a021 100644 --- a/src/domain/guidelines/Guideline.ts +++ b/src/domain/guidelines/Guideline.ts @@ -10,6 +10,7 @@ import { AggregateState, } from "../BaseAggregate.js"; import { UUID } from "../BaseEvent.js"; +import { GuidelineId } from "./GuidelineId.js"; import { ValidationRuleSet } from "../validation/ValidationRule.js"; import { GuidelineEvent, @@ -32,7 +33,7 @@ import { EXAMPLES_RULES } from "./rules/ExamplesRules.js"; * Domain state: business properties + aggregate metadata */ export interface GuidelineState extends AggregateState { - id: UUID; + id: GuidelineId; category: GuidelineCategoryValue; title: string; description: string; @@ -85,7 +86,7 @@ export class Guideline extends BaseAggregate { * Creates a new Guideline aggregate. * Use this when starting a new aggregate that will emit its first event. */ - static create(id: UUID): Guideline { + static create(id: GuidelineId = GuidelineId.create()): Guideline { const state: GuidelineState = { id, category: "other" as GuidelineCategoryValue, @@ -105,7 +106,7 @@ export class Guideline extends BaseAggregate { */ static rehydrate(id: UUID, history: GuidelineEvent[]): Guideline { const state: GuidelineState = { - id, + id: GuidelineId.fromLegacy(id), category: "other" as GuidelineCategoryValue, title: "", description: "", diff --git a/src/domain/guidelines/GuidelineId.ts b/src/domain/guidelines/GuidelineId.ts new file mode 100644 index 00000000..5af41905 --- /dev/null +++ b/src/domain/guidelines/GuidelineId.ts @@ -0,0 +1,4 @@ +import { defineDomainIdentity, DomainIdentity } from "../identity/DomainIdentity.js"; + +export type GuidelineId = DomainIdentity<"GuidelineId">; +export const GuidelineId = defineDomainIdentity("GuidelineId"); diff --git a/src/domain/identity/DomainIdentity.ts b/src/domain/identity/DomainIdentity.ts new file mode 100644 index 00000000..2bf50207 --- /dev/null +++ b/src/domain/identity/DomainIdentity.ts @@ -0,0 +1,33 @@ +import { randomUUID } from "node:crypto"; + +declare const domainIdentityBrand: unique symbol; + +export type DomainIdentity = string & { + readonly [domainIdentityBrand]: Name; +}; + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export interface DomainIdentityType { + create(): DomainIdentity; + from(value: string): DomainIdentity; + fromLegacy(value: string): DomainIdentity; + is(value: string): value is DomainIdentity; +} + +export function defineDomainIdentity( + name: Name, +): DomainIdentityType { + return { + create: () => randomUUID() as DomainIdentity, + from: (value: string) => { + if (!UUID_PATTERN.test(value)) { + throw new Error(`${name} must be a valid UUID`); + } + return value as DomainIdentity; + }, + fromLegacy: (value: string) => value as DomainIdentity, + is: (value: string): value is DomainIdentity => UUID_PATTERN.test(value), + }; +} diff --git a/src/domain/invariants/Invariant.ts b/src/domain/invariants/Invariant.ts index 196ca37e..cf62d6fe 100644 --- a/src/domain/invariants/Invariant.ts +++ b/src/domain/invariants/Invariant.ts @@ -10,6 +10,7 @@ import { AggregateState, } from "../BaseAggregate.js"; import { UUID, ISO8601 } from "../BaseEvent.js"; +import { InvariantId } from "./InvariantId.js"; import { ValidationRuleSet } from "../validation/ValidationRule.js"; import { InvariantEvent, InvariantAddedEvent, InvariantUpdatedEvent, InvariantRemovedEvent } from "./EventIndex.js"; import { InvariantEventType, InvariantErrorMessages } from "./Constants.js"; @@ -21,7 +22,7 @@ import { RATIONALE_RULES } from "./rules/RationaleRules.js"; * Domain state: business properties + aggregate metadata */ export interface InvariantState extends AggregateState { - id: UUID; + id: InvariantId; title: string; description: string; rationale: string | null; @@ -66,7 +67,7 @@ export class Invariant extends BaseAggregate { * Creates a new Invariant aggregate. * Use this when starting a new aggregate that will emit its first event. */ - static create(id: UUID): Invariant { + static create(id: InvariantId = InvariantId.create()): Invariant { const state: InvariantState = { id, title: "", @@ -83,7 +84,7 @@ export class Invariant extends BaseAggregate { */ static rehydrate(id: UUID, history: InvariantEvent[]): Invariant { const state: InvariantState = { - id, + id: InvariantId.fromLegacy(id), title: "", description: "", rationale: null, diff --git a/src/domain/invariants/InvariantId.ts b/src/domain/invariants/InvariantId.ts new file mode 100644 index 00000000..9d14b9c0 --- /dev/null +++ b/src/domain/invariants/InvariantId.ts @@ -0,0 +1,4 @@ +import { defineDomainIdentity, DomainIdentity } from "../identity/DomainIdentity.js"; + +export type InvariantId = DomainIdentity<"InvariantId">; +export const InvariantId = defineDomainIdentity("InvariantId"); diff --git a/src/domain/project/Project.ts b/src/domain/project/Project.ts index bf352c47..467d2dca 100644 --- a/src/domain/project/Project.ts +++ b/src/domain/project/Project.ts @@ -10,6 +10,7 @@ import { AggregateState, } from "../BaseAggregate.js"; import { UUID } from "../BaseEvent.js"; +import { ProjectId } from "./ProjectId.js"; import { ValidationRuleSet } from "../validation/ValidationRule.js"; import { ProjectEvent, ProjectInitializedEvent, ProjectUpdatedEvent } from "./EventIndex.js"; import { ProjectEventType, ProjectErrorMessages } from "./Constants.js"; @@ -20,7 +21,7 @@ import { PURPOSE_RULES } from "./rules/PurposeRules.js"; * Domain state: business properties + aggregate metadata */ export interface ProjectState extends AggregateState { - id: UUID; // Aggregate identity + id: ProjectId; // Aggregate identity name: string; // Required: project name purpose: string | null; // Optional: high-level what version: number; // Aggregate version for event sourcing @@ -57,7 +58,7 @@ export class Project extends BaseAggregate { * Creates a new Project aggregate. * Use this when starting a new aggregate that will emit its first event. */ - static create(id: UUID): Project { + static create(id: ProjectId = ProjectId.create()): Project { const state: ProjectState = { id, name: "", @@ -73,7 +74,7 @@ export class Project extends BaseAggregate { */ static rehydrate(id: UUID, history: ProjectEvent[]): Project { const state: ProjectState = { - id, + id: ProjectId.fromLegacy(id), name: "", purpose: null, version: 0, diff --git a/src/domain/project/ProjectId.ts b/src/domain/project/ProjectId.ts new file mode 100644 index 00000000..f13863a6 --- /dev/null +++ b/src/domain/project/ProjectId.ts @@ -0,0 +1,4 @@ +import { defineDomainIdentity, DomainIdentity } from "../identity/DomainIdentity.js"; + +export type ProjectId = DomainIdentity<"ProjectId">; +export const ProjectId = defineDomainIdentity("ProjectId"); diff --git a/src/domain/relations/Relation.ts b/src/domain/relations/Relation.ts index 45808530..44dee50e 100644 --- a/src/domain/relations/Relation.ts +++ b/src/domain/relations/Relation.ts @@ -1,5 +1,6 @@ import { BaseAggregate, AggregateState } from "../BaseAggregate.js"; import { UUID } from "../BaseEvent.js"; +import { RelationId } from "./RelationId.js"; import { ValidationRuleSet } from "../validation/ValidationRule.js"; import { RelationAddedEvent } from "./add/RelationAddedEvent.js"; import { RelationDeactivatedEvent } from "./deactivate/RelationDeactivatedEvent.js"; @@ -18,7 +19,7 @@ export type RelationEvent = | RelationRemovedEvent; export interface RelationState extends AggregateState { - id: UUID; + id: RelationId; fromEntityType: EntityTypeValue; fromEntityId: string; toEntityType: EntityTypeValue; @@ -72,7 +73,7 @@ export class Relation extends BaseAggregate { } } - static create(id: UUID): Relation { + static create(id: RelationId = RelationId.create()): Relation { const state: RelationState = { id, fromEntityType: '' as EntityTypeValue, @@ -94,7 +95,7 @@ export class Relation extends BaseAggregate { */ static rehydrate(id: UUID, history: RelationEvent[]): Relation { const state: RelationState = { - id, + id: RelationId.fromLegacy(id), fromEntityType: '' as EntityTypeValue, fromEntityId: '', toEntityType: '' as EntityTypeValue, diff --git a/src/domain/relations/RelationId.ts b/src/domain/relations/RelationId.ts new file mode 100644 index 00000000..ed474b4b --- /dev/null +++ b/src/domain/relations/RelationId.ts @@ -0,0 +1,4 @@ +import { defineDomainIdentity, DomainIdentity } from "../identity/DomainIdentity.js"; + +export type RelationId = DomainIdentity<"RelationId">; +export const RelationId = defineDomainIdentity("RelationId"); diff --git a/src/domain/sessions/Session.ts b/src/domain/sessions/Session.ts index 4d8abf9c..76cbbc27 100644 --- a/src/domain/sessions/Session.ts +++ b/src/domain/sessions/Session.ts @@ -3,6 +3,7 @@ import { AggregateState, } from "../BaseAggregate.js"; import { UUID } from "../BaseEvent.js"; +import { SessionId } from "./SessionId.js"; import { ValidationRuleSet } from "../validation/ValidationRule.js"; import { SessionEvent, SessionStartedEvent, SessionEndedEvent } from "./EventIndex.js"; import { @@ -16,7 +17,7 @@ import { SUMMARY_RULES } from "./rules/SummaryRules.js"; // Domain state: business properties + aggregate metadata export interface SessionState extends AggregateState { - id: UUID; // Session ID + id: SessionId; // Session ID focus: string; // Theme of the work accomplished (set at session end) status: SessionStatusType; // Current session status version: number; // Aggregate version for event sourcing @@ -48,7 +49,7 @@ export class Session extends BaseAggregate { } } - static create(id: UUID): Session { + static create(id: SessionId = SessionId.create()): Session { const state: SessionState = { id, focus: "", @@ -64,7 +65,7 @@ export class Session extends BaseAggregate { */ static rehydrate(id: UUID, history: SessionEvent[]): Session { const state: SessionState = { - id, + id: SessionId.fromLegacy(id), focus: "", status: SessionStatus.ACTIVE, version: 0, diff --git a/src/domain/sessions/SessionId.ts b/src/domain/sessions/SessionId.ts new file mode 100644 index 00000000..38d80a28 --- /dev/null +++ b/src/domain/sessions/SessionId.ts @@ -0,0 +1,4 @@ +import { defineDomainIdentity, DomainIdentity } from "../identity/DomainIdentity.js"; + +export type SessionId = DomainIdentity<"SessionId">; +export const SessionId = defineDomainIdentity("SessionId"); diff --git a/src/domain/value-propositions/ValueProposition.ts b/src/domain/value-propositions/ValueProposition.ts index c5954300..f1c34f67 100644 --- a/src/domain/value-propositions/ValueProposition.ts +++ b/src/domain/value-propositions/ValueProposition.ts @@ -1,5 +1,6 @@ import { BaseAggregate } from "../BaseAggregate.js"; import { UUID } from "../BaseEvent.js"; +import { ValuePropositionId } from "./ValuePropositionId.js"; import { ValidationRuleSet } from "../validation/ValidationRule.js"; import { ValuePropositionAddedEvent } from "./add/ValuePropositionAddedEvent.js"; import { ValuePropositionUpdatedEvent } from "./update/ValuePropositionUpdatedEvent.js"; @@ -22,7 +23,9 @@ export class ValueProposition extends BaseAggregate< super(state); // Call BaseAggregate constructor } - static create(id: UUID): ValueProposition { + static create( + id: ValuePropositionId = ValuePropositionId.create(), + ): ValueProposition { const state: ValuePropositionState = { id, title: "", @@ -38,7 +41,10 @@ export class ValueProposition extends BaseAggregate< id: UUID, history: ValuePropositionEvent[] ): ValueProposition { - const state = ValuePropositionProjection.rehydrate(id, history); + const state = ValuePropositionProjection.rehydrate( + ValuePropositionId.fromLegacy(id), + history, + ); return new ValueProposition(state); } diff --git a/src/domain/value-propositions/ValuePropositionId.ts b/src/domain/value-propositions/ValuePropositionId.ts new file mode 100644 index 00000000..96b0cc76 --- /dev/null +++ b/src/domain/value-propositions/ValuePropositionId.ts @@ -0,0 +1,4 @@ +import { defineDomainIdentity, DomainIdentity } from "../identity/DomainIdentity.js"; + +export type ValuePropositionId = DomainIdentity<"ValuePropositionId">; +export const ValuePropositionId = defineDomainIdentity("ValuePropositionId"); diff --git a/src/domain/value-propositions/ValuePropositionProjection.ts b/src/domain/value-propositions/ValuePropositionProjection.ts index 9f7b85d9..4992ebe5 100644 --- a/src/domain/value-propositions/ValuePropositionProjection.ts +++ b/src/domain/value-propositions/ValuePropositionProjection.ts @@ -2,6 +2,7 @@ import { ValuePropositionAddedEvent } from "./add/ValuePropositionAddedEvent.js" import { ValuePropositionUpdatedEvent } from "./update/ValuePropositionUpdatedEvent.js"; import { ValuePropositionRemovedEvent } from "./remove/ValuePropositionRemovedEvent.js"; import { UUID } from "../BaseEvent.js"; +import { ValuePropositionId } from "./ValuePropositionId.js"; import { AggregateState } from "../BaseAggregate.js"; import { ValuePropositionEventType } from "./Constants.js"; @@ -13,7 +14,7 @@ export type ValuePropositionEvent = // Domain state: business properties + aggregate metadata export interface ValuePropositionState extends AggregateState { - id: UUID; // Aggregate identity + id: ValuePropositionId; // Aggregate identity title: string; // Required: short value description description: string; // Required: detailed explanation benefit: string; // Required: how this improves situation @@ -64,7 +65,7 @@ export class ValuePropositionProjection { * Used by Aggregate.rehydrate() to rebuild from event store. */ static rehydrate( - id: UUID, + id: ValuePropositionId, history: ValuePropositionEvent[] ): ValuePropositionState { const state: ValuePropositionState = { diff --git a/src/domain/workers/WorkerId.ts b/src/domain/workers/WorkerId.ts new file mode 100644 index 00000000..19b8c04e --- /dev/null +++ b/src/domain/workers/WorkerId.ts @@ -0,0 +1,4 @@ +import { defineDomainIdentity, DomainIdentity } from "../identity/DomainIdentity.js"; + +export type WorkerId = DomainIdentity<"WorkerId">; +export const WorkerId = defineDomainIdentity("WorkerId"); diff --git a/src/infrastructure/context/dependencies/add/SqliteDependencyAddedProjector.ts b/src/infrastructure/context/dependencies/add/SqliteDependencyAddedProjector.ts index 4aebd259..2bb32c80 100644 --- a/src/infrastructure/context/dependencies/add/SqliteDependencyAddedProjector.ts +++ b/src/infrastructure/context/dependencies/add/SqliteDependencyAddedProjector.ts @@ -56,6 +56,19 @@ export class SqliteDependencyAddedProjector implements IDependencyAddedProjector return row ? this.mapRowToView(row as Record) : null; } + async findByIdentity( + ecosystem: string, + packageName: string, + ): Promise { + const row = this.db.prepare(` + SELECT * FROM dependency_views + WHERE ecosystem = ? AND packageName = ? AND status != 'removed' + ORDER BY createdAt DESC + LIMIT 1 + `).get(ecosystem, packageName); + return row ? this.mapRowToView(row as Record) : null; + } + async findByConsumerId(consumerId: string): Promise { const rows = this.db .prepare('SELECT * FROM dependency_views WHERE consumerId = ? ORDER BY createdAt DESC') diff --git a/src/infrastructure/host/workers/FsWorkerIdentityRegistry.ts b/src/infrastructure/host/workers/FsWorkerIdentityRegistry.ts index ac09ea39..6108a23c 100644 --- a/src/infrastructure/host/workers/FsWorkerIdentityRegistry.ts +++ b/src/infrastructure/host/workers/FsWorkerIdentityRegistry.ts @@ -13,9 +13,9 @@ import fs from "fs-extra"; import path from "path"; -import { randomUUID } from "crypto"; import { IWorkerIdentityReader } from "../../../application/host/workers/IWorkerIdentityReader.js"; import { WorkerId, createWorkerId } from "../../../application/host/workers/WorkerId.js"; +import { WorkerId as DomainWorkerId } from "../../../domain/workers/WorkerId.js"; import { HostSessionKeyResolver } from "../session/HostSessionKeyResolver.js"; /** @@ -77,7 +77,7 @@ export class FsWorkerIdentityRegistry implements IWorkerIdentityReader { } // Create new worker entry - const newWorkerId = randomUUID(); + const newWorkerId = DomainWorkerId.create(); const now = new Date().toISOString(); registry.entries[hostSessionKey] = { diff --git a/src/infrastructure/host/workers/SqliteWorkerIdentityRegistry.ts b/src/infrastructure/host/workers/SqliteWorkerIdentityRegistry.ts index bc432b4a..b301a5b2 100644 --- a/src/infrastructure/host/workers/SqliteWorkerIdentityRegistry.ts +++ b/src/infrastructure/host/workers/SqliteWorkerIdentityRegistry.ts @@ -17,7 +17,6 @@ */ import { Database } from "better-sqlite3"; -import { randomUUID } from "crypto"; import { IWorkerIdentityReader } from "../../../application/host/workers/IWorkerIdentityReader.js"; import { IWorkerModeAccessor } from "../../../application/host/workers/IWorkerModeAccessor.js"; import { IWorkerIdentifiedEventWriter } from "../../../application/host/workers/identify/IWorkerIdentifiedEventWriter.js"; @@ -25,6 +24,7 @@ import { IEventBus } from "../../../application/messaging/IEventBus.js"; import { WorkerId } from "../../../application/host/workers/WorkerId.js"; import { WorkerMode } from "../../../application/host/workers/WorkerMode.js"; import { WorkerIdentifiedEvent, WorkerEventType } from "../../../domain/workers/identify/WorkerIdentifiedEvent.js"; +import { WorkerId as DomainWorkerId } from "../../../domain/workers/WorkerId.js"; import { HostSessionKeyResolver } from "../session/HostSessionKeyResolver.js"; import { WorkerRecord } from "./WorkerRecord.js"; import { WorkerRecordMapper } from "./WorkerRecordMapper.js"; @@ -151,7 +151,7 @@ export class SqliteWorkerIdentityRegistry implements IWorkerIdentityReader, IWor } // Create new worker entry via event - const newWorkerId = randomUUID(); + const newWorkerId = DomainWorkerId.create(); const now = new Date().toISOString(); const event: WorkerIdentifiedEvent = { diff --git a/tests/application/context/dependencies/add/AddDependencyCommandHandler.test.ts b/tests/application/context/dependencies/add/AddDependencyCommandHandler.test.ts index 39ff597f..2f5392a2 100644 --- a/tests/application/context/dependencies/add/AddDependencyCommandHandler.test.ts +++ b/tests/application/context/dependencies/add/AddDependencyCommandHandler.test.ts @@ -20,13 +20,14 @@ describe("AddDependencyCommandHandler", () => { } as unknown as jest.Mocked; dependencyReader = { findById: jest.fn(), + findByIdentity: jest.fn(), } as unknown as jest.Mocked; handler = new AddDependencyCommandHandler(eventWriter, eventBus, dependencyReader); }); it("creates external dependencies from name/ecosystem/packageName identity", async () => { - dependencyReader.findById.mockResolvedValue(null); + dependencyReader.findByIdentity.mockResolvedValue(null); const result = await handler.execute({ name: "Express", @@ -37,11 +38,29 @@ describe("AddDependencyCommandHandler", () => { contract: "IAuthApi", }); - expect(result).toEqual({ dependencyId: "dep_npm_express" }); + expect(result.dependencyId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(dependencyReader.findByIdentity).toHaveBeenCalledWith("npm", "express"); expect(eventWriter.append).toHaveBeenCalledTimes(1); expect(eventBus.publish).toHaveBeenCalledTimes(1); }); + it("returns an existing legacy identifier for an idempotent add", async () => { + dependencyReader.findByIdentity.mockResolvedValue({ + dependencyId: "dep_npm_express", + } as never); + + const result = await handler.execute({ + name: "Express", + ecosystem: "npm", + packageName: "express", + }); + + expect(result).toEqual({ dependencyId: "dep_npm_express" }); + expect(eventWriter.append).not.toHaveBeenCalled(); + }); + it("throws when external identity flags are missing", async () => { await expect( handler.execute({ diff --git a/tests/application/context/project/init/InitializeProjectCommandHandler.test.ts b/tests/application/context/project/init/InitializeProjectCommandHandler.test.ts index 681b1353..09ae364a 100644 --- a/tests/application/context/project/init/InitializeProjectCommandHandler.test.ts +++ b/tests/application/context/project/init/InitializeProjectCommandHandler.test.ts @@ -26,7 +26,6 @@ describe("InitializeProjectCommandHandler", () => { let settingsInitializer: jest.Mocked; let gitignoreProtocol: jest.Mocked; let projectIdentityResolver: jest.Mocked; - const generatedProjectId = "11111111-1111-4111-8111-111111111111"; beforeEach(() => { eventWriter = { @@ -65,7 +64,9 @@ describe("InitializeProjectCommandHandler", () => { }; projectIdentityResolver = { - generateProjectId: jest.fn().mockReturnValue(generatedProjectId), + generateProjectId: jest.fn().mockReturnValue( + "11111111-1111-4111-8111-111111111111", + ), persistProjectId: jest.fn().mockResolvedValue(undefined), resolveExistingProjectId: jest.fn(), }; @@ -122,20 +123,22 @@ describe("InitializeProjectCommandHandler", () => { const result = await handler.execute(command, "/repo", ["claude", "antigravity"]); - expect(result.projectId).toBe(generatedProjectId); - expect(projectIdentityResolver.generateProjectId).toHaveBeenCalledTimes(1); + expect(result.projectId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(projectIdentityResolver.generateProjectId).not.toHaveBeenCalled(); expect(eventWriter.append).toHaveBeenCalledTimes(1); const appendedEvent = eventWriter.append.mock.calls[0][0] as ProjectInitializedEvent; expect(appendedEvent.type).toBe(ProjectEventType.INITIALIZED); - expect(appendedEvent.aggregateId).toBe(generatedProjectId); + expect(appendedEvent.aggregateId).toBe(result.projectId); expect(appendedEvent.version).toBe(1); expect(appendedEvent.payload.name).toBe(command.name); expect(appendedEvent.payload.purpose).toBe(command.purpose); expect(eventBus.publish).toHaveBeenCalledWith(appendedEvent); expect(settingsInitializer.ensureSettingsFileExists).toHaveBeenCalledTimes(1); - expect(projectIdentityResolver.persistProjectId).toHaveBeenCalledWith(generatedProjectId); + expect(projectIdentityResolver.persistProjectId).toHaveBeenCalledWith(result.projectId); expect(agentFileProtocol.ensureAgentsMd).toHaveBeenCalledWith("/repo"); expect(agentFileProtocol.ensureAgentConfigurations).toHaveBeenCalledWith("/repo", ["claude", "antigravity"]); expect(gitignoreProtocol.ensureExclusions).toHaveBeenCalledWith("/repo"); diff --git a/tests/application/context/relations/add/AddRelationCommandHandler.test.ts b/tests/application/context/relations/add/AddRelationCommandHandler.test.ts index 069b824d..49da80aa 100644 --- a/tests/application/context/relations/add/AddRelationCommandHandler.test.ts +++ b/tests/application/context/relations/add/AddRelationCommandHandler.test.ts @@ -4,13 +4,6 @@ import { jest, describe, it, expect, beforeEach } from "@jest/globals"; -// Mock IdGenerator -jest.unstable_mockModule("../../../../../src/application/identity/IdGenerator", () => ({ - IdGenerator: { - generate: jest.fn(() => "test-uuid-123"), - }, -})); - const { AddRelationCommandHandler } = await import("../../../../../src/application/context/relations/add/AddRelationCommandHandler"); const { AddRelationCommand } = await import("../../../../../src/application/context/relations/add/AddRelationCommand"); import type { IRelationAddedEventWriter } from "../../../../../src/application/context/relations/add/IRelationAddedEventWriter"; @@ -61,7 +54,9 @@ describe("AddRelationCommandHandler", () => { const result = await handler.execute(command); // Assert - expect(result.relationId).toBe("test-uuid-123"); + expect(result.relationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); // Verify relation uniqueness check expect(reader.findByEntities).toHaveBeenCalledWith( @@ -75,6 +70,7 @@ describe("AddRelationCommandHandler", () => { // Verify event was appended to event store expect(eventWriter.append).toHaveBeenCalledTimes(1); const appendedEvent = (eventWriter.append as jest.Mock).mock.calls[0][0]; + expect(appendedEvent.aggregateId).toBe(result.relationId); expect(appendedEvent.type).toBe(RelationEventType.ADDED); expect(appendedEvent.payload.fromEntityType).toBe(EntityType.GOAL); expect(appendedEvent.payload.fromEntityId).toBe("goal-1"); diff --git a/tests/domain/identity/DomainIdentity.test.ts b/tests/domain/identity/DomainIdentity.test.ts new file mode 100644 index 00000000..aefb6ce3 --- /dev/null +++ b/tests/domain/identity/DomainIdentity.test.ts @@ -0,0 +1,27 @@ +import { AudienceId } from "../../../src/domain/audiences/AudienceId.js"; +import { GoalId } from "../../../src/domain/goals/GoalId.js"; +import { ProjectId } from "../../../src/domain/project/ProjectId.js"; + +describe("domain primitive identities", () => { + it("creates canonical UUID identities", () => { + const id = GoalId.create(); + + expect(GoalId.is(id)).toBe(true); + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + }); + + it("rejects non-UUID values at strict boundaries", () => { + expect(() => ProjectId.from("project")).toThrow( + "ProjectId must be a valid UUID", + ); + }); + + it("preserves legacy identifiers as runtime strings", () => { + const id = AudienceId.fromLegacy("audience-legacy"); + + expect(id).toBe("audience-legacy"); + expect(JSON.stringify({ id })).toBe('{"id":"audience-legacy"}'); + }); +});