diff --git a/docs/subsystem/design/subsystem-write-lld.md b/docs/subsystem/design/subsystem-write-lld.md new file mode 100644 index 000000000..9511e9d20 --- /dev/null +++ b/docs/subsystem/design/subsystem-write-lld.md @@ -0,0 +1,914 @@ + + +# Subsystem Write API — LLD + +**Date:** 2026-08-13 +**Status:** Requirements aligned - pending review +**Requirements:** [`../requirements/subsystem-requirements.md`](../requirements/subsystem-requirements.md) +**Framework reference:** [`docs/edit-crud/overall-design.md`](../../edit-crud/overall-design.md) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Aggregate Design](#2-aggregate-design) +3. [Write Flow](#3-write-flow) +4. [SubsystemRepository](#4-subsystemrepository) +5. [Commands and Handlers](#5-commands-and-handlers) + - 5.1 [FR-SS-01 — Create Subsystem](#51-fr-ss-01--create-subsystem) + - 5.2 [FR-SS-02 — Delete Subsystem](#52-fr-ss-02--delete-subsystem) + - 5.3 [FR-SS-03/05/06 — Patch Subsystem](#53-fr-ss-030506--patch-subsystem) + - 5.4 [FR-SS-04 — Set Filtered Keys](#54-fr-ss-04--set-filtered-keys) + - 5.5 [FR-SS-07 — Move Components](#55-fr-ss-07--move-components) +6. [Controller and DTOs](#6-controller-and-dtos) +7. [CommandHandlerRegistry](#7-commandhandlerregistry) +8. [Error Handling](#8-error-handling) +9. [Folder Structure](#9-folder-structure) +10. [Open Items](#10-open-items) + +--- + +## 1. Overview + +This LLD specifies the write path for all subsystem management operations defined in the +requirements document. All operations follow the Hexagonal + CQRS + DDD pattern: the controller +translates HTTP into a command, the `CommandBus` starts a `UnitOfWork`, the handler executes +domain logic, and a new `SubsystemEditRepository` stages changes as `edit_actions` rows. + +**APIs in scope:** + +| FR | HTTP | Endpoint | +|----|------|----------| +| FR-SS-01 | `POST` | `/arc-api/v1/projects/{projectId}/subsystems` | +| FR-SS-02 | `DELETE` | `/arc-api/v1/projects/{projectId}/subsystems/{subsystemSystemId}` | +| FR-SS-03, FR-SS-05, FR-SS-06 | `PATCH` | `/arc-api/v1/projects/{projectId}/subsystems/{subsystemSystemId}` | +| FR-SS-04 | `PUT` | `/arc-api/v1/projects/{projectId}/subsystems/{subsystemSystemId}/filtered-keys` | +| FR-SS-07 | `POST` | `/arc-api/v1/projects/{projectId}/subsystems/components/move` | + +FR-SS-03, FR-SS-05, and FR-SS-06 share the same `PATCH` endpoint and are handled by a single +`PatchSubsystemCommand`. + +--- + +## 2. Aggregate Design + +**Subsystem is its own aggregate root.** The `aggregateId` on every `edit_actions` row produced +by these handlers is the subsystem's `systemId`. + +The domain entity `Subsystem` extends `Node`, which carries `systemId`, `fileSystemId`, +`parentId`, `dataPorts`, and `controlPorts`. Child entities written through the subsystem edit +repo (ports, filtered keys, parent relationships) carry the same `aggregateId = subsystemSystemId`. + +This LLD brings Subsystem into edit scope by introducing `SubsystemEditRepository` — the +dedicated write interface for this aggregate. No other handler may produce `edit_actions` rows +attributed to the Subsystem aggregate. + +--- + +## 3. Write Flow + +The diagram below shows the general request lifecycle shared by all five write operations. +Operation-specific steps (validation reads, domain checks, ID generation) are handled inside +the handler box. + +```mermaid +sequenceDiagram + participant Client + participant Controller as SubsystemController + participant Guard as SessionGuard + participant Bus as CommandBus + participant Handler as XxxHandler + participant Reader as Session-aware read service + participant Repo as SubsystemEditRepository + participant Writer as PendingChangeWriter + participant DB as SQLite (edit_actions) + + Client->>+Controller: HTTP Request + Controller->>+Guard: resolve session (projectId) + Guard-->>-Controller: ActiveSession + Controller->>+Bus: execute(Command, session) + Bus->>Bus: check allowedModes + Bus->>Bus: stamp WriteContext (groupId, session) + Bus->>+Handler: handle(command) + Handler->>Handler: validate inputs + Handler->>Handler: start UnitOfWork transaction + Handler->>+Reader: load current state and validate ownership + Note right of Reader: includes current session changes + Reader-->>-Handler: current subsystem/component state + Handler->>Handler: domain rule checks + Handler->>+Repo: write method (createSubsystem / renameSubsystem / ...) + Repo->>+Writer: writeCreate / writeDelta / writeDelete + Writer->>+DB: INSERT INTO edit_actions (aggregateId, groupId, ...) + DB-->>-Writer: ok + Writer-->>-Repo: ok + Repo-->>-Handler: ok + Handler->>Handler: commit UnitOfWork transaction + Handler-->>-Bus: { groupId } + Bus-->>-Controller: { groupId } + alt follow-up read required (PATCH / DELETE) + Controller->>Controller: queryBus.execute(SubsystemQuery) + Controller->>Controller: map to SubsystemDto + end + Controller-->>-Client: ApiResult +``` + +--- + +## 4. SubsystemRepository + +### 4.1 Interface (core) + +**File:** `packages/core/src/application/ports/persistence/repositories/subsystem/subsystem.repository.ts` + +Extend the existing `SubsystemRepository` interface — do **not** create a separate file. + +```typescript +import type {Subsystem} from '../../../../domain/entities/usecase-data/subsystem/subsystem.js'; +import type {DataPort} from '../../../../domain/entities/usecase-data/node/entities/data-port.js'; +import type {ControlPort} from '../../../../domain/entities/usecase-data/node/entities/control-port.js'; +import type {EditOptions} from '../../edit-options.js'; + +export interface SubsystemEditRepository { + // ── Write methods ───────────────────────────────────────────────────────── + createSubsystem(subsystem: Subsystem, options?: EditOptions): Promise; + deleteSubsystem(systemId: number, options?: EditOptions): Promise; + renameSubsystem(systemId: number, name: string, options?: EditOptions): Promise; + setFilteredKeys(systemId: number, keySystemIds: number[], options?: EditOptions): Promise; + addDataPort(port: DataPort, subsystemSystemId: number, options?: EditOptions): Promise; + removeDataPort(portSystemId: number, subsystemSystemId: number, options?: EditOptions): Promise; + addControlPort(port: ControlPort, subsystemSystemId: number, options?: EditOptions): Promise; + removeControlPort(portSystemId: number, subsystemSystemId: number, options?: EditOptions): Promise; + updateParentId( + subsystemSystemId: number, + parentSubsystemSystemId: number | null, + options?: EditOptions, + ): Promise; +} +``` + +The existing `ModuleRepository` gains the corresponding `updateParentId(moduleSystemId, +parentSubsystemSystemId, options?)` write method. A component move is an application operation, +not a `SubsystemRepository` bulk write: the core handler resolves the affected modules and calls +the repository for each one, while it calls `SubsystemRepository.updateParentId` for each selected +subsystem. + +Command-handler method coverage: + +| Command handler | Repository methods | +|-----------------|--------------------| +| `CreateSubsystemHandler` | `createSubsystem` | +| `DeleteSubsystemHandler` | `deleteSubsystem` | +| `PatchSubsystemHandler` | `renameSubsystem`, `addDataPort`, `removeDataPort`, `addControlPort`, `removeControlPort` | +| `SetSubsystemFilteredKeysHandler` | `setFilteredKeys` | +| `MoveSubsystemComponentsHandler` | `ModuleRepository.updateParentId`, `SubsystemRepository.updateParentId`, link repositories | + +### 4.2 Adapter (persistence) + +**File:** `packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subsystem/subsystem.repository.ts` + +Extend the existing TypeORM subsystem repository with the write methods — do **not** create a separate adapter file. +`updateParentId` writes the `nodes.parent_id` delta for a subsystem node. The TypeORM module +repository implements the matching module-node delta method. + +`aggregateId` is always the subsystem's `systemId` — including for child `DataPort` and +`ControlPort` rows. + +Handlers obtain current subsystem, component, ownership, and name-uniqueness state through +existing session-aware read services. Those validation reads include the current session's +pending `edit_actions` but are intentionally not part of `SubsystemEditRepository`. + +### 4.3 TypeOrmUnitOfWork wiring + +No new wiring required. The existing `uow.getSubsystemRepository()` already returns the +`TypeOrmSubsystemRepository`. Since write methods are added to the same class, all handlers +call `uow.getSubsystemRepository()` directly — no new accessor needed. + +--- + +## 5. Commands and Handlers + +### 5.1 FR-SS-01 — Create Subsystem + +**Files:** +``` +packages/core/src/application/usecase-designer/subsystem/create/ + create-subsystem.command.ts + create-subsystem.handler.ts +``` + +#### Command + +```typescript +export class CreateSubsystemCommand extends BaseCommand { + static override readonly requiresSession = true; + static override readonly allowedModes: readonly SessionMode[] = [ + SESSION_MODE.Designer, + SESSION_MODE.DiffMerge, + ]; + + constructor( + clientId: string, + public readonly fileSystemId: number, + public readonly name: string | undefined, + public readonly parentId: number | undefined, + ) { + super(clientId); + } +} +``` + +#### Handler logic + +``` +1. If name provided and name.length > 255 → throw InvalidOperationException +2. await uow.startTransaction() +3. const subsystemSystemId = await idGeneration.getNextId(fileSystemId) +4. const subsystemId = naturalIdGeneration.getNextId(fileSystemId, NaturalIdType.SUBSYSTEM) +5. const resolvedName = name ?? `SS_0x${subsystemId.toString(16).padStart(8, '0').toUpperCase()` +6. If name provided: + validate global case-insensitive uniqueness through the session-aware read service + → throw DomainRuleViolationException if taken (I1) +7. If parentId provided: + validate that the parent subsystem exists in this project through the session-aware read service + → throw ResourceNotFoundException if absent +8. Construct Subsystem entity: empty dataPorts, controlPorts, filteredKeySystemIds = [] +9. await repo.createSubsystem(subsystem) +10. await uow.commit() +11. return { groupId, subsystemSystemId, subsystemId, name: resolvedName, parentId } +``` + +The name validation includes pending creates in the current session, preventing a duplicate +name from being staged before commit. + +#### Controller response + +No follow-up read needed. The handler returns the lean response directly: + +```typescript +return toApiResult(Result.ok(result), r => + new CreateSubsystemResponseDto(r.subsystemSystemId, r.subsystemId, r.name, r.parentId), +); +``` + +--- + +### 5.2 FR-SS-02 — Delete Subsystem + +**Files:** +``` +packages/core/src/application/usecase-designer/subsystem/delete/ + delete-subsystem.command.ts + delete-subsystem.handler.ts +``` + +#### Command + +```typescript +export class DeleteSubsystemCommand extends BaseCommand { + static override readonly requiresSession = true; + static override readonly allowedModes: readonly SessionMode[] = [ + SESSION_MODE.Designer, + SESSION_MODE.DiffMerge, + ]; + + constructor( + clientId: string, + public readonly subsystemSystemId: number, + public readonly fileSystemId: number, + ) { + super(clientId); + } +} +``` + +#### Handler logic + +``` +1. await uow.startTransaction() +2. Load the current subsystem state through the session-aware read service +3. If absent → throw ResourceNotFoundException +4. If (subsystem.children.subsystemSystemIds?.length ?? 0) + (subsystem.children.subgraphSystemIds?.length ?? 0) > 0: + throw DomainRuleViolationException([ + IssueFactory.subsystemNotEmpty(subsystemSystemId) + ]) + // message: "Subsystem is not empty — remove all children before deleting." +5. await repo.deleteSubsystem(subsystemSystemId) +6. await uow.commit() +7. return { groupId, deletedSubsystemSnapshot } +``` + +The handler returns the pre-deletion snapshot directly. This satisfies the delete response +contract without querying a subsystem after it has been removed. + +--- + +### 5.3 FR-SS-03/05/06 — Patch Subsystem + +FR-SS-03 (rename), FR-SS-05 (data port count), and FR-SS-06 (control port count) all map to +`PATCH /projects/{projectId}/subsystems/{subsystemSystemId}`. They are consolidated into one +`PatchSubsystemCommand`. + +**Files:** +``` +packages/core/src/application/usecase-designer/subsystem/patch/ + patch-subsystem.command.ts + patch-subsystem.handler.ts +``` + +#### Command + +```typescript +export class PatchSubsystemCommand extends BaseCommand { + static override readonly requiresSession = true; + static override readonly allowedModes: readonly SessionMode[] = [ + SESSION_MODE.Designer, + SESSION_MODE.DiffMerge, + ]; + + constructor( + clientId: string, + public readonly subsystemSystemId: number, + public readonly fileSystemId: number, + public readonly name: string | undefined, + public readonly inputDataPortCount: number | undefined, + public readonly outputDataPortCount: number | undefined, + public readonly controlPortCount: number | undefined, + ) { + super(clientId); + } +} +``` + +#### Handler logic + +``` +1. If name, inputDataPortCount, outputDataPortCount, controlPortCount are all undefined: + throw InvalidOperationException('At least one field must be provided.') +2. await uow.startTransaction() +3. Load the current subsystem state through the session-aware read service +4. If absent → throw ResourceNotFoundException + +Rename (if name is defined): +5a. If name.trim() === '': do not stage a rename; retain subsystem.name +5b. Otherwise, if name.length > 255 → throw InvalidOperationException +5c. Otherwise validate global case-insensitive uniqueness through the session-aware read service + If taken → throw DomainRuleViolationException (duplicate name, I1) +5d. Otherwise await repo.renameSubsystem(subsystemSystemId, name) + +Data port count (inputDataPortCount and/or outputDataPortCount defined): +6. allocatedDataPortIds = Set of all subsystem.dataPorts[*].dataPortId + applyDataPortCountChange(PORT_IO_TYPE.Input, inputDataPortCount, allocatedDataPortIds) + applyDataPortCountChange(PORT_IO_TYPE.Output, outputDataPortCount, allocatedDataPortIds) + (each direction is independent — partial success is allowed per FR-SS-05) + +Control port count (controlPortCount defined): +7. applyControlPortCountChange(controlPortCount) + +8. await uow.commit() +9. return { groupId } +``` + +**Partial-success transaction rule:** Subsystem existence and rename validation are request-level +checks; failure rolls back the whole request. For each requested input, output, or control port +count, the handler first evaluates occupancy independently. It records an occupied-port failure +as an issue and stages no writes for that direction, while staging every direction that passes. +If no requested operation succeeds, the handler rolls back and throws the first port violation. +Otherwise it commits the staged changes and returns `Result.partial({groupId}, issues)` when any +direction failed, or `Result.ok({groupId})` when all requested changes succeeded. +`PartialSuccessInterceptor` converts a result containing an ERROR or FATAL issue to HTTP 207. + +#### Port count change algorithm + +**`applyDataPortCountChange(direction, requested, allocatedDataPortIds)`:** + +``` +current = subsystem.dataPorts filtered by direction +if requested === undefined: return (no-op) +if requested === current.length: return (no-op) + +if requested > current.length: + portIds = nextDataPortIds( + allocatedDataPortIds, + direction === PORT_IO_TYPE.Input, + MODULE_PORT_STRATEGIES.SEQUENTIAL, + requested - current.length, + ) + for portId in portIds: + portSystemId = await idGeneration.getNextId(fileSystemId) + await repo.addDataPort( + new DataPort({ systemId: portSystemId, dataPortId: portId, portIoType: direction, isStatic: false, name: '' }), + subsystemSystemId, + ) + allocatedDataPortIds.add(portId) + +if requested < current.length: + links = await dataLinkRepo.getLinksByPortSystemIds(current.map(p => p.systemId), fileSystemId) + outcome = resolvePortCountChange( + current, + requested, + Number.MAX_SAFE_INTEGER, + links, + ISSUE_ENTITY_TYPE.DataPort, + subsystemSystemId, + ) + if outcome is fail: return its issues // caller records a per-direction issue + for each portSystemId in outcome.data.toRemove: + await repo.removeDataPort(portSystemId, subsystemSystemId) +``` + +The handler reuses `nextDataPortIds` and `resolvePortCountChange` from the module PATCH flow +instead of duplicating port allocation and occupied-port detection. Passing the sequential module +strategy with one shared allocation set across both directions preserves the subsystem's shared, +gap-filling ID space even when one PATCH increases both counts. `resolvePortCountChange` also +supplies the link-aware failure issues and the port IDs to remove. + +**`applyControlPortCountChange(requested)`** likewise reuses `resolvePortCountChange` with +`controlPorts` and `controlLinkRepo.getLinksByPortSystemIds`; it uses `nextControlPortIds` for +new control-port IDs. + +The controller performs a follow-up read after a committed complete or partial result and +returns the updated `SubsystemDto` together with any issues. + +--- + +### 5.4 FR-SS-04 — Set Filtered Keys + +**Files:** +``` +packages/core/src/application/usecase-designer/subsystem/set-filtered-keys/ + set-subsystem-filtered-keys.command.ts + set-subsystem-filtered-keys.handler.ts +``` + +#### Command + +```typescript +export class SetSubsystemFilteredKeysCommand extends BaseCommand { + static override readonly requiresSession = true; + static override readonly allowedModes: readonly SessionMode[] = [ + SESSION_MODE.Designer, + SESSION_MODE.DiffMerge, + ]; + + constructor( + clientId: string, + public readonly subsystemSystemId: number, + public readonly fileSystemId: number, + public readonly keySystemIds: number[], + ) { + super(clientId); + } +} +``` + +#### Handler logic + +``` +1. await uow.startTransaction() +2. Load the current subsystem state through the session-aware read service +3. If absent → throw ResourceNotFoundException +4. For each id in command.keySystemIds: + verify key-definition exists via PropertyDefinitionsRepository or KeyDefinitionRepository + If not found → throw ResourceNotFoundException(`KeyDefinition ${id} not found`) +5. await repo.setFilteredKeys(subsystemSystemId, keySystemIds) +6. await uow.commit() +7. return { groupId } +``` + +The controller returns a lean `FilteredKeyDto[]` directly — no follow-up subsystem read required. +The handler returns the resolved key list (each entry: `keySystemId`, `keyId`, `keyLabel`) +fetched during step 4's validation, avoiding a separate query after commit. + +--- + +### 5.5 FR-SS-07 — Move Components + +**Files:** +``` +packages/core/src/application/usecase-designer/subsystem/move/ + move-subsystem-components.command.ts + move-subsystem-components.handler.ts +``` + +#### Command + +```typescript +export class MoveSubsystemComponentsCommand extends BaseCommand { + static override readonly requiresSession = true; + static override readonly allowedModes: readonly SessionMode[] = [ + SESSION_MODE.Designer, + SESSION_MODE.DiffMerge, + ]; + + constructor( + clientId: string, + public readonly fileSystemId: number, + public readonly subgraphSystemIds: number[], + public readonly subsystemSystemIds: number[], + public readonly targetSubsystemSystemId: number | null, + ) { + super(clientId); + } +} +``` + +#### Handler logic + +``` +1. If subgraphSystemIds.length === 0 && subsystemSystemIds.length === 0 + → throw InvalidOperationException('At least one component system ID must be provided.') +2. await uow.startTransaction() +3. Validate every supplied subgraph and subsystem exists in this project. + A missing component returns 404; an out-of-project component returns 422. No move is staged. +4. If targetSubsystemSystemId !== null: + Validate target subsystem exists in this project → ResourceNotFoundException if absent + +For each id in subsystemSystemIds: +5a. id === targetSubsystemSystemId → DomainRuleViolationException (circular, I2) +5b. id is a descendant of target → DomainRuleViolationException (circular, I2) +5c. id already a direct child of target location → DomainRuleViolationException (duplicate child, I2) + +For each id in subgraphSystemIds: +6a. id already a direct child of target location → DomainRuleViolationException (duplicate child, I2) + +After all validations pass: +7. Resolve each supplied subgraph to its member modules through the session-aware topology reader. + For each module, call `moduleRepo.updateParentId(moduleSystemId, targetSubsystemSystemId)`. +8. For each selected subsystem, recursively load its descendant topology for link reconstruction, + then call `subsystemRepo.updateParentId(subsystemSystemId, targetSubsystemSystemId)`. + Descendants retain their direct parent IDs, so moving a subsystem preserves its internal tree. + +The handler owns this traversal and the calls to both repositories in `packages/core`; adapters +only stage their respective `nodes.parent_id` deltas. Every re-parenting write uses the current +write context and the same transaction/group ID. + +Link reconstruction (after all re-parenting): +9. Identify every affected data and control subsystem-link segment in both the committed state + and the active edit-session overlay. +10. For resolved segments (a non-null data/control-link ID), follow FR-VL-20a: directly stage + deletion of the old segments, retain the physical DataLink or ControlLink, and create the + complete replacement chain for the new hierarchy. +11. For unresolved overlay-only segments (a null data/control-link ID), stage deletion when the + move affects either endpoint or boundary. They have no physical link to retain and cannot be + safely re-parented as a partial chain. +12. Any subsystems whose port wiring changed are tracked in subsystemPortChanges. + +13. await uow.commit() +14. return { groupId, updatedModules, updatedSubsystems, + addedDataLinks, removedDataLinks, + addedControlLinks, removedControlLinks, + subsystemPortChanges } +``` + +The controller maps the handler result directly to `MoveSubsystemComponentsResponseDto` — no +separate follow-up read needed since the handler builds all collections during execution. + +--- + +## 6. Controller and DTOs + +### 6.1 DTO correction — `CreateSubsystemRequestDto` + +**File:** `packages/api/src/presentation/rest/modules/subsystem/dto/request/create-subsystem-request.dto.ts` + +Current `name` is marked `@IsNotEmpty()` (required). Per FR-SS-01 it is optional. + +```typescript +export class CreateSubsystemRequestDto { + @ApiProperty({ + required: false, + description: 'Subsystem name — max 255 chars. Omit to auto-generate SS_0x{id:X8}.', + maxLength: 255, + }) + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @ApiProperty({ + required: false, + description: 'System ID of parent subsystem. Omit for root level.', + }) + @IsOptional() + @IsInt() + @IsPositive() + parentId?: number; +} +``` + +### 6.2 New response DTO — `CreateSubsystemResponseDto` + +**File:** `packages/api/src/presentation/rest/modules/subsystem/dto/response/create-subsystem-response.dto.ts` + +```typescript +export class CreateSubsystemResponseDto { + @ApiProperty({ description: 'System-generated unique identifier' }) + systemId!: number; + + @ApiProperty({ description: 'Sequential natural subsystem ID' }) + naturalId!: number; + + @ApiProperty({ description: 'Assigned or auto-generated name' }) + name!: string; + + @ApiProperty({ required: false, description: 'Parent subsystem system ID, if nested' }) + parentId?: number; +} +``` + +### 6.3 `PatchSubsystemRequestDto` — add port count fields + +**File:** `packages/api/src/presentation/rest/modules/subsystem/dto/request/patch-subsystem-request.dto.ts` + +```typescript +export class PatchSubsystemRequestDto { + @ApiProperty({ required: false, maxLength: 255 }) + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @ApiProperty({ required: false, minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + inputDataPortCount?: number; + + @ApiProperty({ required: false, minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + outputDataPortCount?: number; + + @ApiProperty({ required: false, minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + controlPortCount?: number; +} +``` + +### 6.4 `SetSubsystemFilteredKeysRequestDto` + +**File:** `packages/api/src/presentation/rest/modules/subsystem/dto/request/set-subsystem-filtered-keys-request.dto.ts` + +`keySystemIds` is required and must be an array. An empty array is valid and clears all filtered +keys; `null` or an omitted property is rejected as a 400 request error. + +```typescript +export class SetSubsystemFilteredKeysRequestDto { + @ApiProperty({ type: [String] }) + @IsArray() + @IsString({ each: true }) + keySystemIds!: string[]; +} +``` + +The handler resolves the provided keys and returns the required `FilteredKeyDto[]` list +(`keySystemId`, `keyId`, `keyLabel`) directly; it does not perform a follow-up subsystem read. + +### 6.5 `MoveSubsystemComponentsRequestDto` + +**File:** `packages/api/src/presentation/rest/modules/subsystem/dto/request/move-subsystem-components-request.dto.ts` + +Flat structure — no nested `components` wrapper. `targetSubsystemSystemId: null` moves +components to root (subsumes the old "move-out" case). + +```typescript +export class MoveSubsystemComponentsRequestDto { + @ApiProperty({ type: [String], required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + subgraphSystemIds?: string[]; + + @ApiProperty({ type: [String], required: false }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + subsystemSystemIds?: string[]; + + @ApiProperty({ type: 'string', nullable: true }) + @IsOptional() + @IsString() + targetSubsystemSystemId!: string | null; +} +``` + +### 6.6 Controller updates + +The controller currently has an empty constructor with no bus injection. All five write methods +must be wired up. Pattern per method: + +```typescript +@Post() +@UseGuards(SessionGuard) +@ApiOperation({ summary: 'Create an empty subsystem' }) +@ApiBody({ type: CreateSubsystemRequestDto }) +@ApiResponse({ status: 200, type: CreateSubsystemResponseDto }) +@ApiResponse({ status: 400, description: 'Invalid input' }) +@ApiResponse({ status: 403, description: 'No active session' }) +@ApiResponse({ status: 404, description: 'Parent subsystem not found' }) +@ApiResponse({ status: 422, description: 'Name already in use' }) +async createSubsystem( + @Param('projectId', ParseIntPipe) projectId: number, + @Body() dto: CreateSubsystemRequestDto, + @ArcSession() session: ActiveSession, +): Promise> { + const result = await this.commandBus.execute( + new CreateSubsystemCommand('api-client', session.fileSystemId, dto.name, dto.parentId), + session, + ); + return toApiResult(Result.ok(result), r => + new CreateSubsystemResponseDto(r.subsystemSystemId, r.subsystemId, r.name, r.parentId), + ); +} +``` + +Constructor must be updated to inject both buses: + +```typescript +constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, +) { + super(); +} +``` + +Move components uses the existing REST endpoint in +`packages/api/src/presentation/rest/modules/subsystem/subsystem.controller.ts`: + +```typescript +@Post('components/move') +async moveComponents( + @Param('projectId') projectId: string, + @Body() request: MoveSubsystemComponentsRequestDto, + @ArcSession() session: ActiveSession, +): Promise> { + const hasSubgraphs = (request.subgraphSystemIds?.length ?? 0) > 0; + const hasSubsystems = (request.subsystemSystemIds?.length ?? 0) > 0; + if (!hasSubgraphs && !hasSubsystems) { + throw new BadRequestException( + 'At least one of subgraphSystemIds or subsystemSystemIds must be provided', + ); + } + + const result = await this.commandBus.execute( + new MoveSubsystemComponentsCommand( + 'api-client', + session.fileSystemId, + parseSystemIds(request.subgraphSystemIds ?? []), + parseSystemIds(request.subsystemSystemIds ?? []), + parseOptionalSystemId(request.targetSubsystemSystemId), + ), + session, + ); + + return toApiResult(Result.ok(result), r => r); +} +``` + +`parseSystemIds` and `parseOptionalSystemId` reject malformed, non-integer, negative, or +out-of-range unsigned IDs with `BadRequestException`. They are the only HTTP-to-command +conversion point: request and response DTOs retain string IDs, while commands and repositories +use numeric IDs. + +The HTTP client provides component IDs and the target subsystem/root only. The core handler +resolves subgraphs to their module nodes, calls `ModuleRepository.updateParentId` for those +modules, and calls `SubsystemRepository.updateParentId` for selected subsystem nodes inside the +current session transaction. + +`MoveSubsystemComponentsRequestDto` does not expose persistence-layer aggregate details. + +--- + +## 7. CommandHandlerRegistry + +**File:** `packages/core/src/application/orchestration/cqrs/registries/command-handler-registry.ts` + +```typescript +this.commandHandlerFactories.set(CreateSubsystemCommand, { + create: deps => + new CreateSubsystemHandler(deps.uow, deps.idGeneration, deps.naturalIdGeneration), +}); +this.commandHandlerFactories.set(DeleteSubsystemCommand, { + create: deps => new DeleteSubsystemHandler(deps.uow), +}); +this.commandHandlerFactories.set(PatchSubsystemCommand, { + create: deps => new PatchSubsystemHandler(deps.uow, deps.idGeneration), +}); +this.commandHandlerFactories.set(SetSubsystemFilteredKeysCommand, { + create: deps => new SetSubsystemFilteredKeysHandler(deps.uow), +}); +this.commandHandlerFactories.set(MoveSubsystemComponentsCommand, { + create: deps => new MoveSubsystemComponentsHandler(deps.uow), +}); +``` + +All five command classes must also be exported from `packages/core/src/index.ts`. + +--- + +## 8. Error Handling + +| Condition | Exception | HTTP | +|-----------|-----------|------| +| Subsystem not found | `ResourceNotFoundException` | 404 | +| Parent subsystem not found | `ResourceNotFoundException` | 404 | +| KeyDefinition not found | `ResourceNotFoundException` | 404 | +| Component not found | `ResourceNotFoundException` | 404 | +| No fields provided (PATCH) | `InvalidOperationException` | 400 | +| Malformed or out-of-range system ID | `InvalidOperationException` | 400 | +| Name exceeds 255 characters | `InvalidOperationException` | 400 | +| Both `subgraphSystemIds` and `subsystemSystemIds` are empty | `InvalidOperationException` | 400 | +| Component does not belong to this project | `DomainRuleViolationException` | 422 | +| Duplicate subsystem name (I1) | `DomainRuleViolationException` | 422 | +| Delete with children present | `DomainRuleViolationException` | 422 | +| Occupied port cannot be removed (I3) | `DomainRuleViolationException` | 422 | +| Circular subsystem hierarchy (I2) | `DomainRuleViolationException` | 422 | +| Component already a child (I2) | `DomainRuleViolationException` | 422 | + +New `IssueFactory` entries required: +- `subsystemNotEmpty(subsystemSystemId)` — message: "Subsystem is not empty — remove all children before deleting." +- `duplicateSubsystemName(name)` — message: name conflict +- `occupiedSubsystemPortCannotBeRemoved(portSystemId)` — I3 +- `circularSubsystemHierarchy(componentSystemId, targetSystemId)` — I2 +- `duplicateChildComponent(componentSystemId, subsystemSystemId)` — I2 + +--- + +## 9. Folder Structure + +### New files + +``` +packages/core/src/application/ + usecase-designer/subsystem/ + create/ + create-subsystem.command.ts + create-subsystem.handler.ts + delete/ + delete-subsystem.command.ts + delete-subsystem.handler.ts + patch/ + patch-subsystem.command.ts + patch-subsystem.handler.ts + set-filtered-keys/ + set-subsystem-filtered-keys.command.ts + set-subsystem-filtered-keys.handler.ts + move/ + move-subsystem-components.command.ts + move-subsystem-components.handler.ts + +packages/api/src/presentation/rest/modules/subsystem/ + dto/response/ + create-subsystem-response.dto.ts ← new + dto/request/ + move-subsystem-components-request.dto.ts ← flat request (subgraphSystemIds, subsystemSystemIds, targetSubsystemSystemId) +``` + +### Modified files + +``` +packages/core/src/application/ports/persistence/repositories/subsystem/ + subsystem.repository.ts ← add write contracts, including updateParentId + +packages/core/src/application/ports/persistence/repositories/module/ + module.repository.ts ← add updateParentId for moved subgraph modules + +packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subsystem/ + subsystem.repository.ts ← add write method implementations, including updateParentId + +packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/module/ + module.repository.ts ← add updateParentId implementation + +packages/core/src/application/orchestration/cqrs/registries/ + command-handler-registry.ts ← register 5 new handlers + +packages/core/src/index.ts ← export 5 new command classes + +packages/api/src/presentation/rest/modules/subsystem/ + subsystem.controller.ts ← inject buses, implement 5 write methods + dto/request/ + create-subsystem-request.dto.ts ← fix name to @IsOptional() + patch-subsystem-request.dto.ts ← add inputDataPortCount, outputDataPortCount, + controlPortCount fields + dto/response/ + update-subsystem-filtered-keys-response.dto.ts + ← return the required FilteredKeyDto[] response +``` + +--- + +## 10. Open Items + +| ID | Item | Blocks | +|----|------|--------| +| OI-1 | **Data port ID space** — requirements specify "minimum available ID" without clarifying whether input and output ports share one ID space. This LLD assumes a **shared** space (one pool per subsystem, across both directions). Confirm before implementing. | FR-SS-05 | +| OI-2 | **New `IssueFactory` entries** — `subsystemNotEmpty`, `duplicateSubsystemName`, `occupiedSubsystemPortCannotBeRemoved`, `circularSubsystemHierarchy`, `duplicateChildComponent` must be added to `packages/core/src/shared/issues/factories.ts`. | All DomainRuleViolationException sites | +| OI-3 | **Follow-up read shape for PATCH** — verify the existing subsystem query service returns `SubsystemDto` with ports and filtered keys populated after both complete and partial updates. Delete returns its pre-deletion snapshot directly. | FR-SS-03, FR-SS-05, FR-SS-06 | diff --git a/docs/subsystem/requirements/subsystem-requirements.md b/docs/subsystem/requirements/subsystem-requirements.md new file mode 100644 index 000000000..d1ddf1bee --- /dev/null +++ b/docs/subsystem/requirements/subsystem-requirements.md @@ -0,0 +1,345 @@ +# Subsystem: Requirements + +**Date:** 2026-08-05 +**Status:** Updated — restructured and API inputs/outputs added +**Source:** Extracted from `SubSystemManager.cs` and `SubsystemRepository.cs` (CRFIXING8_3_2024 branch); +API contracts from `packages/api/src/presentation/rest/modules/subsystem/subsystem.controller.ts` + +--- + +## 1. Context + +### 1.1 Problem statement + +Subsystems are named, hierarchical groupings that let designers organize audio graph components +(subgraphs, nested subsystems) into logical layers. They expose data ports (input/output) +and control ports that cross their boundaries, and they carry filtered-graph-key sets that govern +which calibration key-values are visible inside them. + +This document captures the behavioral requirements for the subsystem write/modify API in the +AudioReach Creator Backend. + +### 1.2 What this builds on + +- Domain entity: `packages/core/src/domain/entities/usecase-data/subsystem/subsystem.ts` +- Persistence schema + bulk inserter: + `packages/infrastructure/persistence/src/persistence-typeorm-sqllite/entity-schema/usecase-data/subsystem/` +- Controller stubs (all `NotImplementedException`): + `packages/api/src/presentation/rest/modules/subsystem/subsystem.controller.ts` +- Existing read-path spec: `docs/superpowers/specs/subsystem-query-lld.md` +- Subsystem-link requirements (cross-boundary link resolution): + `docs/subsystem-links/2026-05-30-subsystem-links-requirements.md` + +### 1.3 Key decisions already made + +- The backend uses Hexagonal + CQRS + DDD; all write operations go through `CommandBus`. +- IDs are `uint` (natural key); the `IdGenerationPort` generates sequential IDs per type + (`UNIQUE_ID_TYPE.SUBSYSTEM`). +- Default name on creation is `SS_0x{id:X8}` (e.g. `SS_0x00000001`). +- Port ID assignment uses the **minimum available ID** — when a new port is created, it receives + the smallest unused port ID, filling gaps left by previously removed ports before allocating + a new higher ID. + +--- + +## 2. Definitions + +| Term | Definition | +|------|------------| +| **Subsystem** | A named hierarchical grouping of graph components with its own ports and filtered-key set | +| **Subgraph** | A processing node in the audio graph that contains SPF modules; the primary building block placed inside subsystems | +| **Data port** | An input or output data port on a subsystem boundary (carries audio stream connections) | +| **Control port** | A control port on a subsystem boundary (carries key-value parameter control links) | +| **Child** | A component (subgraph or subsystem) owned by a parent subsystem | +| **Filtered keys** | A set of key-definition system IDs that filter which key-values are visible inside the subsystem | +| **Occupied port** | A port that has at least one active connection (data or control link) | +| **System ID** | A globally unique unsigned integer identifier assigned to each entity by the backend at creation time | + +--- + +## 3. Functional Requirements + +### 3.1 Create a Subsystem + +#### FR-SS-01: Create empty subsystem + +A new subsystem must be created with a system-generated unique ID. +The default name is `SS_0x{id:X8}` (uppercase hex, zero-padded to 8 digits). +The created subsystem has no children, no ports, and no filtered keys. +The operation must succeed even if no name is provided explicitly. + +**Endpoint:** `POST /arc-api/v1/projects/{projectId}/subsystems` + +**Inputs:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | no | Subsystem name — max 255 characters. If omitted, the system generates `SS_0x{id:X8}` | +| `parentId` | number | no | System ID of an existing subsystem to nest under; omit for root-level | + +**Output:** The created subsystem — a lean record with the following fields only: + +| Field | Type | Description | +|-------|------|-------------| +| `systemId` | number | System-generated unique identifier assigned at creation | +| `naturalId` | number | Natural (sequential) subsystem ID (`subsystemId`) | +| `name` | string | The assigned or auto-generated name (`SS_0x{id:X8}`) | +| `parentId` | number \| undefined | System ID of the parent subsystem, if created nested | + +**Validations:** +- If `name` is provided, it must be globally unique across all subsystems in the project (case-insensitive) — I1. +- If `name` is provided, it must not exceed 255 characters. +- If `parentId` is provided, the referenced subsystem must exist in this project. + +--- + +### 3.2 Delete Subsystem + +#### FR-SS-02: Delete subsystem + +A subsystem can only be deleted when it has **no child components or nested subsystems**. +If the subsystem still has children, the operation must fail with an error. +Use the move-out operation (FR-SS-08) to relocate children before deleting. + +**Endpoint:** `DELETE /arc-api/v1/projects/{projectId}/subsystems/{subsystemSystemId}` + +**Inputs:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `subsystemSystemId` | uint | yes | System ID of the subsystem to delete | + +**Output:** Snapshot of the deleted subsystem as it existed before removal. + +**Validations:** +- Subsystem must exist. +- Subsystem must have no child components or nested subsystems — if children are present, the operation must fail with the error: `"Subsystem is not empty — remove all children before deleting."` + +--- + +### 3.3 Update Subsystem Properties + +#### FR-SS-03: Rename subsystem + +A subsystem's name can be updated by its system ID. +An empty or whitespace-only name leaves the existing name unchanged; it does not reset the name +to an auto-generated default. +The new name must be **globally unique** (case-insensitive) across all subsystems within the project. +If the name is already in use by a different subsystem, the operation must fail with an error +indicating the duplicate name. + +**Endpoint:** `PATCH /arc-api/v1/projects/{projectId}/subsystems/{subsystemSystemId}` + +**Inputs:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `subsystemSystemId` | uint | yes | System ID of the subsystem to rename | +| `name` | string | yes | New name — max 255 characters | + +**Output:** The updated subsystem. + +**Validations:** +- Subsystem must exist. +- `name` must be globally unique across the project (case-insensitive) — I1. +- `name` must not exceed 255 characters. + +--- + +#### FR-SS-04: Set filtered graph keys + +The filtered-key set of a subsystem can be replaced in full by providing a new list of +key-definition system IDs. +This is a **full replacement**, not an additive update — the existing set is discarded and +replaced with exactly the provided list. +An empty list is a valid input and clears all filtered keys. + +**Endpoint:** `PUT /arc-api/v1/projects/{projectId}/subsystems/{subsystemSystemId}/filtered-keys` + +**Inputs:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `subsystemSystemId` | uint | yes | System ID of the subsystem | +| `keySystemIds` | string[] | yes | New set of key-definition system IDs; empty array clears all keys | + +**Output:** The updated filtered keys list — an array of entries, each containing: + +| Field | Type | Description | +|-------|------|-------------| +| `keySystemId` | number | System ID of the key definition | +| `keyId` | number | Natural key ID | +| `keyLabel` | string | Human-readable key label | + +**Validations:** +- Subsystem must exist. +- Every entry in `keySystemIds` must reference a key-definition that exists in this project. +- `keySystemIds` may be empty (clears all filtered keys); it must not be `null` or omitted. + +--- + +#### FR-SS-05: Set data port count + +The number of input or output data ports on a subsystem can be adjusted. + +**Increasing count:** New ports are created until the target count is reached. +Each new port is assigned the **minimum available port ID** — that is, the smallest ID not +currently in use. This fills gaps left by previously removed ports before allocating a new +higher ID. +Each new port starts with an empty name. + +**Decreasing count:** Only **unoccupied** ports (those with no active connections) may be removed. +Ports are candidates for removal in descending port-ID order (highest ID first). +If there are not enough unoccupied ports to reach the target count, the operation must fail +with an error indicating that occupied ports cannot be removed. + +**Endpoint:** `PATCH /arc-api/v1/projects/{projectId}/subsystems/{subsystemSystemId}` + +**Example:** + +Initial state — 5 ports, occupied ports: 1, 3, 5 — unoccupied ports: 2, 4 + +*Decrement by 1 (target = 4):* +- Candidates for removal (unoccupied, highest ID first): 4, 2 +- Removes port **4** (highest unoccupied) +- Result: ports 1, 2, 3, 5 + +*Increment by 1 (target = 5, starting from above result):* +- Minimum available ID (gap): **4** +- Adds port **4** back +- Result: ports 1, 2, 3, 4, 5 + +**Inputs:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `subsystemSystemId` | uint | yes | System ID of the subsystem | +| `inputDataPortCount` | number | no† | Target number of input data ports | +| `outputDataPortCount` | number | no† | Target number of output data ports | + +†At least one of `inputDataPortCount`, `outputDataPortCount`, or +`controlPortCount` must be provided. + +**Output:** The updated subsystem with the adjusted data ports list. + +**Validations:** +- Subsystem must exist. +- Reducing count: unoccupied ports in that direction must be ≥ the reduction amount. + Occupied ports cannot be removed — I3. +- Supports partial success: if one direction fails, the other may still be updated. + +--- + +#### FR-SS-06: Set control port count + +The number of control ports on a subsystem can be adjusted. + +**Increasing count:** New control ports are created until the target count is reached. +Each new port is assigned the **minimum available port ID** — the smallest ID not currently +in use. This fills gaps left by previously removed ports before allocating a new higher ID. +Each new port starts with an empty name. + +**Decreasing count:** Only **unoccupied** control ports (those with no active control links) may +be removed. Ports are candidates for removal in descending port-ID order (highest ID first). +If there are not enough unoccupied control ports to reach the target count, the operation must fail. + +**Endpoint:** `PATCH /arc-api/v1/projects/{projectId}/subsystems/{subsystemSystemId}` + +**Example:** + +Initial state — 5 control ports, occupied ports: 1, 3, 5 — unoccupied ports: 2, 4 + +*Decrement by 1 (target = 4):* +- Candidates for removal (unoccupied, highest ID first): 4, 2 +- Removes port **4** (highest unoccupied) +- Result: ports 1, 2, 3, 5 + +*Increment by 1 (target = 5, starting from above result):* +- Minimum available ID (gap): **4** +- Adds port **4** back +- Result: ports 1, 2, 3, 4, 5 + +**Inputs:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `subsystemSystemId` | uint | yes | System ID of the subsystem | +| `controlPortCount` | number | no† | Target number of control ports | + +†At least one of `inputDataPortCount`, `outputDataPortCount`, or +`controlPortCount` must be provided. + +**Output:** The updated subsystem with the adjusted control ports list. + +**Validations:** +- Subsystem must exist. +- Reducing count: unoccupied control ports must be ≥ the reduction amount — I3. + +--- + +### 3.4 Child Component Management + +#### FR-SS-07: Move components + +One or more existing components (subgraphs or subsystems) can be moved to any target location +within the project — either into a specific subsystem or to the root graph — in a single +operation. + +Set `targetSubsystemSystemId` to a subsystem's system ID to move components into it, +or `null` to move them to the root graph. + +Rules by component type: +- **Subgraph:** Re-parented without additional checks. Subgraphs are leaf nodes and cannot + create a circular hierarchy. +- **Subsystem:** Must not be the target subsystem itself or any of its descendants. Moving a + subsystem into itself or a descendant creates a circular hierarchy and must fail. + +If any component is already a direct child of the target location, the operation must fail +with a duplicate-child error. + +Beyond re-parenting, the operation also removes cross-boundary links that become invalid and +constructs new links as needed. + +**Endpoint:** `POST /arc-api/v1/projects/{projectId}/subsystems/components/move` + +**Inputs:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `subgraphSystemIds` | string[] | no† | System IDs of subgraphs to move | +| `subsystemSystemIds` | string[] | no† | System IDs of subsystems to move | +| `targetSubsystemSystemId` | string \| null | yes | System ID of the target subsystem. `null` moves components to root | + +†At least one of `subgraphSystemIds` or `subsystemSystemIds` must be non-empty. + +**Output:** A result describing what changed: + +| Field | Description | +|-------|-------------| +| `updatedModules` | Modules re-parented by the move (with new `parentSystemId`) | +| `updatedSubsystems` | Subsystems re-parented by the move (with new `parentSystemId`) | +| `addedDataLinks` | Data links constructed after the move | +| `removedDataLinks` | System IDs of data links removed after the move | +| `addedControlLinks` | Control links constructed after the move | +| `removedControlLinks` | System IDs of control links removed after the move | +| `subsystemPortChanges` | Port additions/removals on subsystems whose wiring changed | + +**Validations:** +- At least one of `subgraphSystemIds` or `subsystemSystemIds` must be non-empty. +- If `targetSubsystemSystemId` is provided (non-null), the target subsystem must exist. +- Moving a subsystem into itself or a descendant creates a circular hierarchy — must fail. +- All components must belong to this project. + +--- + + +## 4. Invariants + +**I1 — Global name uniqueness:** No two subsystems within the same project may share a name +(comparison is case-insensitive). + +**I2 — Unique child membership:** A given component ID may appear at most once in a subsystem's +child list. + +**I3 — Occupied port protection:** Ports with at least one active connection (data or control) +cannot be removed individually or via count reduction. diff --git a/docs/swagger-api.json b/docs/swagger-api.json index f631fe4fa..9bc1ba664 100644 --- a/docs/swagger-api.json +++ b/docs/swagger-api.json @@ -7584,7 +7584,7 @@ }, "delete": { "description": "Deletes the specified subsystem. The subsystem must have no child components or nested subsystems.\n\nReturns the removed subsystem.", - "operationId": "SubsystemController_removeSubsystem", + "operationId": "SubsystemController_deleteSubsystem", "parameters": [ { "name": "projectId", @@ -11675,7 +11675,9 @@ "type": "string", "enum": [ "Input", - "Output" + "Output", + "InputOutput", + "OutputInput" ], "description": "Port IO type" }, @@ -11914,7 +11916,9 @@ "type": "string", "enum": [ "Input", - "Output" + "Output", + "InputOutput", + "OutputInput" ], "description": "Port IO type" }, @@ -12305,7 +12309,9 @@ "type": "string", "enum": [ "Input", - "Output" + "Output", + "InputOutput", + "OutputInput" ], "description": "Port IO type" }, @@ -12512,7 +12518,9 @@ "type": "string", "enum": [ "Input", - "Output" + "Output", + "InputOutput", + "OutputInput" ], "description": "Port IO type" }, @@ -13333,9 +13341,6 @@ "EC", "LINKED", "ISLAND" - "EC", - "LINKED", - "ISLAND" ] }, "categories": { @@ -14677,7 +14682,9 @@ "type": "string", "enum": [ "Input", - "Output" + "Output", + "InputOutput", + "OutputInput" ], "description": "Port IO type" }, @@ -17306,7 +17313,9 @@ "type": "string", "enum": [ "Input", - "Output" + "Output", + "InputOutput", + "OutputInput" ], "description": "Port IO type" }, @@ -17789,4 +17798,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/api/src/presentation/rest/modules/subsystem/dto/request/create-subsystem-request.dto.ts b/packages/api/src/presentation/rest/modules/subsystem/dto/request/create-subsystem-request.dto.ts index a9ab7439b..2efbade25 100644 --- a/packages/api/src/presentation/rest/modules/subsystem/dto/request/create-subsystem-request.dto.ts +++ b/packages/api/src/presentation/rest/modules/subsystem/dto/request/create-subsystem-request.dto.ts @@ -4,7 +4,7 @@ */ import {ApiProperty} from '@nestjs/swagger'; -import {IsOptional, IsString} from 'class-validator'; +import {IsOptional, IsString, MaxLength} from 'class-validator'; /** * Request DTO for creating an empty subsystem. @@ -18,6 +18,7 @@ export class CreateSubsystemRequestDto { }) @IsOptional() @IsString() + @MaxLength(255) name?: string; @ApiProperty({ diff --git a/packages/api/src/presentation/rest/modules/subsystem/dto/request/move-subsystem-components-request.dto.ts b/packages/api/src/presentation/rest/modules/subsystem/dto/request/move-subsystem-components-request.dto.ts index 14b301e97..42bbef405 100644 --- a/packages/api/src/presentation/rest/modules/subsystem/dto/request/move-subsystem-components-request.dto.ts +++ b/packages/api/src/presentation/rest/modules/subsystem/dto/request/move-subsystem-components-request.dto.ts @@ -4,7 +4,7 @@ */ import {ApiProperty} from '@nestjs/swagger'; -import {IsArray, IsOptional, IsString} from 'class-validator'; +import {IsArray, IsOptional, IsString, ValidateIf} from 'class-validator'; /** * Request DTO for moving subgraphs or subsystems to a target subsystem. @@ -34,10 +34,11 @@ export class MoveSubsystemComponentsRequestDto { @ApiProperty({ type: 'string', nullable: true, + required: true, description: 'System ID of the target subsystem. null moves components to root.', }) - @IsOptional() + @ValidateIf((_, value) => value !== null) @IsString() targetSubsystemSystemId!: string | null; } diff --git a/packages/api/src/presentation/rest/modules/subsystem/dto/request/patch-subsystem-request.dto.ts b/packages/api/src/presentation/rest/modules/subsystem/dto/request/patch-subsystem-request.dto.ts index 206a80e87..f50f00d07 100644 --- a/packages/api/src/presentation/rest/modules/subsystem/dto/request/patch-subsystem-request.dto.ts +++ b/packages/api/src/presentation/rest/modules/subsystem/dto/request/patch-subsystem-request.dto.ts @@ -4,6 +4,7 @@ */ import {ApiProperty} from '@nestjs/swagger'; +import {IsInt, IsOptional, IsString, MaxLength, Min} from 'class-validator'; /** * Request DTO for partially updating subsystem properties. @@ -15,6 +16,9 @@ export class PatchSubsystemRequestDto { required: false, maxLength: 255, }) + @IsOptional() + @IsString() + @MaxLength(255) name?: string; @ApiProperty({ @@ -22,6 +26,9 @@ export class PatchSubsystemRequestDto { 'Target number of input data ports. The API will add or remove input DataPort entities to reach this count.', required: false, }) + @IsOptional() + @IsInt() + @Min(0) inputDataPortCount?: number; @ApiProperty({ @@ -29,6 +36,9 @@ export class PatchSubsystemRequestDto { 'Target number of output data ports. The API will add or remove output DataPort entities to reach this count.', required: false, }) + @IsOptional() + @IsInt() + @Min(0) outputDataPortCount?: number; @ApiProperty({ @@ -36,5 +46,8 @@ export class PatchSubsystemRequestDto { 'Target number of control ports. The API will add or remove ControlPort entities to reach this count.', required: false, }) + @IsOptional() + @IsInt() + @Min(0) controlPortCount?: number; } diff --git a/packages/api/src/presentation/rest/modules/subsystem/subsystem.controller.ts b/packages/api/src/presentation/rest/modules/subsystem/subsystem.controller.ts index 22f5ff0f7..ac3644b62 100644 --- a/packages/api/src/presentation/rest/modules/subsystem/subsystem.controller.ts +++ b/packages/api/src/presentation/rest/modules/subsystem/subsystem.controller.ts @@ -35,6 +35,20 @@ import {DeleteSubsystemResponseDto} from './dto/response/delete-subsystem-respon import {UpdateSubsystemResponseDto} from './dto/response/update-subsystem-response.dto.js'; import {UpdateSubsystemFilteredKeysResponseDto} from './dto/response/update-subsystem-filtered-keys-response.dto.js'; import {SubsystemResponseDto} from './dto/response/subsystem-response.dto.js'; +import {toApiResult} from '../../common/result/to-api-result.js'; +import {SessionGuard} from '../../../../guards/session-guard.js'; +import {ArcSession} from '../../../../guards/arc-session.decorator.js'; +import type {ActiveSession, MoveSubsystemComponentsResult} from '@arc/core'; +import { + CommandBus, + CreateSubsystemCommand, + DeleteSubsystemCommand, + MoveSubsystemComponentsCommand, + PatchSubsystemCommand, + Result, + SetSubsystemFilteredKeysCommand, + LINK_TYPE, +} from '@arc/core'; /** * Controller to support all Subsystem related APIs for usecase design. @@ -51,7 +65,7 @@ import {SubsystemResponseDto} from './dto/response/subsystem-response.dto.js'; example: '12345', }) export class SubsystemController extends BaseController { - constructor() { + constructor(private readonly commandBus: CommandBus) { super(); } @@ -166,6 +180,7 @@ export class SubsystemController extends BaseController { * Create an empty subsystem. */ @Post() + @UseGuards(SessionGuard) @ApiDocumentationWithExample({ summary: 'Create an empty subsystem', description: @@ -195,14 +210,31 @@ export class SubsystemController extends BaseController { ], }) async createSubsystem( - @Param('projectId') projectId: string, + @Param('projectId') _projectId: string, @Body() request: CreateSubsystemRequestDto, + @ArcSession() session: ActiveSession, ): Promise> { - await Promise.resolve(); // Placeholder to satisfy linter - console.log( - `Creating subsystem in project ${projectId}: ${JSON.stringify(request)}`, + const result = await this.commandBus.execute<{ + subsystemSystemId: number; + naturalId: number; + name: string; + parentId?: number; + }>( + new CreateSubsystemCommand( + session.fileSystemId, + request.name, + parseOptionalSystemId(request.parentSystemId) ?? undefined, + ), + session, ); - throw new NotImplementedException('createSubsystem is not implemented yet'); + return toApiResult(Result.ok(result), value => ({ + systemId: String(value.subsystemSystemId), + naturalId: value.naturalId, + name: value.name, + ...(value.parentId !== undefined + ? {parentSystemId: String(value.parentId)} + : {}), + })); } //#endregion @@ -215,6 +247,7 @@ export class SubsystemController extends BaseController { * invalid, and constructs new links per the updated structure. */ @Post('components/move') + @UseGuards(SessionGuard) @ApiDocumentationWithExample({ summary: 'Move subgraphs or subsystems to a target subsystem', description: @@ -256,8 +289,9 @@ export class SubsystemController extends BaseController { ], }) async moveComponents( - @Param('projectId') projectId: string, + @Param('projectId') _projectId: string, @Body() request: MoveSubsystemComponentsRequestDto, + @ArcSession() session: ActiveSession, ): Promise> { const hasSubgraphs = (request.subgraphSystemIds?.length ?? 0) > 0; const hasSubsystems = (request.subsystemSystemIds?.length ?? 0) > 0; @@ -266,11 +300,55 @@ export class SubsystemController extends BaseController { 'At least one of subgraphSystemIds or subsystemSystemIds must be provided', ); } - await Promise.resolve(); // Placeholder to satisfy linter - console.log( - `Moving components in project ${projectId}: ${JSON.stringify(request)}`, + const result = await this.commandBus.execute( + new MoveSubsystemComponentsCommand( + session.fileSystemId, + parseSystemIds(request.subgraphSystemIds), + parseSystemIds(request.subsystemSystemIds), + parseOptionalSystemId(request.targetSubsystemSystemId) ?? null, + ), + session, ); - throw new NotImplementedException('moveComponents is not implemented yet'); + const commandResult = result.issues?.length + ? Result.partial(result, result.issues) + : Result.ok(result); + return toApiResult(commandResult, value => ({ + updatedModules: value.updatedModules.map(component => + mapMovedComponent(component), + ), + updatedSubsystems: value.updatedSubsystems.map(component => + mapMovedComponent(component), + ), + addedDataLinks: value.addedDataLinks.map(link => ({ + systemId: String(link.systemId), + sourceSystemId: String(link.sourceNodeSystemId), + sourcePortSystemId: String(link.sourcePortSystemId), + destinationSystemId: String(link.destinationNodeSystemId), + destinationPortSystemId: String(link.destinationPortSystemId), + isInterUsecase: link.linkType === LINK_TYPE.InterUsecase, + })), + removedDataLinks: value.removedDataLinks.map(String), + addedControlLinks: value.addedControlLinks.map(link => ({ + systemId: String(link.systemId), + sourceSystemId: String(link.peerNodeASystemId), + sourcePortSystemId: String(link.nodeAPortSystemId), + destinationSystemId: String(link.peerNodeBSystemId), + destinationPortSystemId: String(link.nodeBPortSystemId), + isInterUsecase: link.linkType === LINK_TYPE.InterUsecase, + })), + removedControlLinks: value.removedControlLinks.map(String), + subsystemPortChanges: value.subsystemPortChanges.map(change => ({ + systemId: String(change.systemId), + addedDataPorts: change.addedDataPorts.map(port => + mapMovedDataPort(port), + ), + removedDataPorts: change.removedDataPorts.map(String), + addedControlPorts: change.addedControlPorts.map(port => + mapMovedControlPort(port), + ), + removedControlPorts: change.removedControlPorts.map(String), + })), + })); } //#endregion @@ -286,6 +364,7 @@ export class SubsystemController extends BaseController { * The provided list replaces the current set entirely. An empty array clears all filtered keys. */ @Put(':subsystemSystemId/filtered-keys') + @UseGuards(SessionGuard) @ApiParam({ name: 'subsystemSystemId', required: true, @@ -321,17 +400,30 @@ export class SubsystemController extends BaseController { ], }) async setSubsystemFilteredKeys( - @Param('projectId') projectId: string, + @Param('projectId') _projectId: string, @Param('subsystemSystemId') subsystemSystemId: string, @Body() request: SetSubsystemFilteredKeysRequestDto, + @ArcSession() session: ActiveSession, ): Promise> { - await Promise.resolve(); // Placeholder to satisfy linter - console.log( - `Setting filtered keys for subsystem ${subsystemSystemId} in project ${projectId}: ${JSON.stringify(request)}`, - ); - throw new NotImplementedException( - 'setSubsystemFilteredKeys is not implemented yet', + const result = await this.commandBus.execute<{ + subsystemSystemId: number; + filteredKeys: Array<{systemId: number; keyId: number; name: string}>; + }>( + new SetSubsystemFilteredKeysCommand( + parseSystemId(subsystemSystemId), + session.fileSystemId, + parseSystemIds(request.keySystemIds), + ), + session, ); + return toApiResult(Result.ok(result), value => ({ + systemId: String(value.subsystemSystemId), + filteredKeys: value.filteredKeys.map(key => ({ + systemId: String(key.systemId), + naturalId: key.keyId, + name: key.name, + })), + })); } //#endregion @@ -347,6 +439,7 @@ export class SubsystemController extends BaseController { * Port count changes add or remove DataPort / ControlPort entities to reach the target count. */ @Patch(':subsystemSystemId') + @UseGuards(SessionGuard) @ApiParam({ name: 'subsystemSystemId', required: true, @@ -396,20 +489,60 @@ export class SubsystemController extends BaseController { ], }) async patchSubsystem( - @Param('projectId') projectId: string, + @Param('projectId') _projectId: string, @Param('subsystemSystemId') subsystemSystemId: string, @Body() request: PatchSubsystemRequestDto, + @ArcSession() session: ActiveSession, ): Promise> { if (!Object.values(request).some(v => v !== undefined)) { throw new BadRequestException( 'At least one field must be provided to patch', ); } - await Promise.resolve(); // Placeholder to satisfy linter - console.log( - `Patching subsystem ${subsystemSystemId} in project ${projectId}: ${JSON.stringify(request)}`, + const result = await this.commandBus.execute<{ + subsystem: { + systemId: number; + naturalId?: number; + name: string; + parentId?: number; + filteredKeys: Array<{systemId: number; keyId: number; name: string}>; + dataPorts?: Array<{ + systemId: number; + portId: number; + name: string | null; + portIoType: string; + isStatic: boolean; + totalLinksAtPort: number; + }>; + controlPorts?: Array<{ + systemId: number; + portId: number; + name: string | null; + isStatic: boolean; + allocatedIntents: Array<{ + systemId: number; + intentId: number; + name?: string; + }>; + totalLinksAtPort: number; + }>; + }; + issues?: readonly never[]; + }>( + new PatchSubsystemCommand( + parseSystemId(subsystemSystemId), + session.fileSystemId, + request.name, + request.inputDataPortCount, + request.outputDataPortCount, + request.controlPortCount, + ), + session, ); - throw new NotImplementedException('patchSubsystem is not implemented yet'); + const commandResult = result.issues?.length + ? Result.partial(result, result.issues) + : Result.ok(result); + return toApiResult(commandResult, value => mapSubsystem(value.subsystem)); } //#endregion @@ -418,12 +551,13 @@ export class SubsystemController extends BaseController { //#region DELETE - //#region Remove subsystem + //#region Delete subsystem /** * Remove a subsystem. Only succeeds when the subsystem has no children. */ @Delete(':subsystemSystemId') + @UseGuards(SessionGuard) @ApiParam({ name: 'subsystemSystemId', required: true, @@ -452,18 +586,181 @@ export class SubsystemController extends BaseController { }, ], }) - async removeSubsystem( - @Param('projectId') projectId: string, + async deleteSubsystem( + @Param('projectId') _projectId: string, @Param('subsystemSystemId') subsystemSystemId: string, + @ArcSession() session: ActiveSession, ): Promise> { - await Promise.resolve(); // Placeholder to satisfy linter - console.log( - `Removing subsystem ${subsystemSystemId} in project ${projectId}`, + const result = await this.commandBus.execute<{ + deletedSubsystemSnapshot: { + systemId: number; + naturalId: number; + name: string; + parentId?: number; + }; + }>( + new DeleteSubsystemCommand( + parseSystemId(subsystemSystemId), + session.fileSystemId, + ), + session, ); - throw new NotImplementedException('removeSubsystem is not implemented yet'); + return toApiResult(Result.ok(result), value => ({ + systemId: String(value.deletedSubsystemSnapshot.systemId), + naturalId: value.deletedSubsystemSnapshot.naturalId, + name: value.deletedSubsystemSnapshot.name, + ...(value.deletedSubsystemSnapshot.parentId !== undefined + ? { + parentSystemId: String(value.deletedSubsystemSnapshot.parentId), + } + : {}), + })); } //#endregion //#endregion } + +function parseSystemId(value: string): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new BadRequestException(`Invalid system ID: ${value}`); + } + return parsed; +} + +function parseSystemIds(values: string[] | undefined): number[] { + return (values ?? []).map(value => parseSystemId(value)); +} + +function parseOptionalSystemId( + value: string | null | undefined, +): number | null | undefined { + if (value === null) return null; + if (value === undefined) return undefined; + return parseSystemId(value); +} + +function mapMovedComponent(component: { + systemId: number; + parentSystemId: number | null; +}): {systemId: string; parentSystemId?: string} { + return { + systemId: String(component.systemId), + ...(component.parentSystemId !== null + ? {parentSystemId: String(component.parentSystemId)} + : {}), + }; +} + +function mapMovedDataPort(port: { + systemId: number; + naturalId: number; + portIoType: string; + isStatic: boolean; + name?: string; +}) { + return { + systemId: String(port.systemId), + naturalId: port.naturalId, + name: port.name ?? '', + portIoType: mapPortIoType(port.portIoType), + portType: port.isStatic ? ('Static' as const) : ('Dynamic' as const), + totalLinksAtPort: 0, + }; +} + +function mapPortIoType( + value: string, +): 'Input' | 'Output' | 'InputOutput' | 'OutputInput' { + switch (value) { + case 'INPUT': + return 'Input'; + case 'OUTPUT': + return 'Output'; + case 'INPUT_OUTPUT': + return 'InputOutput'; + case 'OUTPUT_INPUT': + return 'OutputInput'; + default: + return value as 'Input' | 'Output' | 'InputOutput' | 'OutputInput'; + } +} + +function mapMovedControlPort(port: { + systemId: number; + naturalId: number; + isStatic: boolean; + name?: string; +}) { + return { + systemId: String(port.systemId), + naturalId: port.naturalId, + name: port.name ?? '', + portType: port.isStatic ? ('Static' as const) : ('Dynamic' as const), + totalLinksAtPort: 0, + intents: [], + }; +} + +function mapSubsystem(subsystem: { + systemId: number; + naturalId?: number; + name: string; + parentId?: number; + filteredKeys: Array<{systemId: number; keyId: number; name: string}>; + dataPorts?: Array<{ + systemId: number; + portId: number; + name: string | null; + portIoType: string; + isStatic: boolean; + totalLinksAtPort: number; + }>; + controlPorts?: Array<{ + systemId: number; + portId: number; + name: string | null; + isStatic: boolean; + allocatedIntents: Array<{ + systemId: number; + intentId: number; + name?: string; + }>; + totalLinksAtPort: number; + }>; +}) { + return { + systemId: String(subsystem.systemId), + naturalId: subsystem.naturalId ?? 0, + name: subsystem.name, + ...(subsystem.parentId !== undefined + ? {parentSystemId: String(subsystem.parentId)} + : {}), + dataPorts: (subsystem.dataPorts ?? []).map(port => ({ + systemId: String(port.systemId), + naturalId: port.portId, + name: port.name ?? '', + portIoType: mapPortIoType(port.portIoType), + portType: port.isStatic ? ('Static' as const) : ('Dynamic' as const), + totalLinksAtPort: port.totalLinksAtPort, + })), + controlPorts: (subsystem.controlPorts ?? []).map(port => ({ + systemId: String(port.systemId), + naturalId: port.portId, + name: port.name ?? '', + portType: port.isStatic ? ('Static' as const) : ('Dynamic' as const), + totalLinksAtPort: port.totalLinksAtPort, + intents: port.allocatedIntents.map(intent => ({ + naturalId: intent.intentId, + ...(intent.name ? {name: intent.name} : {}), + })), + })), + filteredKeys: subsystem.filteredKeys.map(key => ({ + systemId: String(key.systemId), + naturalId: key.keyId, + name: key.name, + })), + }; +} diff --git a/packages/api/tests/e2e/subsystem/subsystem-crud.e2e-spec.ts b/packages/api/tests/e2e/subsystem/subsystem-crud.e2e-spec.ts new file mode 100644 index 000000000..e72a6655e --- /dev/null +++ b/packages/api/tests/e2e/subsystem/subsystem-crud.e2e-spec.ts @@ -0,0 +1,301 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {describe, expect, it, beforeAll, afterAll} from '@jest/globals'; +import request from 'supertest'; +import {join, dirname} from 'path'; +import {fileURLToPath} from 'url'; +import type {INestApplication} from '@nestjs/common'; +import {setupE2ETest, teardownE2ETest} from '../helpers/e2e-test-setup.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const subsystemPath = (projectId: string) => + `/arc-api/v1/projects/${projectId}/subsystems`; + +type HttpServer = Parameters[0]; + +async function uploadProject( + httpServer: unknown, + authToken: string, +): Promise { + const acdbPath = join(__dirname, '../fixtures/acdb_cal.acdb'); + const awspPath = join(__dirname, '../fixtures/workspaceFileXml.awsp'); + const response = await request(httpServer as HttpServer) + .post('/arc-api/v1/projects/offline/upload-files') + .set('Authorization', `Bearer ${authToken}`) + .attach('acdbFile', acdbPath) + .attach('workspaceFile', awspPath) + .timeout(120_000) + .expect(201); + return response.body.data.projectId as string; +} + +async function startDesignerSession( + httpServer: unknown, + authToken: string, + projectId: string, +): Promise { + await request(httpServer as HttpServer) + .post(`/arc-api/v1/projects/${projectId}/start-session`) + .set('Authorization', `Bearer ${authToken}`) + .send({mode: 'DESIGNER'}) + .timeout(30_000) + .expect(201); +} + +async function endSession( + httpServer: unknown, + authToken: string, + projectId: string, +): Promise { + await request(httpServer as HttpServer) + .post(`/arc-api/v1/projects/${projectId}/end-session`) + .set('Authorization', `Bearer ${authToken}`) + .timeout(30_000); +} + +async function createSubsystem( + httpServer: unknown, + authToken: string, + projectId: string, + name: string, +): Promise<{systemId: string; naturalId: number; name: string}> { + const response = await request(httpServer as HttpServer) + .post(subsystemPath(projectId)) + .set('Authorization', `Bearer ${authToken}`) + .send({name}) + .timeout(30_000) + .expect(201); + return response.body.data; +} + +describe('Subsystem API E2E', () => { + let app: INestApplication; + let httpServer: unknown; + let authToken: string; + let projectId: string; + + beforeAll(async () => { + const setup = await setupE2ETest(); + app = setup.app; + httpServer = setup.httpServer; + authToken = setup.authToken; + projectId = await uploadProject(httpServer, authToken); + await startDesignerSession(httpServer, authToken, projectId); + }, 180_000); + + afterAll(async () => { + await teardownE2ETest(app); + }); + + describe('POST /subsystems', () => { + it('returns 401 without an access token', async () => { + await request(httpServer as HttpServer) + .post(subsystemPath(projectId)) + .send({name: 'unauthorized-subsystem'}) + .timeout(30_000) + .expect(401); + }); + + it('returns 403 without an active project session', async () => { + await endSession(httpServer, authToken, projectId); + + await request(httpServer as HttpServer) + .post(subsystemPath(projectId)) + .set('Authorization', `Bearer ${authToken}`) + .send({name: 'no-session-subsystem'}) + .timeout(30_000) + .expect(403); + + await startDesignerSession(httpServer, authToken, projectId); + }, 60_000); + + it('creates a subsystem and maps the snapshot response', async () => { + const subsystem = await createSubsystem( + httpServer, + authToken, + projectId, + `api-subsystem-${Date.now()}`, + ); + + expect(subsystem.systemId).toMatch(/^\d+$/); + expect(subsystem.naturalId).toEqual(expect.any(Number)); + expect(subsystem.name).toMatch(/^api-subsystem-/); + }); + + it('returns 422 for a duplicate subsystem name', async () => { + const name = `duplicate-subsystem-${Date.now()}`; + await createSubsystem(httpServer, authToken, projectId, name); + + const response = await request(httpServer as HttpServer) + .post(subsystemPath(projectId)) + .set('Authorization', `Bearer ${authToken}`) + .send({name}) + .timeout(30_000); + + expect(response.status).toBe(422); + expect(Array.isArray(response.body.issues)).toBe(true); + }); + }); + + describe('PATCH /subsystems/:subsystemSystemId', () => { + it('returns 400 when no patch fields are provided', async () => { + const subsystem = await createSubsystem( + httpServer, + authToken, + projectId, + `patch-subsystem-${Date.now()}`, + ); + + await request(httpServer as HttpServer) + .patch(`${subsystemPath(projectId)}/${subsystem.systemId}`) + .set('Authorization', `Bearer ${authToken}`) + .send({}) + .timeout(30_000) + .expect(400); + }); + + it('updates the subsystem name and returns the mapped subsystem', async () => { + const subsystem = await createSubsystem( + httpServer, + authToken, + projectId, + `rename-subsystem-${Date.now()}`, + ); + const name = `renamed-subsystem-${Date.now()}`; + + const response = await request(httpServer as HttpServer) + .patch(`${subsystemPath(projectId)}/${subsystem.systemId}`) + .set('Authorization', `Bearer ${authToken}`) + .send({name}) + .timeout(30_000) + .expect(200); + + expect(response.body.data.systemId).toBe(subsystem.systemId); + expect(response.body.data.name).toBe(name); + expect(Array.isArray(response.body.data.dataPorts)).toBe(true); + expect(Array.isArray(response.body.data.controlPorts)).toBe(true); + }); + + it('returns 400 for an invalid subsystem system ID', async () => { + await request(httpServer as HttpServer) + .patch(`${subsystemPath(projectId)}/not-a-system-id`) + .set('Authorization', `Bearer ${authToken}`) + .send({name: 'invalid-id'}) + .timeout(30_000) + .expect(400); + }); + }); + + describe('PUT /subsystems/:subsystemSystemId/filtered-keys', () => { + it('replaces filtered keys with an empty list', async () => { + const subsystem = await createSubsystem( + httpServer, + authToken, + projectId, + `filtered-keys-subsystem-${Date.now()}`, + ); + + const response = await request(httpServer as HttpServer) + .put(`${subsystemPath(projectId)}/${subsystem.systemId}/filtered-keys`) + .set('Authorization', `Bearer ${authToken}`) + .send({keySystemIds: []}) + .timeout(30_000) + .expect(200); + + expect(response.body.data.systemId).toBe(subsystem.systemId); + expect(response.body.data.filteredKeys).toEqual([]); + }); + + it('returns 400 when keySystemIds is not an array of strings', async () => { + const subsystem = await createSubsystem( + httpServer, + authToken, + projectId, + `invalid-filtered-keys-${Date.now()}`, + ); + + await request(httpServer as HttpServer) + .put(`${subsystemPath(projectId)}/${subsystem.systemId}/filtered-keys`) + .set('Authorization', `Bearer ${authToken}`) + .send({keySystemIds: [1]}) + .timeout(30_000) + .expect(400); + }); + }); + + describe('POST /subsystems/components/move', () => { + it('returns 400 when no components are provided', async () => { + await request(httpServer as HttpServer) + .post(`${subsystemPath(projectId)}/components/move`) + .set('Authorization', `Bearer ${authToken}`) + .send({targetSubsystemSystemId: null}) + .timeout(30_000) + .expect(400); + }); + + it('moves a subsystem into another subsystem', async () => { + const source = await createSubsystem( + httpServer, + authToken, + projectId, + `move-source-${Date.now()}`, + ); + const target = await createSubsystem( + httpServer, + authToken, + projectId, + `move-target-${Date.now()}`, + ); + + const response = await request(httpServer as HttpServer) + .post(`${subsystemPath(projectId)}/components/move`) + .set('Authorization', `Bearer ${authToken}`) + .send({ + subsystemSystemIds: [source.systemId], + targetSubsystemSystemId: target.systemId, + }) + .timeout(30_000) + .expect(201); + + expect(response.body.data.updatedSubsystems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + systemId: source.systemId, + parentSystemId: target.systemId, + }), + ]), + ); + expect(response.body.data.addedDataLinks).toEqual([]); + expect(response.body.data.removedDataLinks).toEqual([]); + expect(response.body.data.addedControlLinks).toEqual([]); + expect(response.body.data.removedControlLinks).toEqual([]); + }, 60_000); + }); + + describe('DELETE /subsystems/:subsystemSystemId', () => { + it('deletes an empty subsystem and returns its snapshot', async () => { + const subsystem = await createSubsystem( + httpServer, + authToken, + projectId, + `delete-subsystem-${Date.now()}`, + ); + + const response = await request(httpServer as HttpServer) + .delete(`${subsystemPath(projectId)}/${subsystem.systemId}`) + .set('Authorization', `Bearer ${authToken}`) + .timeout(30_000) + .expect(200); + + expect(response.body.data).toMatchObject({ + systemId: subsystem.systemId, + naturalId: subsystem.naturalId, + name: subsystem.name, + }); + }); + }); +}); diff --git a/packages/core/src/application/orchestration/cqrs/registries/command-handler-registry.ts b/packages/core/src/application/orchestration/cqrs/registries/command-handler-registry.ts index b7a094ef2..a38562996 100644 --- a/packages/core/src/application/orchestration/cqrs/registries/command-handler-registry.ts +++ b/packages/core/src/application/orchestration/cqrs/registries/command-handler-registry.ts @@ -107,6 +107,16 @@ import {DeleteProjectCommand} from '../../../project/delete/delete-project.comma import {DeleteProjectHandler} from '../../../project/delete/delete-project.handler.js'; import {DeleteSpfModuleCommand} from '../../../usecase-designer/spf-module/delete/delete-spf-module.command.js'; import {DeleteSpfModuleHandler} from '../../../usecase-designer/spf-module/delete/delete-spf-module.handler.js'; +import {CreateSubsystemCommand} from '../../../usecase-designer/subsystem/create/create-subsystem.command.js'; +import {CreateSubsystemHandler} from '../../../usecase-designer/subsystem/create/create-subsystem.handler.js'; +import {DeleteSubsystemCommand} from '../../../usecase-designer/subsystem/delete/delete-subsystem.command.js'; +import {DeleteSubsystemHandler} from '../../../usecase-designer/subsystem/delete/delete-subsystem.handler.js'; +import {PatchSubsystemCommand} from '../../../usecase-designer/subsystem/patch/patch-subsystem.command.js'; +import {PatchSubsystemHandler} from '../../../usecase-designer/subsystem/patch/patch-subsystem.handler.js'; +import {SetSubsystemFilteredKeysCommand} from '../../../usecase-designer/subsystem/set-filtered-keys/set-subsystem-filtered-keys.command.js'; +import {SetSubsystemFilteredKeysHandler} from '../../../usecase-designer/subsystem/set-filtered-keys/set-subsystem-filtered-keys.handler.js'; +import {MoveSubsystemComponentsCommand} from '../../../usecase-designer/subsystem/move/move-subsystem-components.command.js'; +import {MoveSubsystemComponentsHandler} from '../../../usecase-designer/subsystem/move/move-subsystem-components.handler.js'; import {UpdateTkvCalDataCommand} from '../../../usecase-designer/spf-module/update-tag-data/update-tkv-cal-data.command.js'; import {UpdateTkvCalDataHandler} from '../../../usecase-designer/spf-module/update-tag-data/update-tkv-cal-data.handler.js'; import {CreateUsecasesCommand} from '../../../usecase-designer/use-case-creator/create-usecases/create-usecases.command.js'; @@ -285,5 +295,26 @@ export class CommandHandlerRegistry { this.commandHandlerFactories.set(CreateManualUsecasesCommand, { create: deps => new CreateManualUsecasesHandler(deps.uow), }); + this.commandHandlerFactories.set(CreateSubsystemCommand, { + create: deps => + new CreateSubsystemHandler( + deps.uow, + deps.idGeneration, + deps.naturalIdGeneration, + ), + }); + this.commandHandlerFactories.set(DeleteSubsystemCommand, { + create: deps => new DeleteSubsystemHandler(deps.uow), + }); + this.commandHandlerFactories.set(PatchSubsystemCommand, { + create: deps => new PatchSubsystemHandler(deps.uow, deps.idGeneration), + }); + this.commandHandlerFactories.set(SetSubsystemFilteredKeysCommand, { + create: deps => new SetSubsystemFilteredKeysHandler(deps.uow), + }); + this.commandHandlerFactories.set(MoveSubsystemComponentsCommand, { + create: deps => + new MoveSubsystemComponentsHandler(deps.uow, deps.idGeneration), + }); } } diff --git a/packages/core/src/application/ports/persistence/query-services/subsystem/subsystem-read-model.ts b/packages/core/src/application/ports/persistence/query-services/subsystem/subsystem-read-model.ts index c2940c8dd..e4981f32c 100644 --- a/packages/core/src/application/ports/persistence/query-services/subsystem/subsystem-read-model.ts +++ b/packages/core/src/application/ports/persistence/query-services/subsystem/subsystem-read-model.ts @@ -4,6 +4,8 @@ */ import type {KeyDefinitionSummaryReadModel} from '../key-value/key-value-definition-read-model.js'; +import type {ControlPortReadModel} from '../spf-module/ports/control-port-read-model.js'; +import type {DataPortReadModel} from '../spf-module/ports/data-port-read-model.js'; /** * Read model for a subsystem node. @@ -17,7 +19,11 @@ import type {KeyDefinitionSummaryReadModel} from '../key-value/key-value-definit */ export interface SubsystemReadModel { readonly systemId: number; + readonly naturalId?: number; readonly name: string; readonly parentSystemId?: number; + readonly subgraphSystemIds?: number[]; readonly filteredKeys: KeyDefinitionSummaryReadModel[]; + readonly dataPorts?: DataPortReadModel[]; + readonly controlPorts?: ControlPortReadModel[]; } diff --git a/packages/core/src/application/ports/persistence/repositories/control-link/control-link.repository.ts b/packages/core/src/application/ports/persistence/repositories/control-link/control-link.repository.ts index 5dfa4eb28..c2d7226bf 100644 --- a/packages/core/src/application/ports/persistence/repositories/control-link/control-link.repository.ts +++ b/packages/core/src/application/ports/persistence/repositories/control-link/control-link.repository.ts @@ -92,6 +92,21 @@ export interface ControlLinkRepository { */ findIntraUcLinksByFile(fileSystemId: number): Promise; + findAllWithSegments(fileSystemId: number): Promise; + + replaceSubsystemControlLinkSegments( + controlLinkSystemId: number, + segments: SubsystemControlLink[], + options?: EditOptions, + ): Promise; + + replaceUnresolvedSubsystemControlLinkSegments( + subsystemLinkSystemIds: number[], + segments: SubsystemControlLink[], + fileSystemId: number, + options?: EditOptions, + ): Promise; + /** * Returns ControlLinks added or deleted in the current session — a * `SessionChanged` split. No `source` filter is applied; diff --git a/packages/core/src/application/ports/persistence/repositories/data-link/data-link.repository.ts b/packages/core/src/application/ports/persistence/repositories/data-link/data-link.repository.ts index 7ba3ef61d..d71fdc6e4 100644 --- a/packages/core/src/application/ports/persistence/repositories/data-link/data-link.repository.ts +++ b/packages/core/src/application/ports/persistence/repositories/data-link/data-link.repository.ts @@ -93,6 +93,21 @@ export interface DataLinkRepository { */ findIntraUcLinksByFile(fileSystemId: number): Promise; + findAllWithSegments(fileSystemId: number): Promise; + + replaceSubsystemDataLinkSegments( + dataLinkSystemId: number, + segments: SubsystemDataLink[], + options?: EditOptions, + ): Promise; + + replaceUnresolvedSubsystemDataLinkSegments( + subsystemLinkSystemIds: number[], + segments: SubsystemDataLink[], + fileSystemId: number, + options?: EditOptions, + ): Promise; + /** * Returns DataLinks added or deleted in the current session — a * `SessionChanged` split. No `source` filter is applied; MANUAL diff --git a/packages/core/src/application/ports/persistence/repositories/module/module.repository.ts b/packages/core/src/application/ports/persistence/repositories/module/module.repository.ts index 185f3b6e5..1e2b3544a 100644 --- a/packages/core/src/application/ports/persistence/repositories/module/module.repository.ts +++ b/packages/core/src/application/ports/persistence/repositories/module/module.repository.ts @@ -97,6 +97,11 @@ export interface ModuleRepository { moduleSystemId: number, options?: EditOptions, ): Promise; + updateParentId( + moduleSystemId: number, + parentSubsystemSystemId: number | null, + options?: EditOptions, + ): Promise; createModule(module: SpfModule, options?: EditOptions): Promise; /** diff --git a/packages/core/src/application/ports/persistence/repositories/subgraph/subgraph.repository.ts b/packages/core/src/application/ports/persistence/repositories/subgraph/subgraph.repository.ts index 9d0dea05d..176b6b35e 100644 --- a/packages/core/src/application/ports/persistence/repositories/subgraph/subgraph.repository.ts +++ b/packages/core/src/application/ports/persistence/repositories/subgraph/subgraph.repository.ts @@ -21,6 +21,7 @@ export interface SgkvEntry { } export interface SubgraphRepository { + findSubgraphFileSystemId(systemId: number): Promise; subgraphExists(systemId: number, fileSystemId: number): Promise; deleteSubgraph( diff --git a/packages/core/src/application/ports/persistence/repositories/subsystem/subsystem.repository.ts b/packages/core/src/application/ports/persistence/repositories/subsystem/subsystem.repository.ts index a6977f76b..ddbaa1049 100644 --- a/packages/core/src/application/ports/persistence/repositories/subsystem/subsystem.repository.ts +++ b/packages/core/src/application/ports/persistence/repositories/subsystem/subsystem.repository.ts @@ -3,15 +3,50 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import type {EditOptions} from '../../edit-options.js'; - /** Identifies a subsystem control port without losing its aggregate owner. */ export interface SubsystemControlPortRef { subsystemSystemId: number; controlPortSystemId: number; } +import type {EditOptions} from '../../edit-options.js'; +import type {ControlPort} from '../../../../../domain/entities/usecase-data/node/entities/control-port.js'; +import type {DataPort} from '../../../../../domain/entities/usecase-data/node/entities/data-port.js'; +import type {Subsystem} from '../../../../../domain/entities/usecase-data/subsystem/subsystem.js'; +import type {NodeType} from '../../../../../domain/entities/usecase-data/node/node.js'; + +export interface SubsystemSummary { + readonly systemId: number; + readonly naturalId: number; + readonly name: string; + readonly parentId?: number; + readonly subgraphSystemIds: readonly number[]; +} + +export interface SubsystemKeyDefinition { + readonly systemId: number; + readonly keyId: number; + readonly name: string; +} + +export type SubsystemNodeTopology = { + systemId: number; + parentId: number | null; + type: NodeType; +}; + export interface SubsystemRepository { + findSubsystems(fileSystemId: number): Promise; + findSubsystemFileSystemId(systemId: number): Promise; + findNodeTopology(fileSystemId: number): Promise; + findSubsystemForPatch( + systemId: number, + fileSystemId: number, + ): Promise; + findKeyDefinitionsByIds( + keySystemIds: readonly number[], + fileSystemId: number, + ): Promise; subsystemExists(systemId: number, fileSystemId: number): Promise; hasSubsystems(fileSystemId: number): Promise; @@ -20,4 +55,42 @@ export interface SubsystemRepository { fileSystemId: number, options?: EditOptions, ): Promise; + + createSubsystem(subsystem: Subsystem, options?: EditOptions): Promise; + deleteSubsystem(systemId: number, options?: EditOptions): Promise; + renameSubsystem( + systemId: number, + name: string, + options?: EditOptions, + ): Promise; + setFilteredKeys( + systemId: number, + keySystemIds: number[], + options?: EditOptions, + ): Promise; + addDataPort( + port: DataPort, + subsystemSystemId: number, + options?: EditOptions, + ): Promise; + removeDataPort( + portSystemId: number, + subsystemSystemId: number, + options?: EditOptions, + ): Promise; + addControlPort( + port: ControlPort, + subsystemSystemId: number, + options?: EditOptions, + ): Promise; + removeControlPort( + portSystemId: number, + subsystemSystemId: number, + options?: EditOptions, + ): Promise; + updateParentId( + subsystemSystemId: number, + parentSubsystemSystemId: number | null, + options?: EditOptions, + ): Promise; } diff --git a/packages/core/src/application/usecase-designer/spf-module/patch/resolve-port-count-change.ts b/packages/core/src/application/usecase-designer/shared/resolve-port-count-change.ts similarity index 92% rename from packages/core/src/application/usecase-designer/spf-module/patch/resolve-port-count-change.ts rename to packages/core/src/application/usecase-designer/shared/resolve-port-count-change.ts index 305b91352..f2b4a61e6 100644 --- a/packages/core/src/application/usecase-designer/spf-module/patch/resolve-port-count-change.ts +++ b/packages/core/src/application/usecase-designer/shared/resolve-port-count-change.ts @@ -3,9 +3,9 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {Result} from '../../../shared/result/result.js'; -import type {IssueEntityType} from '../../../../shared/issues/impacted-entity.js'; -import {IssueFactory} from '../../../../shared/issues/factories.js'; +import {Result} from '../../shared/result/result.js'; +import type {IssueEntityType} from '../../../shared/issues/impacted-entity.js'; +import {IssueFactory} from '../../../shared/issues/factories.js'; export interface PortCountChangeResult { /** Number of new ports to add. */ diff --git a/packages/core/src/application/usecase-designer/spf-module/patch/patch-spf-module.handler.ts b/packages/core/src/application/usecase-designer/spf-module/patch/patch-spf-module.handler.ts index 6dac94333..c450c01a4 100644 --- a/packages/core/src/application/usecase-designer/spf-module/patch/patch-spf-module.handler.ts +++ b/packages/core/src/application/usecase-designer/spf-module/patch/patch-spf-module.handler.ts @@ -22,7 +22,7 @@ import {CONTAINER_PROP_ID_STACK_SIZE} from '../../../file-operations/shared/cons import {buildContainerCopy} from '../../container/build-container-copy.js'; import {DataPort} from '../../../../domain/entities/usecase-data/node/entities/data-port.js'; import {ControlPort} from '../../../../domain/entities/usecase-data/node/entities/control-port.js'; -import {resolvePortCountChange} from './resolve-port-count-change.js'; +import {resolvePortCountChange} from '../../shared/resolve-port-count-change.js'; import {RESULT_KIND} from '../../../shared/result/result.js'; import {ContainerStackSizeService} from '../../container/services/container-stack-size.service.js'; import { diff --git a/packages/core/src/application/usecase-designer/spf-module/query/spf-module-dto.ts b/packages/core/src/application/usecase-designer/spf-module/query/spf-module-dto.ts index 5241fc150..cbf93fcd5 100644 --- a/packages/core/src/application/usecase-designer/spf-module/query/spf-module-dto.ts +++ b/packages/core/src/application/usecase-designer/spf-module/query/spf-module-dto.ts @@ -108,7 +108,9 @@ export const DataPortDtoSchema = z.object({ systemId: z.string().describe('Port system ID'), naturalId: z.number().int().describe('Port definition natural ID'), name: z.string().describe('Port name'), - portIoType: z.enum(['Input', 'Output']).describe('Port IO type'), + portIoType: z + .enum(['Input', 'Output', 'InputOutput', 'OutputInput']) + .describe('Port IO type'), portType: z.enum(['Static', 'Dynamic']).describe('Port type'), totalLinksAtPort: z .number() diff --git a/packages/core/src/application/usecase-designer/subsystem/create/create-subsystem.command.ts b/packages/core/src/application/usecase-designer/subsystem/create/create-subsystem.command.ts new file mode 100644 index 000000000..9f6d5c338 --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/create/create-subsystem.command.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {BaseCommand} from '../../../shared/base-command.js'; +import {SESSION_MODE} from '../../../shared/change-vocabulary.js'; +import type {SessionMode} from '../../../shared/change-vocabulary.js'; + +export class CreateSubsystemCommand extends BaseCommand { + static override readonly requiresSession = true; + static override readonly allowedModes: readonly SessionMode[] = [ + SESSION_MODE.Designer, + SESSION_MODE.DiffMerge, + ]; + + constructor( + public readonly fileSystemId: number, + public readonly name: string | undefined, + public readonly parentId: number | undefined, + ) { + super(); + } +} diff --git a/packages/core/src/application/usecase-designer/subsystem/create/create-subsystem.handler.ts b/packages/core/src/application/usecase-designer/subsystem/create/create-subsystem.handler.ts new file mode 100644 index 000000000..4da39130f --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/create/create-subsystem.handler.ts @@ -0,0 +1,117 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import type {CommandHandler} from '../../../orchestration/cqrs/commands/command-handler.js'; +import type {UnitOfWork} from '../../../ports/persistence/unit-of-work.js'; +import type {IdGenerationPort} from '../../../ports/id-generation/id-generation.port.js'; +import type {NaturalIdGenerationPort} from '../../../ports/id-generation/natural-id-generation.port.js'; +import {Subsystem} from '../../../../domain/entities/usecase-data/subsystem/subsystem.js'; +import {NaturalIdType} from '../../../../domain/services/natural-id-generator/natural-id-type.js'; +import { + DomainRuleViolationException, + InvalidOperationException, + ResourceNotFoundException, +} from '../../../../shared/exceptions/index.js'; +import {ISSUE_ENTITY_TYPE} from '../../../../shared/issues/impacted-entity.js'; +import {IssueFactory} from '../../../../shared/issues/factories.js'; +import type {CreateSubsystemCommand} from './create-subsystem.command.js'; + +export type CreateSubsystemResult = { + groupId: string; + subsystemSystemId: number; + naturalId: number; + name: string; + parentId?: number; +}; + +export class CreateSubsystemHandler implements CommandHandler< + CreateSubsystemCommand, + CreateSubsystemResult +> { + constructor( + private readonly uow: UnitOfWork, + private readonly idGeneration: IdGenerationPort, + private readonly naturalIdGeneration: NaturalIdGenerationPort, + ) {} + + async handle( + command: CreateSubsystemCommand, + ): Promise { + if (command.name !== undefined && command.name.length > 255) { + throw new InvalidOperationException( + 'Subsystem name must not exceed 255 characters.', + ); + } + + await this.uow.startTransaction(); + try { + const subsystems = await this.uow + .getSubsystemRepository() + .findSubsystems(command.fileSystemId); + const normalizedName = command.name?.toLocaleLowerCase(); + if ( + normalizedName !== undefined && + subsystems.some(s => s.name.toLocaleLowerCase() === normalizedName) + ) { + throw new DomainRuleViolationException([ + IssueFactory.duplicateSubsystemName(command.name!), + ]); + } + + if (command.parentId !== undefined) { + const parentExists = subsystems.some( + s => s.systemId === command.parentId, + ); + if (!parentExists) { + throw new ResourceNotFoundException( + `Subsystem ${command.parentId} not found.`, + [ + IssueFactory.notFound( + ISSUE_ENTITY_TYPE.Subsystem, + command.parentId, + ), + ], + ); + } + } + + const subsystemSystemId = await this.idGeneration.getNextId( + command.fileSystemId, + ); + const subsystemNaturalId = this.naturalIdGeneration.getNextId( + command.fileSystemId, + NaturalIdType.SUBSYSTEM, + ); + const name = + command.name ?? + `SS_0x${subsystemNaturalId.toString(16).padStart(8, '0').toUpperCase()}`; + + await this.uow.getSubsystemRepository().createSubsystem( + new Subsystem({ + systemId: subsystemSystemId, + fileSystemId: command.fileSystemId, + parentSystemId: command.parentId, + name, + naturalId: subsystemNaturalId, + filteredKeySystemIds: [], + dataPorts: [], + controlPorts: [], + }), + ); + await this.uow.commit(); + + return { + groupId: this.uow.getWriteContext().groupId, + subsystemSystemId, + naturalId: subsystemNaturalId, + name, + parentId: command.parentId, + }; + } catch (error) { + if (this.uow.isInTransaction()) await this.uow.rollback(); + throw error; + } + } +} diff --git a/packages/core/src/application/usecase-designer/subsystem/delete/delete-subsystem.command.ts b/packages/core/src/application/usecase-designer/subsystem/delete/delete-subsystem.command.ts new file mode 100644 index 000000000..209211d9c --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/delete/delete-subsystem.command.ts @@ -0,0 +1,23 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {BaseCommand} from '../../../shared/base-command.js'; +import {SESSION_MODE} from '../../../shared/change-vocabulary.js'; +import type {SessionMode} from '../../../shared/change-vocabulary.js'; + +export class DeleteSubsystemCommand extends BaseCommand { + static override readonly requiresSession = true; + static override readonly allowedModes: readonly SessionMode[] = [ + SESSION_MODE.Designer, + SESSION_MODE.DiffMerge, + ]; + + constructor( + public readonly subsystemSystemId: number, + public readonly fileSystemId: number, + ) { + super(); + } +} diff --git a/packages/core/src/application/usecase-designer/subsystem/delete/delete-subsystem.handler.ts b/packages/core/src/application/usecase-designer/subsystem/delete/delete-subsystem.handler.ts new file mode 100644 index 000000000..7b0160e13 --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/delete/delete-subsystem.handler.ts @@ -0,0 +1,84 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import type {CommandHandler} from '../../../orchestration/cqrs/commands/command-handler.js'; +import type {UnitOfWork} from '../../../ports/persistence/unit-of-work.js'; +import { + DomainRuleViolationException, + ResourceNotFoundException, +} from '../../../../shared/exceptions/index.js'; +import {ISSUE_ENTITY_TYPE} from '../../../../shared/issues/impacted-entity.js'; +import {IssueFactory} from '../../../../shared/issues/factories.js'; +import type {DeleteSubsystemCommand} from './delete-subsystem.command.js'; + +export type DeleteSubsystemResult = { + groupId: string; + deletedSubsystemSnapshot: { + systemId: number; + naturalId: number; + name: string; + parentId?: number; + }; +}; + +export class DeleteSubsystemHandler implements CommandHandler< + DeleteSubsystemCommand, + DeleteSubsystemResult +> { + constructor(private readonly uow: UnitOfWork) {} + + async handle( + command: DeleteSubsystemCommand, + ): Promise { + await this.uow.startTransaction(); + try { + const subsystems = await this.uow + .getSubsystemRepository() + .findSubsystems(command.fileSystemId); + const subsystem = subsystems.find( + item => item.systemId === command.subsystemSystemId, + ); + if (!subsystem) { + throw new ResourceNotFoundException( + `Subsystem ${command.subsystemSystemId} not found.`, + [ + IssueFactory.notFound( + ISSUE_ENTITY_TYPE.Subsystem, + command.subsystemSystemId, + ), + ], + ); + } + + const hasChildSubsystem = subsystems.some( + item => item.parentId === command.subsystemSystemId, + ); + const hasChildSubgraph = (subsystem.subgraphSystemIds?.length ?? 0) > 0; + if (hasChildSubsystem || hasChildSubgraph) { + throw new DomainRuleViolationException([ + IssueFactory.subsystemNotEmpty(command.subsystemSystemId), + ]); + } + + await this.uow + .getSubsystemRepository() + .deleteSubsystem(command.subsystemSystemId); + await this.uow.commit(); + + return { + groupId: this.uow.getWriteContext().groupId, + deletedSubsystemSnapshot: { + systemId: subsystem.systemId, + naturalId: subsystem.naturalId, + name: subsystem.name, + parentId: subsystem.parentId, + }, + }; + } catch (error) { + if (this.uow.isInTransaction()) await this.uow.rollback(); + throw error; + } + } +} diff --git a/packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-components.command.ts b/packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-components.command.ts new file mode 100644 index 000000000..349f73957 --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-components.command.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {BaseCommand} from '../../../shared/base-command.js'; +import {SESSION_MODE} from '../../../shared/change-vocabulary.js'; +import type {SessionMode} from '../../../shared/change-vocabulary.js'; + +export class MoveSubsystemComponentsCommand extends BaseCommand { + static override readonly requiresSession = true; + static override readonly allowedModes: readonly SessionMode[] = [ + SESSION_MODE.Designer, + SESSION_MODE.DiffMerge, + ]; + + constructor( + public readonly fileSystemId: number, + public readonly subgraphSystemIds: number[], + public readonly subsystemSystemIds: number[], + public readonly targetSubsystemSystemId: number | null, + ) { + super(); + } +} diff --git a/packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-components.handler.ts b/packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-components.handler.ts new file mode 100644 index 000000000..7fd42eef2 --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-components.handler.ts @@ -0,0 +1,305 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import type {CommandHandler} from '../../../orchestration/cqrs/commands/command-handler.js'; +import type {UnitOfWork} from '../../../ports/persistence/unit-of-work.js'; +import { + DomainRuleViolationException, + InvalidOperationException, + ResourceNotFoundException, +} from '../../../../shared/exceptions/index.js'; +import {ISSUE_ENTITY_TYPE} from '../../../../shared/issues/impacted-entity.js'; +import {IssueFactory} from '../../../../shared/issues/factories.js'; +import type {Issue} from '../../../../shared/issues/issue.js'; +import type {MoveSubsystemComponentsCommand} from './move-subsystem-components.command.js'; +import {isDescendant} from '../subsystem-helpers.js'; +import { + rebuildMoveSubsystemImpact, + type MoveSubsystemImpact, +} from './move-subsystem-impact.js'; +import type {IdGenerationPort} from '../../../ports/id-generation/id-generation.port.js'; + +export type MoveSubsystemComponentsResult = { + groupId: string; + updatedModules: Array<{systemId: number; parentSystemId: number | null}>; + updatedSubsystems: Array<{systemId: number; parentSystemId: number | null}>; + issues?: readonly Issue[]; +} & MoveSubsystemImpact; + +export class MoveSubsystemComponentsHandler implements CommandHandler< + MoveSubsystemComponentsCommand, + MoveSubsystemComponentsResult +> { + constructor( + private readonly uow: UnitOfWork, + private readonly idGeneration: IdGenerationPort, + ) {} + + // The move validates two component types and hierarchy rules in one transaction. + // eslint-disable-next-line sonarjs/cognitive-complexity + async handle( + command: MoveSubsystemComponentsCommand, + ): Promise { + if ( + command.subgraphSystemIds.length === 0 && + command.subsystemSystemIds.length === 0 + ) { + throw new InvalidOperationException( + 'At least one component system ID must be provided.', + ); + } + + await this.uow.startTransaction(); + try { + const subsystems = await this.uow + .getSubsystemRepository() + .findSubsystems(command.fileSystemId); + const topology = await this.uow + .getSubsystemRepository() + .findNodeTopology(command.fileSystemId); + const parentBefore = new Map( + topology.map(node => [node.systemId, node.parentId]), + ); + const issues: Issue[] = []; + const subsystemIds = new Set(subsystems.map(item => item.systemId)); + const subsystemSystemIds: number[] = []; + for (const systemId of command.subsystemSystemIds) { + if (!subsystemIds.has(systemId)) { + const owningFileSystemId = await this.uow + .getSubsystemRepository() + .findSubsystemFileSystemId(systemId); + if ( + owningFileSystemId !== null && + owningFileSystemId !== command.fileSystemId + ) { + issues.push( + IssueFactory.componentInWrongFile( + ISSUE_ENTITY_TYPE.Subsystem, + systemId, + command.fileSystemId, + ), + ); + continue; + } + throw new ResourceNotFoundException( + `Subsystem ${systemId} not found.`, + [IssueFactory.notFound(ISSUE_ENTITY_TYPE.Subsystem, systemId)], + ); + } + const subsystem = subsystems.find(item => item.systemId === systemId)!; + if ( + command.targetSubsystemSystemId === null && + (subsystem.parentId ?? null) === null + ) { + issues.push( + IssueFactory.duplicateRootMove( + ISSUE_ENTITY_TYPE.Subsystem, + systemId, + ), + ); + continue; + } + subsystemSystemIds.push(systemId); + } + if ( + command.targetSubsystemSystemId !== null && + !subsystemIds.has(command.targetSubsystemSystemId) + ) { + const owningFileSystemId = await this.uow + .getSubsystemRepository() + .findSubsystemFileSystemId(command.targetSubsystemSystemId); + if ( + owningFileSystemId !== null && + owningFileSystemId !== command.fileSystemId + ) { + throw new DomainRuleViolationException([ + IssueFactory.componentInWrongFile( + ISSUE_ENTITY_TYPE.Subsystem, + command.targetSubsystemSystemId, + command.fileSystemId, + ), + ]); + } + throw new ResourceNotFoundException( + `Subsystem ${command.targetSubsystemSystemId} not found.`, + [ + IssueFactory.notFound( + ISSUE_ENTITY_TYPE.Subsystem, + command.targetSubsystemSystemId, + ), + ], + ); + } + + if (command.targetSubsystemSystemId !== null) { + for (const componentSystemId of subsystemSystemIds) { + if ( + componentSystemId === command.targetSubsystemSystemId || + isDescendant( + command.targetSubsystemSystemId, + componentSystemId, + subsystems, + ) + ) { + throw new DomainRuleViolationException([ + IssueFactory.circularSubsystemHierarchy( + componentSystemId, + command.targetSubsystemSystemId, + ), + ]); + } + } + + for (const componentSystemId of subsystemSystemIds) { + const component = subsystems.find( + item => item.systemId === componentSystemId, + ); + if (component?.parentId === command.targetSubsystemSystemId) { + throw new DomainRuleViolationException([ + IssueFactory.duplicateChildComponent( + componentSystemId, + command.targetSubsystemSystemId, + ), + ]); + } + } + + for (const component of subsystems) { + if ( + component.subgraphSystemIds?.some(subgraphSystemId => + command.subgraphSystemIds.includes(subgraphSystemId), + ) && + component.systemId === command.targetSubsystemSystemId + ) { + throw new DomainRuleViolationException([ + IssueFactory.duplicateChildComponent( + component.subgraphSystemIds.find(subgraphSystemId => + command.subgraphSystemIds.includes(subgraphSystemId), + )!, + command.targetSubsystemSystemId, + ), + ]); + } + } + } + + const subgraphSystemIds: number[] = []; + const modulesBySubgraph = new Map>(); + for (const subgraphSystemId of command.subgraphSystemIds) { + const exists = await this.uow + .getSubgraphRepository() + .subgraphExists(subgraphSystemId, command.fileSystemId); + if (!exists) { + const owningFileSystemId = await this.uow + .getSubgraphRepository() + .findSubgraphFileSystemId(subgraphSystemId); + if ( + owningFileSystemId !== null && + owningFileSystemId !== command.fileSystemId + ) { + issues.push( + IssueFactory.componentInWrongFile( + ISSUE_ENTITY_TYPE.Subgraph, + subgraphSystemId, + command.fileSystemId, + ), + ); + continue; + } + throw new ResourceNotFoundException( + `Subgraph ${subgraphSystemId} not found.`, + [ + IssueFactory.notFound( + ISSUE_ENTITY_TYPE.Subgraph, + subgraphSystemId, + ), + ], + ); + } + const modules = await this.uow + .getModuleRepository() + .findModulesBySubgraphIds([subgraphSystemId], command.fileSystemId); + modulesBySubgraph.set(subgraphSystemId, modules); + subgraphSystemIds.push(subgraphSystemId); + } + + if (subsystemSystemIds.length === 0 && subgraphSystemIds.length === 0) { + throw new DomainRuleViolationException(issues); + } + + const updatedModules: MoveSubsystemComponentsResult['updatedModules'] = + []; + for (const subgraphSystemId of subgraphSystemIds) { + const modules = modulesBySubgraph.get(subgraphSystemId) ?? []; + for (const module of modules) { + if ( + command.targetSubsystemSystemId === null && + (parentBefore.get(module.systemId) ?? null) === null + ) { + issues.push( + IssueFactory.duplicateRootMove( + ISSUE_ENTITY_TYPE.SpfModule, + module.systemId, + ), + ); + continue; + } + await this.uow + .getModuleRepository() + .updateParentId(module.systemId, command.targetSubsystemSystemId); + updatedModules.push({ + systemId: module.systemId, + parentSystemId: command.targetSubsystemSystemId, + }); + } + } + + const updatedSubsystems: MoveSubsystemComponentsResult['updatedSubsystems'] = + []; + for (const subsystemSystemId of subsystemSystemIds) { + await this.uow + .getSubsystemRepository() + .updateParentId(subsystemSystemId, command.targetSubsystemSystemId); + updatedSubsystems.push({ + systemId: subsystemSystemId, + parentSystemId: command.targetSubsystemSystemId, + }); + } + + if ( + updatedModules.length === 0 && + updatedSubsystems.length === 0 && + issues.length > 0 + ) { + throw new DomainRuleViolationException(issues); + } + + const impact = await rebuildMoveSubsystemImpact( + command.fileSystemId, + topology, + updatedModules, + updatedSubsystems, + { + subsystemRepository: this.uow.getSubsystemRepository(), + dataLinkRepository: this.uow.getDataLinkRepository(), + controlLinkRepository: this.uow.getControlLinkRepository(), + idGeneration: this.idGeneration, + }, + ); + + await this.uow.commit(); + return { + groupId: this.uow.getWriteContext().groupId, + updatedModules, + updatedSubsystems, + ...impact, + ...(issues.length > 0 ? {issues} : {}), + }; + } catch (error) { + if (this.uow.isInTransaction()) await this.uow.rollback(); + throw error; + } + } +} diff --git a/packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-impact.ts b/packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-impact.ts new file mode 100644 index 000000000..c0b4e7fdb --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-impact.ts @@ -0,0 +1,964 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {ControlPort} from '../../../../domain/entities/usecase-data/node/entities/control-port.js'; +import {DataPort} from '../../../../domain/entities/usecase-data/node/entities/data-port.js'; +import {NodeType} from '../../../../domain/entities/usecase-data/node/node.js'; +import {PORT_IO_TYPE} from '../../../../domain/entities/common/enums/port-io-type.js'; +import {SubsystemBoundaryPathService} from '../../../../domain/services/subsystem-data-links/subsystem-boundary-path.service.js'; +import {ChainResolutionService} from '../../../../domain/services/subsystem-data-links/datalink-chain-resolution.service.js'; +import {ControlChainResolutionService} from '../../../../domain/services/subsystem-control-links/control-chain-resolution.service.js'; +import {SubsystemDataLink} from '../../../../domain/entities/usecase-data/links/subsystem-data-link.js'; +import {SubsystemControlLink} from '../../../../domain/entities/usecase-data/links/subsystem-control-link.js'; +import type {DataLink} from '../../../../domain/entities/usecase-data/links/data-link.js'; +import type {ControlLink} from '../../../../domain/entities/usecase-data/links/control-link.js'; +import type {IdGenerationPort} from '../../../ports/id-generation/id-generation.port.js'; +import type {DataLinkRepository} from '../../../ports/persistence/repositories/data-link/data-link.repository.js'; +import type {ControlLinkRepository} from '../../../ports/persistence/repositories/control-link/control-link.repository.js'; +import type { + SubsystemNodeTopology, + SubsystemRepository, +} from '../../../ports/persistence/repositories/subsystem/subsystem.repository.js'; + +export type MoveComponent = { + systemId: number; + parentSystemId: number | null; +}; + +export type SubsystemPortChange = { + systemId: number; + addedDataPorts: DataPort[]; + removedDataPorts: number[]; + addedControlPorts: ControlPort[]; + removedControlPorts: number[]; +}; + +export type MoveSubsystemImpact = { + addedDataLinks: DataLink[]; + removedDataLinks: number[]; + addedControlLinks: ControlLink[]; + removedControlLinks: number[]; + subsystemPortChanges: SubsystemPortChange[]; +}; + +type MoveImpactDependencies = { + subsystemRepository: SubsystemRepository; + dataLinkRepository: DataLinkRepository; + controlLinkRepository: ControlLinkRepository; + idGeneration: IdGenerationPort; +}; + +type SubsystemState = NonNullable< + Awaited> +>; + +type UnresolvedDataRebuild = { + retainedSegments: SubsystemDataLink[]; + replacedSegments: SubsystemDataLink[]; +}; + +type UnresolvedControlRebuild = { + retainedSegments: SubsystemControlLink[]; + replacedSegments: SubsystemControlLink[]; +}; + +type RouteState = { + nodeSequence: number[]; + requiredPortType: Map< + number, + typeof PORT_IO_TYPE.OutputInput | typeof PORT_IO_TYPE.InputOutput + >; +}; + +type DataChain = { + ids: number[]; + sourceNodeSystemId: number; + destinationNodeSystemId: number; +}; + +type ControlChain = { + ids: number[]; + sourceNodeSystemId: number; + destinationNodeSystemId: number | undefined; +}; + +type DataRouteChange = { + link: DataLink; + oldSegments: SubsystemDataLink[]; + newSegments: SubsystemDataLink[]; +}; + +type ControlRouteChange = { + link: ControlLink; + oldSegments: SubsystemControlLink[]; + newSegments: SubsystemControlLink[]; +}; + +function collectMovedNodeIds( + topology: readonly SubsystemNodeTopology[], + parentBefore: ReadonlyMap, + updatedModules: readonly MoveComponent[], + updatedSubsystems: readonly MoveComponent[], +): Set { + const movedSubsystemIds = new Set( + updatedSubsystems.map(component => component.systemId), + ); + const movedNodeIds = new Set([ + ...updatedModules.map(component => component.systemId), + ...movedSubsystemIds, + ]); + + for (const node of topology) { + let parentId = parentBefore.get(node.systemId) ?? null; + const visited = new Set(); + while (parentId !== null && !visited.has(parentId)) { + if (movedSubsystemIds.has(parentId)) { + movedNodeIds.add(node.systemId); + break; + } + visited.add(parentId); + parentId = parentBefore.get(parentId) ?? null; + } + } + + return movedNodeIds; +} + +function dataPortByNode( + segments: readonly SubsystemDataLink[], +): Map { + const result = new Map(); + for (const segment of segments) { + result.set(segment.sourceNodeSystemId, segment.sourcePortSystemId); + result.set( + segment.destinationNodeSystemId, + segment.destinationPortSystemId, + ); + } + return result; +} + +function controlPortByNode( + segments: readonly SubsystemControlLink[], +): Map { + const result = new Map(); + for (const segment of segments) { + result.set(segment.peerNodeASystemId, segment.nodeAPortSystemId); + result.set(segment.peerNodeBSystemId, segment.nodeBPortSystemId); + } + return result; +} + +function endpointPort(segment: SubsystemControlLink, nodeId: number): number { + return segment.peerNodeASystemId === nodeId + ? segment.nodeAPortSystemId + : segment.nodeBPortSystemId; +} + +function dataChainTouchesMovedNode( + chain: DataChain, + byId: ReadonlyMap, + movedNodeIds: ReadonlySet, +): boolean { + return chain.ids.some(id => { + const segment = byId.get(id); + return ( + segment !== undefined && + (movedNodeIds.has(segment.sourceNodeSystemId) || + movedNodeIds.has(segment.destinationNodeSystemId)) + ); + }); +} + +async function prepareDataPorts( + fileSystemId: number, + route: RouteState, + oldPorts: ReadonlyMap, + subsystemStates: Map, + changes: Map, + dependencies: MoveImpactDependencies, +): Promise> { + const ports = new Map(); + for (const nodeSystemId of route.nodeSequence.slice(1, -1)) { + const subsystem = subsystemStates.get(nodeSystemId); + if (!subsystem) continue; + const existingPortId = oldPorts.get(nodeSystemId); + const port = + existingPortId === undefined + ? new DataPort({ + systemId: await dependencies.idGeneration.getNextId(fileSystemId), + naturalId: nextPortId(subsystem.dataPorts), + portIoType: + route.requiredPortType.get(nodeSystemId) ?? + PORT_IO_TYPE.OutputInput, + isStatic: false, + name: '', + }) + : (subsystem.dataPorts.find(item => item.systemId === existingPortId) ?? + new DataPort({ + systemId: existingPortId, + naturalId: nextPortId(subsystem.dataPorts), + portIoType: + route.requiredPortType.get(nodeSystemId) ?? + PORT_IO_TYPE.OutputInput, + isStatic: false, + name: '', + })); + if (existingPortId === undefined) { + subsystem.dataPorts.push(port); + getOrCreatePortChange(changes, nodeSystemId).addedDataPorts.push(port); + await dependencies.subsystemRepository.addDataPort(port, nodeSystemId); + } + ports.set(nodeSystemId, port); + } + return ports; +} + +async function createUnresolvedDataSegments( + fileSystemId: number, + route: RouteState, + oldSegments: readonly SubsystemDataLink[], + portsByNode: ReadonlyMap, + dependencies: MoveImpactDependencies, +): Promise { + const segments: SubsystemDataLink[] = []; + for (let index = 0; index < route.nodeSequence.length - 1; index++) { + const sourceNodeSystemId = route.nodeSequence[index]; + const destinationNodeSystemId = route.nodeSequence[index + 1]; + segments.push( + new SubsystemDataLink({ + systemId: await dependencies.idGeneration.getNextId(fileSystemId), + sourceNodeSystemId, + destinationNodeSystemId, + sourcePortSystemId: + index === 0 + ? oldSegments[0].sourcePortSystemId + : portsByNode.get(sourceNodeSystemId)!.systemId, + destinationPortSystemId: + index === route.nodeSequence.length - 2 + ? oldSegments.at(-1)!.destinationPortSystemId + : portsByNode.get(destinationNodeSystemId)!.systemId, + dataLinkSystemId: null, + fileSystemId, + }), + ); + } + return segments; +} + +async function rebuildDataChain( + fileSystemId: number, + chain: DataChain, + byId: ReadonlyMap, + rebuilt: ReadonlySet, + movedNodeIds: ReadonlySet, + parentBefore: Map, + parentAfter: Map, + subsystemStates: Map, + changes: Map, + dependencies: MoveImpactDependencies, +): Promise<{ + oldSegments: SubsystemDataLink[]; + newSegments: SubsystemDataLink[]; +} | null> { + if ( + chain.ids.length === 0 || + chain.ids.some(id => rebuilt.has(id)) || + !dataChainTouchesMovedNode(chain, byId, movedNodeIds) + ) + return null; + const oldSegments = chain.ids + .map(id => byId.get(id)) + .filter((segment): segment is SubsystemDataLink => segment !== undefined); + if (oldSegments.length !== chain.ids.length) return null; + const oldRoute = getRoute( + chain.sourceNodeSystemId, + chain.destinationNodeSystemId, + parentBefore, + ); + const newRoute = getRoute( + chain.sourceNodeSystemId, + chain.destinationNodeSystemId, + parentAfter, + ); + if (routeSignature(oldRoute) === routeSignature(newRoute)) return null; + const portsByNode = await prepareDataPorts( + fileSystemId, + newRoute, + dataPortByNode(oldSegments), + subsystemStates, + changes, + dependencies, + ); + const newSegments = await createUnresolvedDataSegments( + fileSystemId, + newRoute, + oldSegments, + portsByNode, + dependencies, + ); + await dependencies.dataLinkRepository.replaceUnresolvedSubsystemDataLinkSegments( + chain.ids, + newSegments, + fileSystemId, + ); + return {oldSegments, newSegments}; +} + +async function rebuildUnresolvedDataChains( + fileSystemId: number, + parentBefore: Map, + parentAfter: Map, + movedNodeIds: ReadonlySet, + routeContext: Awaited< + ReturnType + >, + subsystemStates: Map, + changes: Map, + dependencies: MoveImpactDependencies, +): Promise { + const unresolved = routeContext.subsystemDataLinks.filter( + segment => segment.dataLinkSystemId === null, + ); + const byId = new Map(unresolved.map(segment => [segment.systemId, segment])); + const resolution = ChainResolutionService.resolve({ + unresolvedSubsystemLinks: unresolved, + nodeTypeMap: new Map(routeContext.nodeTypeBySystemId), + }); + const chains: DataChain[] = [ + ...resolution.completeChains.map(chain => ({ + ids: chain.ssLinkSystemIds, + sourceNodeSystemId: chain.sourceModuleSystemId, + destinationNodeSystemId: chain.destModuleSystemId, + })), + ...resolution.incompleteChains.map(chain => ({ + ids: chain.ssLinkSystemIds, + sourceNodeSystemId: chain.startModuleSystemId, + destinationNodeSystemId: chain.lastReachableNodeSystemId, + })), + ]; + const rebuilt = new Set(); + const retained: SubsystemDataLink[] = []; + const replacedSegments: SubsystemDataLink[] = []; + for (const chain of chains) { + const result = await rebuildDataChain( + fileSystemId, + chain, + byId, + rebuilt, + movedNodeIds, + parentBefore, + parentAfter, + subsystemStates, + changes, + dependencies, + ); + if (!result) continue; + for (const id of chain.ids) rebuilt.add(id); + replacedSegments.push(...result.oldSegments); + retained.push(...result.newSegments); + } + retained.push( + ...unresolved.filter(segment => !rebuilt.has(segment.systemId)), + ); + return {retainedSegments: retained, replacedSegments}; +} + +function controlChainTouchesMovedNode( + chain: ControlChain, + byId: ReadonlyMap, + movedNodeIds: ReadonlySet, +): boolean { + return chain.ids.some(id => { + const segment = byId.get(id); + return ( + segment !== undefined && + (movedNodeIds.has(segment.peerNodeASystemId) || + movedNodeIds.has(segment.peerNodeBSystemId)) + ); + }); +} + +async function prepareControlPorts( + fileSystemId: number, + route: RouteState, + oldPorts: ReadonlyMap, + subsystemStates: Map, + changes: Map, + dependencies: MoveImpactDependencies, +): Promise> { + const ports = new Map(); + for (const nodeSystemId of route.nodeSequence.slice(1, -1)) { + const subsystem = subsystemStates.get(nodeSystemId); + if (!subsystem) continue; + const existingPortId = oldPorts.get(nodeSystemId); + const port = + existingPortId === undefined + ? new ControlPort({ + systemId: await dependencies.idGeneration.getNextId(fileSystemId), + naturalId: nextPortId(subsystem.controlPorts), + isStatic: false, + nodeSystemId, + name: '', + intentSystemIds: [], + }) + : (subsystem.controlPorts.find( + item => item.systemId === existingPortId, + ) ?? + new ControlPort({ + systemId: existingPortId, + naturalId: nextPortId(subsystem.controlPorts), + isStatic: false, + nodeSystemId, + name: '', + intentSystemIds: [], + })); + if (existingPortId === undefined) { + subsystem.controlPorts.push(port); + getOrCreatePortChange(changes, nodeSystemId).addedControlPorts.push(port); + await dependencies.subsystemRepository.addControlPort(port, nodeSystemId); + } + ports.set(nodeSystemId, port); + } + return ports; +} + +async function createUnresolvedControlSegments( + fileSystemId: number, + route: RouteState, + oldSegments: readonly SubsystemControlLink[], + sourceNodeSystemId: number, + destinationNodeSystemId: number, + portsByNode: ReadonlyMap, + dependencies: MoveImpactDependencies, +): Promise { + const firstOldSegment = oldSegments.find(segment => + [segment.peerNodeASystemId, segment.peerNodeBSystemId].includes( + sourceNodeSystemId, + ), + )!; + const lastOldSegment = oldSegments.find(segment => + [segment.peerNodeASystemId, segment.peerNodeBSystemId].includes( + destinationNodeSystemId, + ), + )!; + const segments: SubsystemControlLink[] = []; + for (let index = 0; index < route.nodeSequence.length - 1; index++) { + const peerNodeASystemId = route.nodeSequence[index]; + const peerNodeBSystemId = route.nodeSequence[index + 1]; + segments.push( + new SubsystemControlLink( + await dependencies.idGeneration.getNextId(fileSystemId), + peerNodeASystemId, + peerNodeBSystemId, + index === 0 + ? endpointPort(firstOldSegment, sourceNodeSystemId) + : portsByNode.get(peerNodeASystemId)!.systemId, + index === route.nodeSequence.length - 2 + ? endpointPort(lastOldSegment, destinationNodeSystemId) + : portsByNode.get(peerNodeBSystemId)!.systemId, + null, + fileSystemId, + 0, + ), + ); + } + return segments; +} + +async function rebuildControlChain( + fileSystemId: number, + chain: ControlChain, + byId: ReadonlyMap, + rebuilt: ReadonlySet, + movedNodeIds: ReadonlySet, + parentBefore: Map, + parentAfter: Map, + subsystemStates: Map, + changes: Map, + dependencies: MoveImpactDependencies, +): Promise<{ + oldSegments: SubsystemControlLink[]; + newSegments: SubsystemControlLink[]; +} | null> { + const destinationNodeSystemId = chain.destinationNodeSystemId; + if ( + destinationNodeSystemId === undefined || + chain.ids.length === 0 || + chain.ids.some(id => rebuilt.has(id)) || + !controlChainTouchesMovedNode(chain, byId, movedNodeIds) + ) + return null; + const oldSegments = chain.ids + .map(id => byId.get(id)) + .filter( + (segment): segment is SubsystemControlLink => segment !== undefined, + ); + if (oldSegments.length !== chain.ids.length) return null; + const oldRoute = getRoute( + chain.sourceNodeSystemId, + destinationNodeSystemId, + parentBefore, + ); + const newRoute = getRoute( + chain.sourceNodeSystemId, + destinationNodeSystemId, + parentAfter, + ); + if (routeSignature(oldRoute) === routeSignature(newRoute)) return null; + const portsByNode = await prepareControlPorts( + fileSystemId, + newRoute, + controlPortByNode(oldSegments), + subsystemStates, + changes, + dependencies, + ); + const newSegments = await createUnresolvedControlSegments( + fileSystemId, + newRoute, + oldSegments, + chain.sourceNodeSystemId, + destinationNodeSystemId, + portsByNode, + dependencies, + ); + await dependencies.controlLinkRepository.replaceUnresolvedSubsystemControlLinkSegments( + chain.ids, + newSegments, + fileSystemId, + ); + return {oldSegments, newSegments}; +} + +async function rebuildUnresolvedControlChains( + fileSystemId: number, + parentBefore: Map, + parentAfter: Map, + movedNodeIds: ReadonlySet, + routeContext: Awaited< + ReturnType + >, + subsystemStates: Map, + changes: Map, + dependencies: MoveImpactDependencies, +): Promise { + const unresolved = routeContext.subsystemControlLinks.filter( + segment => segment.controlLinkSystemId === null, + ); + const byId = new Map(unresolved.map(segment => [segment.systemId, segment])); + const resolution = ControlChainResolutionService.resolve({ + unresolvedSubsystemlinks: unresolved, + nodeTypeMap: new Map(routeContext.nodeTypeBySystemId), + }); + const chains: ControlChain[] = [ + ...resolution.completeChains.map(chain => ({ + ids: chain.ssLinksSystemIds, + sourceNodeSystemId: chain.peerAModuleSystemId, + destinationNodeSystemId: chain.peerBModuleSystemId, + })), + ...resolution.incompleteChains.map(chain => ({ + ids: chain.ssLinksSystemIds, + sourceNodeSystemId: chain.reachableNodeIds[0], + destinationNodeSystemId: chain.reachableNodeIds.at(-1), + })), + ]; + const rebuilt = new Set(); + const retained: SubsystemControlLink[] = []; + const replacedSegments: SubsystemControlLink[] = []; + for (const chain of chains) { + const result = await rebuildControlChain( + fileSystemId, + chain, + byId, + rebuilt, + movedNodeIds, + parentBefore, + parentAfter, + subsystemStates, + changes, + dependencies, + ); + if (!result) continue; + for (const id of chain.ids) rebuilt.add(id); + replacedSegments.push(...result.oldSegments); + retained.push(...result.newSegments); + } + retained.push( + ...unresolved.filter(segment => !rebuilt.has(segment.systemId)), + ); + return {retainedSegments: retained, replacedSegments}; +} + +function routeSignature(route: RouteState): string { + return route.nodeSequence.join(':'); +} + +function getRoute( + sourceNodeId: number, + destinationNodeId: number, + parentByNode: Map, +): RouteState { + const route = SubsystemBoundaryPathService.compute({ + sourceNodeSystemId: sourceNodeId, + destinationNodeSystemId: destinationNodeId, + nodeParentMap: parentByNode, + }); + const nodeSequence: number[] = []; + for (const node of route.nodeSequence) { + if (node !== nodeSequence.at(-1)) nodeSequence.push(node); + } + return {...route, nodeSequence}; +} + +function nextPortId(ports: readonly {naturalId: number}[]): number { + let max = 0; + for (const port of ports) max = Math.max(max, port.naturalId); + return max + 1; +} + +function getOrCreatePortChange( + changes: Map, + systemId: number, +): SubsystemPortChange { + const existing = changes.get(systemId); + if (existing) return existing; + const created: SubsystemPortChange = { + systemId, + addedDataPorts: [], + removedDataPorts: [], + addedControlPorts: [], + removedControlPorts: [], + }; + changes.set(systemId, created); + return created; +} + +function collectDataPortIds( + segments: readonly SubsystemDataLink[], + subsystemIds: ReadonlySet, +): Set { + const ids = new Set(); + for (const segment of segments) { + if (subsystemIds.has(segment.sourceNodeSystemId)) + ids.add(segment.sourcePortSystemId); + if (subsystemIds.has(segment.destinationNodeSystemId)) + ids.add(segment.destinationPortSystemId); + } + return ids; +} + +function collectControlPortIds( + segments: readonly SubsystemControlLink[], + subsystemIds: ReadonlySet, +): Set { + const ids = new Set(); + for (const segment of segments) { + if (subsystemIds.has(segment.peerNodeASystemId)) + ids.add(segment.nodeAPortSystemId); + if (subsystemIds.has(segment.peerNodeBSystemId)) + ids.add(segment.nodeBPortSystemId); + } + return ids; +} + +// eslint-disable-next-line sonarjs/cognitive-complexity +export async function rebuildMoveSubsystemImpact( + fileSystemId: number, + topology: SubsystemNodeTopology[], + updatedModules: MoveComponent[], + updatedSubsystems: MoveComponent[], + dependencies: MoveImpactDependencies, +): Promise { + const parentBefore = new Map( + topology.map(node => [node.systemId, node.parentId]), + ); + const parentAfter = new Map(parentBefore); + for (const component of [...updatedModules, ...updatedSubsystems]) { + parentAfter.set(component.systemId, component.parentSystemId); + } + + const subsystemIds = new Set( + topology + .filter(node => node.type === NodeType.Subsystem) + .map(node => node.systemId), + ); + const subsystemStates = new Map< + number, + NonNullable< + Awaited> + > + >(); + await Promise.all( + [...subsystemIds].map(async systemId => { + const subsystem = + await dependencies.subsystemRepository.findSubsystemForPatch( + systemId, + fileSystemId, + ); + if (subsystem) subsystemStates.set(systemId, subsystem); + }), + ); + + const changes = new Map(); + const dataRoutes: DataRouteChange[] = []; + const controlRoutes: ControlRouteChange[] = []; + const [dataLinks, controlLinks, dataRouteContext, controlRouteContext] = + await Promise.all([ + dependencies.dataLinkRepository.findAllWithSegments(fileSystemId), + dependencies.controlLinkRepository.findAllWithSegments(fileSystemId), + dependencies.dataLinkRepository.findSubsystemDataRouteContext( + fileSystemId, + ), + dependencies.controlLinkRepository.findSubsystemControlRouteContext( + fileSystemId, + ), + ]); + const movedNodeIds = collectMovedNodeIds( + topology, + parentBefore, + updatedModules, + updatedSubsystems, + ); + const unresolvedDataRebuild = await rebuildUnresolvedDataChains( + fileSystemId, + parentBefore, + parentAfter, + movedNodeIds, + dataRouteContext, + subsystemStates, + changes, + dependencies, + ); + const unresolvedControlRebuild = await rebuildUnresolvedControlChains( + fileSystemId, + parentBefore, + parentAfter, + movedNodeIds, + controlRouteContext, + subsystemStates, + changes, + dependencies, + ); + + for (const link of dataLinks) { + const oldRoute = getRoute( + link.sourceNodeSystemId, + link.destinationNodeSystemId, + parentBefore, + ); + const newRoute = getRoute( + link.sourceNodeSystemId, + link.destinationNodeSystemId, + parentAfter, + ); + if (routeSignature(oldRoute) === routeSignature(newRoute)) continue; + const newSegments: SubsystemDataLink[] = []; + const portsByNode = new Map(); + for (const nodeSystemId of newRoute.nodeSequence.slice(1, -1)) { + const subsystem = subsystemStates.get(nodeSystemId); + if (!subsystem) continue; + const port = new DataPort({ + systemId: await dependencies.idGeneration.getNextId(fileSystemId), + naturalId: nextPortId(subsystem.dataPorts), + portIoType: + newRoute.requiredPortType.get(nodeSystemId) ?? + PORT_IO_TYPE.OutputInput, + isStatic: false, + name: '', + }); + subsystem.dataPorts.push(port); + portsByNode.set(nodeSystemId, port); + getOrCreatePortChange(changes, nodeSystemId).addedDataPorts.push(port); + await dependencies.subsystemRepository.addDataPort(port, nodeSystemId); + } + for (let index = 0; index < newRoute.nodeSequence.length - 1; index++) { + const sourceNodeSystemId = newRoute.nodeSequence[index]; + const destinationNodeSystemId = newRoute.nodeSequence[index + 1]; + newSegments.push( + new SubsystemDataLink({ + systemId: await dependencies.idGeneration.getNextId(fileSystemId), + sourceNodeSystemId, + destinationNodeSystemId, + sourcePortSystemId: + index === 0 + ? link.sourcePortSystemId + : portsByNode.get(sourceNodeSystemId)!.systemId, + destinationPortSystemId: + index === newRoute.nodeSequence.length - 2 + ? link.destinationPortSystemId + : portsByNode.get(destinationNodeSystemId)!.systemId, + dataLinkSystemId: link.systemId, + fileSystemId, + }), + ); + } + await dependencies.dataLinkRepository.replaceSubsystemDataLinkSegments( + link.systemId, + newSegments, + ); + dataRoutes.push({ + link, + oldSegments: link.subsystemDataLinks, + newSegments, + }); + } + for (const link of controlLinks) { + const oldRoute = getRoute( + link.peerNodeASystemId, + link.peerNodeBSystemId, + parentBefore, + ); + const newRoute = getRoute( + link.peerNodeASystemId, + link.peerNodeBSystemId, + parentAfter, + ); + if (routeSignature(oldRoute) === routeSignature(newRoute)) continue; + const newSegments: SubsystemControlLink[] = []; + const portsByNode = new Map(); + for (const nodeSystemId of newRoute.nodeSequence.slice(1, -1)) { + const subsystem = subsystemStates.get(nodeSystemId); + if (!subsystem) continue; + const port = new ControlPort({ + systemId: await dependencies.idGeneration.getNextId(fileSystemId), + naturalId: nextPortId(subsystem.controlPorts), + isStatic: false, + nodeSystemId, + name: '', + intentSystemIds: [], + }); + subsystem.controlPorts.push(port); + portsByNode.set(nodeSystemId, port); + getOrCreatePortChange(changes, nodeSystemId).addedControlPorts.push(port); + await dependencies.subsystemRepository.addControlPort(port, nodeSystemId); + } + for (let index = 0; index < newRoute.nodeSequence.length - 1; index++) { + const peerNodeASystemId = newRoute.nodeSequence[index]; + const peerNodeBSystemId = newRoute.nodeSequence[index + 1]; + const nodeAPortSystemId = + index === 0 + ? link.nodeAPortSystemId + : portsByNode.get(peerNodeASystemId)!.systemId; + const nodeBPortSystemId = + index === newRoute.nodeSequence.length - 2 + ? link.nodeBPortSystemId + : portsByNode.get(peerNodeBSystemId)!.systemId; + newSegments.push( + new SubsystemControlLink( + await dependencies.idGeneration.getNextId(fileSystemId), + peerNodeASystemId, + peerNodeBSystemId, + nodeAPortSystemId, + nodeBPortSystemId, + link.systemId, + fileSystemId, + 0, + ), + ); + } + await dependencies.controlLinkRepository.replaceSubsystemControlLinkSegments( + link.systemId, + newSegments, + ); + controlRoutes.push({ + link, + oldSegments: link.subsystemControlLinks, + newSegments, + }); + } + const usedDataPorts = new Set(); + for (const route of dataRoutes) { + for (const id of collectDataPortIds(route.newSegments, subsystemIds)) + usedDataPorts.add(id); + } + for (const link of dataLinks) { + if (dataRoutes.some(route => route.link.systemId === link.systemId)) + continue; + for (const id of collectDataPortIds(link.subsystemDataLinks, subsystemIds)) + usedDataPorts.add(id); + } + const usedControlPorts = new Set(); + for (const route of controlRoutes) { + for (const id of collectControlPortIds(route.newSegments, subsystemIds)) + usedControlPorts.add(id); + } + for (const link of controlLinks) { + if (controlRoutes.some(route => route.link.systemId === link.systemId)) + continue; + for (const id of collectControlPortIds( + link.subsystemControlLinks, + subsystemIds, + )) + usedControlPorts.add(id); + } + for (const id of collectDataPortIds( + unresolvedDataRebuild.retainedSegments, + subsystemIds, + )) + usedDataPorts.add(id); + for (const id of collectControlPortIds( + unresolvedControlRebuild.retainedSegments, + subsystemIds, + )) + usedControlPorts.add(id); + + const oldDataSegmentGroups = [ + ...dataRoutes.map(route => route.oldSegments), + unresolvedDataRebuild.replacedSegments, + ]; + for (const segments of oldDataSegmentGroups) { + for (const portId of collectDataPortIds(segments, subsystemIds)) { + if (usedDataPorts.has(portId)) continue; + const owner = [...subsystemStates.values()].find(subsystem => + subsystem.dataPorts.some(port => port.systemId === portId), + ); + const port = owner?.dataPorts.find(item => item.systemId === portId); + if (!owner || !port || port.isStatic) continue; + await dependencies.subsystemRepository.removeDataPort( + portId, + owner.systemId, + ); + getOrCreatePortChange(changes, owner.systemId).removedDataPorts.push( + portId, + ); + } + } + const oldControlSegmentGroups = [ + ...controlRoutes.map(route => route.oldSegments), + unresolvedControlRebuild.replacedSegments, + ]; + for (const segments of oldControlSegmentGroups) { + for (const portId of collectControlPortIds(segments, subsystemIds)) { + if (usedControlPorts.has(portId)) continue; + const owner = [...subsystemStates.values()].find(subsystem => + subsystem.controlPorts.some(port => port.systemId === portId), + ); + const port = owner?.controlPorts.find(item => item.systemId === portId); + if (!owner || !port || port.isStatic) continue; + await dependencies.subsystemRepository.removeControlPort( + portId, + owner.systemId, + ); + getOrCreatePortChange(changes, owner.systemId).removedControlPorts.push( + portId, + ); + } + } + + return { + addedDataLinks: dataRoutes.map(route => route.link), + removedDataLinks: [], + addedControlLinks: controlRoutes.map(route => route.link), + removedControlLinks: [], + subsystemPortChanges: [...changes.values()].filter( + change => + change.addedDataPorts.length > 0 || + change.removedDataPorts.length > 0 || + change.addedControlPorts.length > 0 || + change.removedControlPorts.length > 0, + ), + }; +} diff --git a/packages/core/src/application/usecase-designer/subsystem/patch/patch-subsystem.command.ts b/packages/core/src/application/usecase-designer/subsystem/patch/patch-subsystem.command.ts new file mode 100644 index 000000000..2341256d2 --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/patch/patch-subsystem.command.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {BaseCommand} from '../../../shared/base-command.js'; +import {SESSION_MODE} from '../../../shared/change-vocabulary.js'; +import type {SessionMode} from '../../../shared/change-vocabulary.js'; + +export class PatchSubsystemCommand extends BaseCommand { + static override readonly requiresSession = true; + static override readonly allowedModes: readonly SessionMode[] = [ + SESSION_MODE.Designer, + SESSION_MODE.DiffMerge, + ]; + + constructor( + public readonly subsystemSystemId: number, + public readonly fileSystemId: number, + public readonly name: string | undefined, + public readonly inputDataPortCount: number | undefined, + public readonly outputDataPortCount: number | undefined, + public readonly controlPortCount: number | undefined, + ) { + super(); + } +} diff --git a/packages/core/src/application/usecase-designer/subsystem/patch/patch-subsystem.handler.ts b/packages/core/src/application/usecase-designer/subsystem/patch/patch-subsystem.handler.ts new file mode 100644 index 000000000..a128e8d58 --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/patch/patch-subsystem.handler.ts @@ -0,0 +1,290 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import type {CommandHandler} from '../../../orchestration/cqrs/commands/command-handler.js'; +import type {UnitOfWork} from '../../../ports/persistence/unit-of-work.js'; +import type {IdGenerationPort} from '../../../ports/id-generation/id-generation.port.js'; +import type {Issue} from '../../../../shared/issues/issue.js'; +import { + DomainRuleViolationException, + InvalidOperationException, + ResourceNotFoundException, +} from '../../../../shared/exceptions/index.js'; +import {ISSUE_ENTITY_TYPE} from '../../../../shared/issues/impacted-entity.js'; +import {IssueFactory} from '../../../../shared/issues/factories.js'; +import {PORT_IO_TYPE} from '../../../../domain/entities/common/enums/port-io-type.js'; +import {MODULE_PORT_STRATEGIES} from '../../../../domain/entities/common/enums/module-port-strategy.js'; +import {DataPort} from '../../../../domain/entities/usecase-data/node/entities/data-port.js'; +import {ControlPort} from '../../../../domain/entities/usecase-data/node/entities/control-port.js'; +import {resolvePortCountChange} from '../../shared/resolve-port-count-change.js'; +import { + nextControlPortIds, + nextDataPortIds, +} from '../../../../domain/services/port-id-calculator/port-id-calculator.js'; +import type {PatchSubsystemCommand} from './patch-subsystem.command.js'; +import type {SubsystemPatchReadModel} from '../subsystem-helpers.js'; + +export type PatchSubsystemResult = { + groupId: string; + subsystem: SubsystemPatchReadModel; + issues?: readonly Issue[]; +}; + +export class PatchSubsystemHandler implements CommandHandler< + PatchSubsystemCommand, + PatchSubsystemResult +> { + constructor( + private readonly uow: UnitOfWork, + private readonly idGeneration: IdGenerationPort, + ) {} + + // Port-count changes intentionally keep input/output/control handling together + // so partial-success rollback semantics remain visible at the transaction boundary. + // eslint-disable-next-line sonarjs/cognitive-complexity + async handle(command: PatchSubsystemCommand): Promise { + if ( + command.name === undefined && + command.inputDataPortCount === undefined && + command.outputDataPortCount === undefined && + command.controlPortCount === undefined + ) { + throw new InvalidOperationException( + 'At least one field must be provided.', + ); + } + if (command.name !== undefined && command.name.length > 255) { + throw new InvalidOperationException( + 'Subsystem name must not exceed 255 characters.', + ); + } + + await this.uow.startTransaction(); + try { + const subsystemRepository = this.uow.getSubsystemRepository(); + const subsystem = await subsystemRepository.findSubsystemForPatch( + command.subsystemSystemId, + command.fileSystemId, + ); + if (!subsystem) { + throw new ResourceNotFoundException( + `Subsystem ${command.subsystemSystemId} not found.`, + [ + IssueFactory.notFound( + ISSUE_ENTITY_TYPE.Subsystem, + command.subsystemSystemId, + ), + ], + ); + } + + let succeeded = command.name !== undefined && command.name.trim() === ''; + let updatedName = subsystem.name; + if (command.name !== undefined && command.name.trim() !== '') { + const subsystems = await subsystemRepository.findSubsystems( + command.fileSystemId, + ); + const normalizedName = command.name.toLocaleLowerCase(); + if ( + subsystems.some( + item => + item.systemId !== command.subsystemSystemId && + item.name.toLocaleLowerCase() === normalizedName, + ) + ) { + throw new DomainRuleViolationException([ + IssueFactory.duplicateSubsystemName(command.name), + ]); + } + await subsystemRepository.renameSubsystem( + command.subsystemSystemId, + command.name, + ); + updatedName = command.name; + succeeded = true; + } + + const issues: Issue[] = []; + const updatedDataPorts = subsystem.dataPorts.map(port => ({ + systemId: port.systemId, + portId: port.naturalId, + name: port.name ?? null, + portIoType: port.portIoType, + isStatic: port.isStatic, + totalLinksAtPort: 0, + })); + const updatedControlPorts = subsystem.controlPorts.map(port => ({ + systemId: port.systemId, + portId: port.naturalId, + name: port.name ?? null, + isStatic: port.isStatic, + allocatedIntents: port.intentIds.map((systemId, index) => ({ + systemId, + intentId: port.intentTypeIds[index] ?? 0, + })), + totalLinksAtPort: 0, + })); + const allocatedDataPortIds = new Set( + subsystem.dataPorts.map(port => port.naturalId), + ); + const dataInputs = [ + [PORT_IO_TYPE.Input, command.inputDataPortCount], + [PORT_IO_TYPE.Output, command.outputDataPortCount], + ] as const; + + for (const [direction, requested] of dataInputs) { + if (requested === undefined) continue; + const current = subsystem.dataPorts.filter( + port => port.portIoType === direction, + ); + const links = await this.uow + .getDataLinkRepository() + .getLinksByPortSystemIds( + current.map(port => port.systemId), + command.fileSystemId, + ); + const outcome = resolvePortCountChange( + current, + requested, + Number.MAX_SAFE_INTEGER, + links, + ISSUE_ENTITY_TYPE.DataPort, + command.subsystemSystemId, + ); + if (outcome.kind === 'FAIL') { + issues.push(...outcome.issues); + continue; + } + succeeded = true; + const isInput = direction === PORT_IO_TYPE.Input; + for (const portId of nextDataPortIds( + allocatedDataPortIds, + isInput, + MODULE_PORT_STRATEGIES.SEQUENTIAL, + outcome.data.toAdd, + )) { + const systemId = await this.idGeneration.getNextId( + command.fileSystemId, + ); + await subsystemRepository.addDataPort( + new DataPort({ + systemId, + naturalId: portId, + portIoType: direction, + isStatic: false, + name: '', + }), + command.subsystemSystemId, + ); + allocatedDataPortIds.add(portId); + updatedDataPorts.push({ + systemId, + portId, + name: '', + portIoType: direction, + isStatic: false, + totalLinksAtPort: 0, + }); + } + for (const portSystemId of outcome.data.toRemove) { + await subsystemRepository.removeDataPort( + portSystemId, + command.subsystemSystemId, + ); + const index = updatedDataPorts.findIndex( + port => port.systemId === portSystemId, + ); + if (index !== -1) updatedDataPorts.splice(index, 1); + } + } + + if (command.controlPortCount !== undefined) { + const current = subsystem.controlPorts; + const links = await this.uow + .getControlLinkRepository() + .getLinksByPortSystemIds( + current.map(port => port.systemId), + command.fileSystemId, + ); + const outcome = resolvePortCountChange( + current, + command.controlPortCount, + Number.MAX_SAFE_INTEGER, + links, + ISSUE_ENTITY_TYPE.ControlPort, + command.subsystemSystemId, + ); + if (outcome.kind === 'FAIL') { + issues.push(...outcome.issues); + } else { + succeeded = true; + const allocated = new Set(current.map(port => port.naturalId)); + for (const portId of nextControlPortIds( + allocated, + outcome.data.toAdd, + )) { + const systemId = await this.idGeneration.getNextId( + command.fileSystemId, + ); + await subsystemRepository.addControlPort( + new ControlPort({ + systemId, + naturalId: portId, + isStatic: false, + nodeSystemId: command.subsystemSystemId, + name: '', + intentSystemIds: [], + }), + command.subsystemSystemId, + ); + updatedControlPorts.push({ + systemId, + portId, + name: '', + isStatic: false, + allocatedIntents: [], + totalLinksAtPort: 0, + }); + } + for (const portSystemId of outcome.data.toRemove) { + await subsystemRepository.removeControlPort( + portSystemId, + command.subsystemSystemId, + ); + const index = updatedControlPorts.findIndex( + port => port.systemId === portSystemId, + ); + if (index !== -1) updatedControlPorts.splice(index, 1); + } + } + } + + if (!succeeded) { + throw new DomainRuleViolationException(issues); + } + await this.uow.commit(); + const filteredKeys = await subsystemRepository.findKeyDefinitionsByIds( + subsystem.filteredKeySystemIds, + command.fileSystemId, + ); + return { + groupId: this.uow.getWriteContext().groupId, + subsystem: { + systemId: subsystem.systemId, + naturalId: subsystem.naturalId, + name: updatedName, + parentId: subsystem.parentSystemId, + filteredKeys, + dataPorts: updatedDataPorts, + controlPorts: updatedControlPorts, + }, + ...(issues.length > 0 ? {issues} : {}), + }; + } catch (error) { + if (this.uow.isInTransaction()) await this.uow.rollback(); + throw error; + } + } +} diff --git a/packages/core/src/application/usecase-designer/subsystem/set-filtered-keys/set-subsystem-filtered-keys.command.ts b/packages/core/src/application/usecase-designer/subsystem/set-filtered-keys/set-subsystem-filtered-keys.command.ts new file mode 100644 index 000000000..4227e8d73 --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/set-filtered-keys/set-subsystem-filtered-keys.command.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {BaseCommand} from '../../../shared/base-command.js'; +import {SESSION_MODE} from '../../../shared/change-vocabulary.js'; +import type {SessionMode} from '../../../shared/change-vocabulary.js'; + +export class SetSubsystemFilteredKeysCommand extends BaseCommand { + static override readonly requiresSession = true; + static override readonly allowedModes: readonly SessionMode[] = [ + SESSION_MODE.Designer, + SESSION_MODE.DiffMerge, + ]; + + constructor( + public readonly subsystemSystemId: number, + public readonly fileSystemId: number, + public readonly keySystemIds: number[], + ) { + super(); + } +} diff --git a/packages/core/src/application/usecase-designer/subsystem/set-filtered-keys/set-subsystem-filtered-keys.handler.ts b/packages/core/src/application/usecase-designer/subsystem/set-filtered-keys/set-subsystem-filtered-keys.handler.ts new file mode 100644 index 000000000..d6e820c7d --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/set-filtered-keys/set-subsystem-filtered-keys.handler.ts @@ -0,0 +1,74 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import type {CommandHandler} from '../../../orchestration/cqrs/commands/command-handler.js'; +import type {UnitOfWork} from '../../../ports/persistence/unit-of-work.js'; +import type {SubsystemKeyDefinition} from '../../../ports/persistence/repositories/subsystem/subsystem.repository.js'; +import {ResourceNotFoundException} from '../../../../shared/exceptions/index.js'; +import {ISSUE_ENTITY_TYPE} from '../../../../shared/issues/impacted-entity.js'; +import {IssueFactory} from '../../../../shared/issues/factories.js'; +import type {SetSubsystemFilteredKeysCommand} from './set-subsystem-filtered-keys.command.js'; + +export type SetSubsystemFilteredKeysResult = { + groupId: string; + subsystemSystemId: number; + filteredKeys: SubsystemKeyDefinition[]; +}; + +export class SetSubsystemFilteredKeysHandler implements CommandHandler< + SetSubsystemFilteredKeysCommand, + SetSubsystemFilteredKeysResult +> { + constructor(private readonly uow: UnitOfWork) {} + + async handle( + command: SetSubsystemFilteredKeysCommand, + ): Promise { + await this.uow.startTransaction(); + try { + const subsystemExists = await this.uow + .getSubsystemRepository() + .subsystemExists(command.subsystemSystemId, command.fileSystemId); + if (!subsystemExists) { + throw new ResourceNotFoundException( + `Subsystem ${command.subsystemSystemId} not found.`, + [ + IssueFactory.notFound( + ISSUE_ENTITY_TYPE.Subsystem, + command.subsystemSystemId, + ), + ], + ); + } + + const filteredKeys = await this.uow + .getSubsystemRepository() + .findKeyDefinitionsByIds(command.keySystemIds, command.fileSystemId); + if (filteredKeys.length !== command.keySystemIds.length) { + const found = new Set(filteredKeys.map(key => key.systemId)); + const missing = command.keySystemIds.find(id => !found.has(id)); + if (missing !== undefined) { + throw new ResourceNotFoundException( + `KeyDefinition ${missing} not found.`, + [IssueFactory.notFound(ISSUE_ENTITY_TYPE.KeyDefinition, missing)], + ); + } + } + + await this.uow + .getSubsystemRepository() + .setFilteredKeys(command.subsystemSystemId, command.keySystemIds); + await this.uow.commit(); + return { + groupId: this.uow.getWriteContext().groupId, + subsystemSystemId: command.subsystemSystemId, + filteredKeys, + }; + } catch (error) { + if (this.uow.isInTransaction()) await this.uow.rollback(); + throw error; + } + } +} diff --git a/packages/core/src/application/usecase-designer/subsystem/subsystem-helpers.ts b/packages/core/src/application/usecase-designer/subsystem/subsystem-helpers.ts new file mode 100644 index 000000000..1bb0d6c3f --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/subsystem-helpers.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import type { + SubsystemKeyDefinition, + SubsystemSummary, +} from '../../ports/persistence/repositories/subsystem/subsystem.repository.js'; + +export interface SubsystemPatchReadModel { + readonly systemId: number; + readonly naturalId: number; + readonly name: string; + readonly parentId?: number; + readonly filteredKeys: SubsystemKeyDefinition[]; + readonly dataPorts: Array<{ + readonly systemId: number; + readonly portId: number; + readonly name: string | null; + readonly portIoType: string; + readonly isStatic: boolean; + readonly totalLinksAtPort: number; + }>; + readonly controlPorts: Array<{ + readonly systemId: number; + readonly portId: number; + readonly name: string | null; + readonly isStatic: boolean; + readonly allocatedIntents: Array<{ + readonly systemId: number; + readonly intentId: number; + readonly name?: string; + }>; + readonly totalLinksAtPort: number; + }>; +} + +export function findSubsystem( + subsystems: SubsystemSummary[], + systemId: number, +): SubsystemSummary | null { + return subsystems.find(subsystem => subsystem.systemId === systemId) ?? null; +} + +export function isDescendant( + candidateSystemId: number, + ancestorSystemId: number, + subsystems: SubsystemSummary[], +): boolean { + let current = findSubsystem(subsystems, candidateSystemId); + const visited = new Set(); + + while (current?.parentId !== undefined) { + if (visited.has(current.systemId)) return false; + visited.add(current.systemId); + if (current.parentId === ancestorSystemId) return true; + current = findSubsystem(subsystems, current.parentId); + } + return false; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f7fc96e75..496c3416e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -78,12 +78,21 @@ export type {ControlLinkRepository} from './application/ports/persistence/reposi export type {SubgraphRepository} from './application/ports/persistence/repositories/subgraph/subgraph.repository.js'; export type { SubsystemControlPortRef, + SubsystemKeyDefinition, SubsystemRepository, + SubsystemNodeTopology, + SubsystemSummary, } from './application/ports/persistence/repositories/subsystem/subsystem.repository.js'; // Module write path — commands (LLD2) export {PatchSpfModuleCommand} from './application/usecase-designer/spf-module/patch/patch-spf-module.command.js'; export {CreateModuleCommand} from './application/usecase-designer/spf-module/create-module/create-module.command.js'; export {DeleteSpfModuleCommand} from './application/usecase-designer/spf-module/delete/delete-spf-module.command.js'; +export {CreateSubsystemCommand} from './application/usecase-designer/subsystem/create/create-subsystem.command.js'; +export {DeleteSubsystemCommand} from './application/usecase-designer/subsystem/delete/delete-subsystem.command.js'; +export {PatchSubsystemCommand} from './application/usecase-designer/subsystem/patch/patch-subsystem.command.js'; +export {SetSubsystemFilteredKeysCommand} from './application/usecase-designer/subsystem/set-filtered-keys/set-subsystem-filtered-keys.command.js'; +export {MoveSubsystemComponentsCommand} from './application/usecase-designer/subsystem/move/move-subsystem-components.command.js'; +export type {MoveSubsystemComponentsResult} from './application/usecase-designer/subsystem/move/move-subsystem-components.handler.js'; export { LINK_DELETION_MODE, isLinkDeletionMode, diff --git a/packages/core/src/shared/issues/factories.ts b/packages/core/src/shared/issues/factories.ts index 63049ca94..f5d98e87d 100644 --- a/packages/core/src/shared/issues/factories.ts +++ b/packages/core/src/shared/issues/factories.ts @@ -129,6 +129,92 @@ export const IssueFactory = { }; }, + subsystemNotEmpty(subsystemSystemId: number): Issue { + return { + code: ISSUE_CODE.SS_NOT_EMPTY, + message: 'Subsystem is not empty — remove all children before deleting.', + severity: IssueSeverity.Error, + impactedEntity: { + entityType: ISSUE_ENTITY_TYPE.Subsystem, + systemId: subsystemSystemId, + }, + }; + }, + + duplicateSubsystemName(name: string): Issue { + return { + code: ISSUE_CODE.SS_DUPLICATE_NAME, + message: `Subsystem name '${name}' is already in use.`, + severity: IssueSeverity.Error, + impactedEntity: { + entityType: ISSUE_ENTITY_TYPE.Subsystem, + systemId: 0, + displayName: name, + }, + }; + }, + + circularSubsystemHierarchy( + componentSystemId: number, + targetSystemId: number, + ): Issue { + return { + code: ISSUE_CODE.SS_CIRCULAR_HIERARCHY, + message: `Moving subsystem ${componentSystemId} under ${targetSystemId} would create a circular hierarchy.`, + severity: IssueSeverity.Error, + impactedEntity: { + entityType: ISSUE_ENTITY_TYPE.Subsystem, + systemId: componentSystemId, + }, + }; + }, + + duplicateChildComponent( + componentSystemId: number, + subsystemSystemId: number, + ): Issue { + return { + code: ISSUE_CODE.SS_DUPLICATE_CHILD, + message: `Component ${componentSystemId} is already a child of subsystem ${subsystemSystemId}.`, + severity: IssueSeverity.Error, + impactedEntity: { + entityType: ISSUE_ENTITY_TYPE.Subsystem, + systemId: subsystemSystemId, + }, + }; + }, + + duplicateRootMove( + entityType: IssueEntityType, + componentSystemId: number, + ): Issue { + return { + code: ISSUE_CODE.SS_DUPLICATE_ROOT_MOVE, + message: `Component ${componentSystemId} is already at root and cannot be moved to root.`, + severity: IssueSeverity.Error, + impactedEntity: { + entityType, + systemId: componentSystemId, + }, + }; + }, + + componentInWrongFile( + entityType: IssueEntityType, + componentSystemId: number, + fileSystemId: number, + ): Issue { + return { + code: ISSUE_CODE.ENTITY_WRONG_FILE, + message: `Component ${componentSystemId} does not belong to file ${fileSystemId}.`, + severity: IssueSeverity.Error, + impactedEntity: { + entityType, + systemId: componentSystemId, + }, + }; + }, + portCountExceedsDefinition( portDirection: string, requested: number, diff --git a/packages/core/src/shared/issues/operational-codes.ts b/packages/core/src/shared/issues/operational-codes.ts index f275ce7fe..0ac75a0fa 100644 --- a/packages/core/src/shared/issues/operational-codes.ts +++ b/packages/core/src/shared/issues/operational-codes.ts @@ -11,6 +11,7 @@ */ export const ISSUE_CODE = { ENTITY_NOT_FOUND: 'ENTITY_NOT_FOUND', + ENTITY_WRONG_FILE: 'ENTITY_WRONG_FILE', DB_QUERY_FAILED: 'DB_QUERY_FAILED', PARSE_ERROR: 'PARSE_ERROR', PARAM_PAYLOAD_NOT_FOUND: 'PARAM_PAYLOAD_NOT_FOUND', @@ -29,6 +30,11 @@ export const ISSUE_CODE = { MOD_NO_AVAILABLE_INTENTS: 'ARC-MOD-NO-AVAILABLE-INTENTS', MOD_PORT_COUNT_BELOW_STATIC_MINIMUM: 'ARC-MOD-PORT-COUNT-BELOW-STATIC-MINIMUM', + SS_NOT_EMPTY: 'ARC-SS-NOT-EMPTY', + SS_DUPLICATE_NAME: 'ARC-SS-DUPLICATE-NAME', + SS_CIRCULAR_HIERARCHY: 'ARC-SS-CIRCULAR-HIERARCHY', + SS_DUPLICATE_CHILD: 'ARC-SS-DUPLICATE-CHILD', + SS_DUPLICATE_ROOT_MOVE: 'ARC-SS-DUPLICATE-ROOT-MOVE', } as const; export type IssueCode = (typeof ISSUE_CODE)[keyof typeof ISSUE_CODE]; diff --git a/packages/core/tests/unit/application/usecase-designer/spf-module/patch/resolve-port-count-change.spec.ts b/packages/core/tests/unit/application/usecase-designer/spf-module/patch/resolve-port-count-change.spec.ts index 98f222647..ade1a078e 100644 --- a/packages/core/tests/unit/application/usecase-designer/spf-module/patch/resolve-port-count-change.spec.ts +++ b/packages/core/tests/unit/application/usecase-designer/spf-module/patch/resolve-port-count-change.spec.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {resolvePortCountChange} from '../../../../../../src/application/usecase-designer/spf-module/patch/resolve-port-count-change.js'; +import {resolvePortCountChange} from '../../../../../../src/application/usecase-designer/shared/resolve-port-count-change.js'; import {RESULT_KIND} from '../../../../../../src/application/shared/result/result.js'; import {ISSUE_CODE} from '../../../../../../src/shared/issues/operational-codes.js'; import {ISSUE_ENTITY_TYPE} from '../../../../../../src/shared/issues/impacted-entity.js'; diff --git a/packages/core/tests/unit/application/usecase-designer/subsystem/move/move-subsystem-impact.spec.ts b/packages/core/tests/unit/application/usecase-designer/subsystem/move/move-subsystem-impact.spec.ts new file mode 100644 index 000000000..91cd64170 --- /dev/null +++ b/packages/core/tests/unit/application/usecase-designer/subsystem/move/move-subsystem-impact.spec.ts @@ -0,0 +1,288 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {jest} from '@jest/globals'; +import {DataLink} from '../../../../../../src/domain/entities/usecase-data/links/data-link.js'; +import {LINK_TYPE} from '../../../../../../src/domain/entities/usecase-data/links/link-type.js'; +import {PORT_IO_TYPE} from '../../../../../../src/domain/entities/common/enums/port-io-type.js'; +import {NodeType} from '../../../../../../src/domain/entities/usecase-data/node/node.js'; +import {Subsystem} from '../../../../../../src/domain/entities/usecase-data/subsystem/subsystem.js'; +import {DataPort} from '../../../../../../src/domain/entities/usecase-data/node/entities/data-port.js'; +import {ControlPort} from '../../../../../../src/domain/entities/usecase-data/node/entities/control-port.js'; +import type {ControlLink} from '../../../../../../src/domain/entities/usecase-data/links/control-link.js'; +import {SubsystemControlLink} from '../../../../../../src/domain/entities/usecase-data/links/subsystem-control-link.js'; +import {SubsystemDataLink} from '../../../../../../src/domain/entities/usecase-data/links/subsystem-data-link.js'; +import {rebuildMoveSubsystemImpact} from '../../../../../../src/application/usecase-designer/subsystem/move/move-subsystem-impact.js'; + +describe('rebuildMoveSubsystemImpact', () => { + it('rebuilds a data-link route and reports the new subsystem port', async () => { + const link = new DataLink({ + systemId: 50, + sourceNodeSystemId: 1, + destinationNodeSystemId: 2, + sourcePortSystemId: 101, + destinationPortSystemId: 201, + linkType: LINK_TYPE.IntraUsecase, + sourceSubgraphSystemId: 11, + destSubgraphSystemId: 22, + fileSystemId: 7, + }); + const addedPorts: unknown[] = []; + const replacedSegments: unknown[] = []; + const subsystem = new Subsystem({ + systemId: 10, + fileSystemId: 7, + parentId: undefined, + name: 'S1', + subsystemId: 1, + filteredKeySystemIds: [], + dataPorts: [], + controlPorts: [], + }); + const result = await rebuildMoveSubsystemImpact( + 7, + [ + {systemId: 1, parentId: null, type: NodeType.Module}, + {systemId: 2, parentId: null, type: NodeType.Module}, + {systemId: 10, parentId: null, type: NodeType.Subsystem}, + ], + [{systemId: 1, parentSystemId: 10}], + [], + { + subsystemRepository: { + findSubsystemForPatch: jest.fn().mockResolvedValue(subsystem), + addDataPort: jest.fn().mockImplementation(port => { + addedPorts.push(port); + }), + addControlPort: jest.fn(), + removeDataPort: jest.fn(), + removeControlPort: jest.fn(), + } as never, + dataLinkRepository: { + findAllWithSegments: jest.fn().mockResolvedValue([link]), + findSubsystemDataRouteContext: jest.fn().mockResolvedValue({ + subsystemDataLinks: [], + nodeTypeBySystemId: new Map(), + }), + deleteSubsystemDataLinks: jest.fn(), + replaceSubsystemDataLinkSegments: jest + .fn() + .mockImplementation((_id, segments) => { + replacedSegments.push(segments); + }), + replaceUnresolvedSubsystemDataLinkSegments: jest.fn(), + } as never, + controlLinkRepository: { + findAllWithSegments: jest.fn().mockResolvedValue([] as ControlLink[]), + findSubsystemControlRouteContext: jest.fn().mockResolvedValue({ + subsystemControlLinks: [], + nodeTypeBySystemId: new Map(), + }), + deleteSubsystemControlLinks: jest.fn(), + replaceSubsystemControlLinkSegments: jest.fn(), + replaceUnresolvedSubsystemControlLinkSegments: jest.fn(), + } as never, + idGeneration: { + getNextId: jest + .fn() + .mockResolvedValueOnce(1000) + .mockResolvedValueOnce(1001), + } as never, + }, + ); + + expect(result.addedDataLinks).toEqual([link]); + expect(result.removedDataLinks).toEqual([]); + expect(result.subsystemPortChanges[0]?.systemId).toBe(10); + expect(result.subsystemPortChanges[0]?.addedDataPorts).toHaveLength(1); + expect(addedPorts).toHaveLength(1); + expect(replacedSegments).toHaveLength(1); + expect( + (replacedSegments[0] as Array<{sourceNodeSystemId: number}>)[0], + ).toMatchObject({ + sourceNodeSystemId: 1, + }); + }); + + it('rebuilds affected unresolved data and control chains without touching unrelated chains', async () => { + const unresolvedData = [ + new SubsystemDataLink({ + systemId: 101, + sourceNodeSystemId: 1, + destinationNodeSystemId: 10, + sourcePortSystemId: 1, + destinationPortSystemId: 2, + dataLinkSystemId: null, + fileSystemId: 7, + }), + new SubsystemDataLink({ + systemId: 102, + sourceNodeSystemId: 10, + destinationNodeSystemId: 20, + sourcePortSystemId: 3, + destinationPortSystemId: 4, + dataLinkSystemId: null, + fileSystemId: 7, + }), + new SubsystemDataLink({ + systemId: 103, + sourceNodeSystemId: 20, + destinationNodeSystemId: 40, + sourcePortSystemId: 5, + destinationPortSystemId: 6, + dataLinkSystemId: null, + fileSystemId: 7, + }), + new SubsystemDataLink({ + systemId: 201, + sourceNodeSystemId: 2, + destinationNodeSystemId: 3, + sourcePortSystemId: 7, + destinationPortSystemId: 8, + dataLinkSystemId: null, + fileSystemId: 7, + }), + ]; + const unresolvedControl = [ + new SubsystemControlLink(301, 1, 10, 1, 2, null, 7, 0), + new SubsystemControlLink(302, 10, 20, 3, 4, null, 7, 0), + new SubsystemControlLink(303, 20, 40, 5, 6, null, 7, 0), + new SubsystemControlLink(401, 2, 3, 7, 8, null, 7, 0), + ]; + const replaceDataLinks = jest.fn(); + const replaceControlLinks = jest.fn(); + + await rebuildMoveSubsystemImpact( + 7, + [ + {systemId: 1, parentId: 10, type: NodeType.Module}, + {systemId: 10, parentId: 20, type: NodeType.Subsystem}, + {systemId: 20, parentId: null, type: NodeType.Subsystem}, + {systemId: 2, parentId: 30, type: NodeType.Module}, + {systemId: 3, parentId: 30, type: NodeType.Module}, + {systemId: 30, parentId: null, type: NodeType.Subsystem}, + {systemId: 40, parentId: null, type: NodeType.Subsystem}, + ], + [], + [{systemId: 10, parentSystemId: 40}], + { + subsystemRepository: { + findSubsystemForPatch: jest.fn().mockResolvedValue( + new Subsystem({ + systemId: 10, + fileSystemId: 7, + parentId: 20, + name: 'S1', + subsystemId: 1, + filteredKeySystemIds: [], + dataPorts: [ + new DataPort({ + systemId: 3, + naturalId: 1, + portIoType: PORT_IO_TYPE.OutputInput, + isStatic: false, + name: 'S1 data', + }), + new DataPort({ + systemId: 5, + naturalId: 2, + portIoType: PORT_IO_TYPE.OutputInput, + isStatic: false, + name: 'S2 data', + }), + ], + controlPorts: [ + new ControlPort({ + systemId: 3, + naturalId: 1, + isStatic: false, + nodeSystemId: 10, + name: 'S1 control', + intentSystemIds: [], + }), + new ControlPort({ + systemId: 5, + naturalId: 2, + isStatic: false, + nodeSystemId: 20, + name: 'S2 control', + intentSystemIds: [], + }), + ], + }), + ), + addDataPort: jest.fn(), + addControlPort: jest.fn(), + removeDataPort: jest.fn(), + removeControlPort: jest.fn(), + } as never, + dataLinkRepository: { + findAllWithSegments: jest.fn().mockResolvedValue([]), + findSubsystemDataRouteContext: jest.fn().mockResolvedValue({ + subsystemDataLinks: unresolvedData, + nodeTypeBySystemId: new Map([ + [1, NodeType.Module], + [10, NodeType.Subsystem], + [20, NodeType.Subsystem], + [40, NodeType.Subsystem], + [2, NodeType.Module], + [3, NodeType.Module], + ]), + }), + deleteSubsystemDataLinks: jest.fn(), + replaceUnresolvedSubsystemDataLinkSegments: replaceDataLinks, + replaceSubsystemDataLinkSegments: jest.fn(), + } as never, + controlLinkRepository: { + findAllWithSegments: jest.fn().mockResolvedValue([] as ControlLink[]), + findSubsystemControlRouteContext: jest.fn().mockResolvedValue({ + subsystemControlLinks: unresolvedControl, + nodeTypeBySystemId: new Map([ + [1, NodeType.Module], + [10, NodeType.Subsystem], + [20, NodeType.Subsystem], + [40, NodeType.Subsystem], + [2, NodeType.Module], + [3, NodeType.Module], + ]), + }), + deleteSubsystemControlLinks: jest.fn(), + replaceUnresolvedSubsystemControlLinkSegments: replaceControlLinks, + replaceSubsystemControlLinkSegments: jest.fn(), + } as never, + idGeneration: {getNextId: jest.fn()} as never, + }, + ); + + expect(replaceDataLinks).toHaveBeenCalledWith( + [101, 102, 103], + expect.arrayContaining([ + expect.objectContaining({ + sourceNodeSystemId: 1, + destinationNodeSystemId: 10, + }), + expect.objectContaining({ + sourceNodeSystemId: 10, + destinationNodeSystemId: 40, + }), + ]), + 7, + ); + expect(replaceControlLinks).toHaveBeenCalledWith( + [301, 302, 303], + expect.arrayContaining([ + expect.objectContaining({ + peerNodeASystemId: 1, + peerNodeBSystemId: 10, + }), + expect.objectContaining({ + peerNodeASystemId: 10, + peerNodeBSystemId: 40, + }), + ]), + 7, + ); + }); +}); diff --git a/packages/core/tests/unit/application/usecase-designer/subsystem/subsystem-handlers.spec.ts b/packages/core/tests/unit/application/usecase-designer/subsystem/subsystem-handlers.spec.ts new file mode 100644 index 000000000..3efd7e519 --- /dev/null +++ b/packages/core/tests/unit/application/usecase-designer/subsystem/subsystem-handlers.spec.ts @@ -0,0 +1,944 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {describe, expect, it, jest} from '@jest/globals'; +import { + DataLink, + DataPort, + LINK_TYPE, + PORT_IO_TYPE, + SubsystemDataLink, + Subsystem, +} from '@arc/core'; +import type { + ControlLinkRepository, + DataLinkRepository, + IdGenerationPort, + NaturalIdGenerationPort, + SubsystemRepository, + UnitOfWork, +} from '@arc/core'; +import {CreateSubsystemHandler} from '../../../../../src/application/usecase-designer/subsystem/create/create-subsystem.handler.js'; +import {CreateSubsystemCommand} from '../../../../../src/application/usecase-designer/subsystem/create/create-subsystem.command.js'; +import {DeleteSubsystemHandler} from '../../../../../src/application/usecase-designer/subsystem/delete/delete-subsystem.handler.js'; +import {DeleteSubsystemCommand} from '../../../../../src/application/usecase-designer/subsystem/delete/delete-subsystem.command.js'; +import {MoveSubsystemComponentsHandler} from '../../../../../src/application/usecase-designer/subsystem/move/move-subsystem-components.handler.js'; +import {MoveSubsystemComponentsCommand} from '../../../../../src/application/usecase-designer/subsystem/move/move-subsystem-components.command.js'; +import {PatchSubsystemHandler} from '../../../../../src/application/usecase-designer/subsystem/patch/patch-subsystem.handler.js'; +import {PatchSubsystemCommand} from '../../../../../src/application/usecase-designer/subsystem/patch/patch-subsystem.command.js'; +import {SetSubsystemFilteredKeysHandler} from '../../../../../src/application/usecase-designer/subsystem/set-filtered-keys/set-subsystem-filtered-keys.handler.js'; +import {SetSubsystemFilteredKeysCommand} from '../../../../../src/application/usecase-designer/subsystem/set-filtered-keys/set-subsystem-filtered-keys.command.js'; + +const FILE_ID = 10; +const GROUP_ID = 'test-group'; +const SUBSYSTEM_ID = 100; + +function makeSubsystem(overrides: Partial = {}): Subsystem { + return new Subsystem({ + systemId: SUBSYSTEM_ID, + fileSystemId: FILE_ID, + parentSystemId: undefined, + name: 'Subsystem', + naturalId: 1, + filteredKeySystemIds: [], + dataPorts: [], + controlPorts: [], + ...overrides, + }); +} + +function makeSubsystemRepository( + overrides: Record = {}, +): SubsystemRepository { + return { + findSubsystems: jest.fn().mockResolvedValue([]), + findSubsystemFileSystemId: jest.fn().mockResolvedValue(null), + findNodeTopology: jest.fn().mockResolvedValue([]), + findSubsystemForPatch: jest.fn().mockResolvedValue(makeSubsystem()), + findKeyDefinitionsByIds: jest.fn().mockResolvedValue([]), + subsystemExists: jest.fn().mockResolvedValue(true), + hasSubsystems: jest.fn().mockResolvedValue(true), + clearControlPortIntents: jest.fn(), + createSubsystem: jest.fn(), + deleteSubsystem: jest.fn(), + renameSubsystem: jest.fn(), + setFilteredKeys: jest.fn(), + addDataPort: jest.fn(), + removeDataPort: jest.fn(), + addControlPort: jest.fn(), + removeControlPort: jest.fn(), + updateParentId: jest.fn(), + ...overrides, + } as unknown as SubsystemRepository; +} + +function makeDataLinkRepository( + overrides: Record = {}, +): DataLinkRepository { + return { + getLinksByPortSystemIds: jest.fn().mockResolvedValue([]), + findAllWithSegments: jest.fn().mockResolvedValue([]), + findSubsystemDataRouteContext: jest.fn().mockResolvedValue({ + subsystemDataLinks: [], + nodeTypeBySystemId: new Map(), + }), + deleteSubsystemDataLinks: jest.fn(), + replaceSubsystemDataLinkSegments: jest.fn(), + replaceUnresolvedSubsystemDataLinkSegments: jest.fn(), + ...overrides, + } as unknown as DataLinkRepository; +} + +function makeControlLinkRepository( + overrides: Record = {}, +): ControlLinkRepository { + return { + getLinksByPortSystemIds: jest.fn().mockResolvedValue([]), + findAllWithSegments: jest.fn().mockResolvedValue([]), + findSubsystemControlRouteContext: jest.fn().mockResolvedValue({ + subsystemControlLinks: [], + nodeTypeBySystemId: new Map(), + }), + deleteSubsystemControlLinks: jest.fn(), + replaceSubsystemControlLinkSegments: jest.fn(), + replaceUnresolvedSubsystemControlLinkSegments: jest.fn(), + ...overrides, + } as unknown as ControlLinkRepository; +} + +function makeUow( + options: { + subsystemRepository?: SubsystemRepository; + dataLinkRepository?: DataLinkRepository; + controlLinkRepository?: ControlLinkRepository; + moduleRepository?: Record; + subgraphRepository?: Record; + } = {}, +): UnitOfWork { + return { + startTransaction: jest.fn(), + commit: jest.fn(), + rollback: jest.fn(), + isInTransaction: jest.fn().mockReturnValue(true), + getWriteContext: jest.fn().mockReturnValue({ + session: {sessionId: 1, fileSystemId: FILE_ID, mode: 'DESIGNER'}, + groupId: GROUP_ID, + }), + getSubsystemRepository: jest + .fn() + .mockReturnValue( + options.subsystemRepository ?? makeSubsystemRepository(), + ), + getDataLinkRepository: jest + .fn() + .mockReturnValue(options.dataLinkRepository ?? makeDataLinkRepository()), + getControlLinkRepository: jest + .fn() + .mockReturnValue( + options.controlLinkRepository ?? makeControlLinkRepository(), + ), + getModuleRepository: jest.fn().mockReturnValue({ + findModulesBySubgraphIds: jest.fn().mockResolvedValue([]), + updateParentId: jest.fn(), + ...options.moduleRepository, + }), + getSubgraphRepository: jest.fn().mockReturnValue({ + findSubgraphFileSystemId: jest.fn().mockResolvedValue(null), + subgraphExists: jest.fn().mockResolvedValue(true), + ...options.subgraphRepository, + }), + } as unknown as UnitOfWork; +} + +function makeDataLink( + systemId: number, + sourceNodeSystemId: number, + destinationNodeSystemId: number, + subsystemDataLinks: SubsystemDataLink[] = [], +): DataLink { + return new DataLink({ + systemId, + sourceNodeSystemId, + destinationNodeSystemId, + sourcePortSystemId: systemId + 1000, + destinationPortSystemId: systemId + 2000, + linkType: LINK_TYPE.IntraUsecase, + sourceSubgraphSystemId: 1, + destSubgraphSystemId: 2, + fileSystemId: FILE_ID, + subsystemDataLinks, + }); +} + +function makeIdGeneration(): IdGenerationPort { + return {getNextId: jest.fn().mockResolvedValue(900)}; +} + +function makeNaturalIdGeneration(): NaturalIdGenerationPort { + return { + getNextId: jest.fn().mockReturnValue(7), + } as unknown as NaturalIdGenerationPort; +} + +describe('CreateSubsystemHandler', () => { + it('creates an auto-named root subsystem', async () => { + const repository = makeSubsystemRepository(); + const uow = makeUow({subsystemRepository: repository}); + const handler = new CreateSubsystemHandler( + uow, + makeIdGeneration(), + makeNaturalIdGeneration(), + ); + + const result = await handler.handle( + new CreateSubsystemCommand(FILE_ID, undefined, undefined), + ); + + expect(result).toMatchObject({ + groupId: GROUP_ID, + subsystemSystemId: 900, + naturalId: 7, + name: 'SS_0x00000007', + }); + expect(repository.createSubsystem).toHaveBeenCalledTimes(1); + expect(uow.commit).toHaveBeenCalledTimes(1); + }); + + it('rejects duplicate names', async () => { + const repository = makeSubsystemRepository({ + findSubsystems: jest + .fn() + .mockResolvedValue([{systemId: 1, name: 'Existing'}]), + }); + const uow = makeUow({subsystemRepository: repository}); + const handler = new CreateSubsystemHandler( + uow, + makeIdGeneration(), + makeNaturalIdGeneration(), + ); + + await expect( + handler.handle( + new CreateSubsystemCommand(FILE_ID, 'existing', undefined), + ), + ).rejects.toThrow('already in use'); + expect(uow.rollback).toHaveBeenCalledTimes(1); + }); +}); + +describe('DeleteSubsystemHandler', () => { + it('rejects a subsystem that still has children', async () => { + const repository = makeSubsystemRepository({ + findSubsystems: jest.fn().mockResolvedValue([ + { + systemId: SUBSYSTEM_ID, + naturalId: 1, + name: 'Parent', + parentId: undefined, + subgraphSystemIds: [], + }, + { + systemId: 101, + naturalId: 2, + name: 'Child', + parentId: SUBSYSTEM_ID, + subgraphSystemIds: [], + }, + ]), + }); + const uow = makeUow({subsystemRepository: repository}); + + await expect( + new DeleteSubsystemHandler(uow).handle( + new DeleteSubsystemCommand(SUBSYSTEM_ID, FILE_ID), + ), + ).rejects.toThrow('not empty'); + expect(repository.deleteSubsystem).not.toHaveBeenCalled(); + }); + + it('deletes an empty subsystem and returns its snapshot', async () => { + const repository = makeSubsystemRepository({ + findSubsystems: jest.fn().mockResolvedValue([ + { + systemId: SUBSYSTEM_ID, + naturalId: 1, + name: 'Empty', + parentId: undefined, + subgraphSystemIds: [], + }, + ]), + }); + const uow = makeUow({subsystemRepository: repository}); + + const result = await new DeleteSubsystemHandler(uow).handle( + new DeleteSubsystemCommand(SUBSYSTEM_ID, FILE_ID), + ); + + expect(result.deletedSubsystemSnapshot).toMatchObject({ + systemId: SUBSYSTEM_ID, + naturalId: 1, + name: 'Empty', + }); + expect(repository.deleteSubsystem).toHaveBeenCalledWith(SUBSYSTEM_ID); + }); +}); + +describe('SetSubsystemFilteredKeysHandler', () => { + it('rejects a missing key definition', async () => { + const repository = makeSubsystemRepository({ + findKeyDefinitionsByIds: jest.fn().mockResolvedValue([]), + }); + const uow = makeUow({subsystemRepository: repository}); + + await expect( + new SetSubsystemFilteredKeysHandler(uow).handle( + new SetSubsystemFilteredKeysCommand(SUBSYSTEM_ID, FILE_ID, [500]), + ), + ).rejects.toThrow('KeyDefinition 500 not found'); + expect(repository.setFilteredKeys).not.toHaveBeenCalled(); + }); + + it('sets valid filtered keys and returns them', async () => { + const keys = [{systemId: 500, keyId: 9, name: 'Mode'}]; + const repository = makeSubsystemRepository({ + findKeyDefinitionsByIds: jest.fn().mockResolvedValue(keys), + }); + const uow = makeUow({subsystemRepository: repository}); + + const result = await new SetSubsystemFilteredKeysHandler(uow).handle( + new SetSubsystemFilteredKeysCommand(SUBSYSTEM_ID, FILE_ID, [500]), + ); + + expect(result.filteredKeys).toEqual(keys); + expect(repository.setFilteredKeys).toHaveBeenCalledWith( + SUBSYSTEM_ID, + [500], + ); + }); +}); + +describe('PatchSubsystemHandler', () => { + it('rejects an empty patch', async () => { + const uow = makeUow(); + const handler = new PatchSubsystemHandler(uow, makeIdGeneration()); + + await expect( + handler.handle( + new PatchSubsystemCommand( + SUBSYSTEM_ID, + FILE_ID, + undefined, + undefined, + undefined, + undefined, + ), + ), + ).rejects.toThrow('At least one field must be provided'); + expect(uow.startTransaction).not.toHaveBeenCalled(); + }); + + it('removes the only free port when reducing a count by one', async () => { + const ports = [ + new DataPort({ + systemId: 101, + dataPortId: 1, + portIoType: PORT_IO_TYPE.Input, + isStatic: false, + }), + new DataPort({ + systemId: 102, + dataPortId: 2, + portIoType: PORT_IO_TYPE.Input, + isStatic: false, + }), + new DataPort({ + systemId: 103, + dataPortId: 3, + portIoType: PORT_IO_TYPE.Input, + isStatic: false, + }), + ]; + const repository = makeSubsystemRepository({ + findSubsystemForPatch: jest + .fn() + .mockResolvedValue(makeSubsystem({dataPorts: ports})), + }); + const dataLinks = makeDataLinkRepository({ + getLinksByPortSystemIds: jest.fn().mockResolvedValue([ + {portSystemId: 101, linkSystemId: 700}, + {portSystemId: 103, linkSystemId: 701}, + ]), + }); + const uow = makeUow({ + subsystemRepository: repository, + dataLinkRepository: dataLinks, + }); + + await new PatchSubsystemHandler(uow, makeIdGeneration()).handle( + new PatchSubsystemCommand( + SUBSYSTEM_ID, + FILE_ID, + undefined, + 2, + undefined, + undefined, + ), + ); + + expect(repository.removeDataPort).toHaveBeenCalledWith(102, SUBSYSTEM_ID); + expect(uow.commit).toHaveBeenCalledTimes(1); + }); +}); + +describe('MoveSubsystemComponentsHandler', () => { + it('rejects an empty move request', async () => { + const uow = makeUow(); + + await expect( + new MoveSubsystemComponentsHandler(uow, makeIdGeneration()).handle( + new MoveSubsystemComponentsCommand(FILE_ID, [], [], null), + ), + ).rejects.toThrow('At least one component system ID'); + }); + + it('moves a subsystem and returns empty impact collections when wiring is unchanged', async () => { + const repository = makeSubsystemRepository({ + findSubsystems: jest.fn().mockResolvedValue([ + { + systemId: SUBSYSTEM_ID, + naturalId: 1, + name: 'Source', + parentId: undefined, + subgraphSystemIds: [], + }, + { + systemId: 200, + naturalId: 2, + name: 'Target', + parentId: undefined, + subgraphSystemIds: [], + }, + ]), + findNodeTopology: jest.fn().mockResolvedValue([ + {systemId: SUBSYSTEM_ID, parentId: null, type: 'subsystem'}, + {systemId: 200, parentId: null, type: 'subsystem'}, + ]), + }); + const uow = makeUow({subsystemRepository: repository}); + + const result = await new MoveSubsystemComponentsHandler( + uow, + makeIdGeneration(), + ).handle( + new MoveSubsystemComponentsCommand(FILE_ID, [], [SUBSYSTEM_ID], 200), + ); + + expect(repository.updateParentId).toHaveBeenCalledWith(SUBSYSTEM_ID, 200); + expect(result.addedDataLinks).toEqual([]); + expect(result.removedDataLinks).toEqual([]); + expect(result.addedControlLinks).toEqual([]); + expect(result.subsystemPortChanges).toEqual([]); + }); + + it('rebuilds a module-to-module path when the source module is moved', async () => { + const targetSubsystem = makeSubsystem({systemId: 200}); + const dataLink = makeDataLink(700, 1, 2); + const repository = makeSubsystemRepository({ + findSubsystems: jest.fn().mockResolvedValue([ + { + systemId: 200, + naturalId: 2, + name: 'Target', + parentId: undefined, + subgraphSystemIds: [], + }, + ]), + findNodeTopology: jest.fn().mockResolvedValue([ + {systemId: 1, parentId: null, type: 'module'}, + {systemId: 2, parentId: null, type: 'module'}, + {systemId: 200, parentId: null, type: 'subsystem'}, + ]), + findSubsystemForPatch: jest.fn().mockResolvedValue(targetSubsystem), + }); + const dataLinks = makeDataLinkRepository({ + findAllWithSegments: jest.fn().mockResolvedValue([dataLink]), + }); + const modules = { + findModulesBySubgraphIds: jest.fn().mockResolvedValue([{systemId: 1}]), + updateParentId: jest.fn(), + }; + const uow = makeUow({ + subsystemRepository: repository, + dataLinkRepository: dataLinks, + moduleRepository: modules, + }); + + const result = await new MoveSubsystemComponentsHandler( + uow, + makeIdGeneration(), + ).handle(new MoveSubsystemComponentsCommand(FILE_ID, [11], [], 200)); + + expect(modules.updateParentId).toHaveBeenCalledWith(1, 200); + expect(dataLinks.replaceSubsystemDataLinkSegments).toHaveBeenCalledWith( + dataLink.systemId, + expect.arrayContaining([ + expect.objectContaining({ + sourceNodeSystemId: 1, + destinationNodeSystemId: 200, + }), + expect.objectContaining({ + sourceNodeSystemId: 200, + destinationNodeSystemId: 2, + }), + ]), + ); + expect(result.addedDataLinks).toEqual([dataLink]); + }); + + it('does not rebuild paths when moved modules have no links', async () => { + const repository = makeSubsystemRepository({ + findSubsystems: jest.fn().mockResolvedValue([ + { + systemId: 200, + naturalId: 2, + name: 'Target', + parentId: undefined, + subgraphSystemIds: [], + }, + ]), + findNodeTopology: jest.fn().mockResolvedValue([ + {systemId: 1, parentId: null, type: 'module'}, + {systemId: 2, parentId: null, type: 'module'}, + {systemId: 200, parentId: null, type: 'subsystem'}, + ]), + findSubsystemForPatch: jest + .fn() + .mockResolvedValue(makeSubsystem({systemId: 200})), + }); + const dataLinks = makeDataLinkRepository(); + const modules = { + findModulesBySubgraphIds: jest.fn().mockResolvedValue([{systemId: 1}]), + updateParentId: jest.fn(), + }; + const uow = makeUow({ + subsystemRepository: repository, + dataLinkRepository: dataLinks, + moduleRepository: modules, + }); + + const result = await new MoveSubsystemComponentsHandler( + uow, + makeIdGeneration(), + ).handle(new MoveSubsystemComponentsCommand(FILE_ID, [11], [], 200)); + + expect(modules.updateParentId).toHaveBeenCalledWith(1, 200); + expect(dataLinks.replaceSubsystemDataLinkSegments).not.toHaveBeenCalled(); + expect(result.addedDataLinks).toEqual([]); + expect(result.subsystemPortChanges).toEqual([]); + }); + + it('only rebuilds the connection crossing a moved nested subsystem', async () => { + const subsystems = [ + { + systemId: 101, + naturalId: 1, + name: 'SS1', + parentId: 102, + subgraphSystemIds: [], + }, + { + systemId: 102, + naturalId: 2, + name: 'SS2', + parentId: undefined, + subgraphSystemIds: [], + }, + { + systemId: 103, + naturalId: 3, + name: 'SS3', + parentId: 104, + subgraphSystemIds: [], + }, + { + systemId: 104, + naturalId: 4, + name: 'SS4', + parentId: undefined, + subgraphSystemIds: [], + }, + { + systemId: 105, + naturalId: 5, + name: 'SS5', + parentId: undefined, + subgraphSystemIds: [], + }, + ]; + const movedConnection = makeDataLink(801, 201, 204, [ + new SubsystemDataLink({ + systemId: 901, + sourceNodeSystemId: 201, + destinationNodeSystemId: 101, + sourcePortSystemId: 1801, + destinationPortSystemId: 1901, + dataLinkSystemId: 801, + fileSystemId: FILE_ID, + }), + new SubsystemDataLink({ + systemId: 902, + sourceNodeSystemId: 101, + destinationNodeSystemId: 102, + sourcePortSystemId: 1901, + destinationPortSystemId: 1902, + dataLinkSystemId: 801, + fileSystemId: FILE_ID, + }), + new SubsystemDataLink({ + systemId: 903, + sourceNodeSystemId: 102, + destinationNodeSystemId: 104, + sourcePortSystemId: 1902, + destinationPortSystemId: 1904, + dataLinkSystemId: 801, + fileSystemId: FILE_ID, + }), + new SubsystemDataLink({ + systemId: 904, + sourceNodeSystemId: 104, + destinationNodeSystemId: 204, + sourcePortSystemId: 1904, + destinationPortSystemId: 2804, + dataLinkSystemId: 801, + fileSystemId: FILE_ID, + }), + ]); + const repository = makeSubsystemRepository({ + findSubsystems: jest.fn().mockResolvedValue(subsystems), + findNodeTopology: jest.fn().mockResolvedValue([ + {systemId: 101, parentId: 102, type: 'subsystem'}, + {systemId: 102, parentId: null, type: 'subsystem'}, + {systemId: 103, parentId: 104, type: 'subsystem'}, + {systemId: 104, parentId: null, type: 'subsystem'}, + {systemId: 105, parentId: null, type: 'subsystem'}, + {systemId: 201, parentId: 101, type: 'module'}, + {systemId: 204, parentId: 104, type: 'module'}, + ]), + findSubsystemForPatch: jest + .fn() + .mockImplementation(async (systemId: number) => { + const subsystem = makeSubsystem({systemId}); + if (systemId === 101) { + subsystem.dataPorts.push( + new DataPort({ + systemId: 3, + naturalId: 1, + portIoType: PORT_IO_TYPE.OutputInput, + isStatic: false, + name: 'SS1 data', + }), + ); + } + if (systemId === 102) { + subsystem.dataPorts.push( + new DataPort({ + systemId: 5, + naturalId: 1, + portIoType: PORT_IO_TYPE.OutputInput, + isStatic: false, + name: 'SS2 data', + }), + ); + } + return subsystem; + }), + }); + const dataLinks = makeDataLinkRepository({ + findAllWithSegments: jest.fn().mockResolvedValue([movedConnection]), + }); + const uow = makeUow({ + subsystemRepository: repository, + dataLinkRepository: dataLinks, + }); + + const result = await new MoveSubsystemComponentsHandler( + uow, + makeIdGeneration(), + ).handle(new MoveSubsystemComponentsCommand(FILE_ID, [], [101], 105)); + + expect(repository.updateParentId).toHaveBeenCalledWith(101, 105); + expect(dataLinks.replaceSubsystemDataLinkSegments).toHaveBeenCalledTimes(1); + expect(dataLinks.replaceSubsystemDataLinkSegments).toHaveBeenCalledWith( + movedConnection.systemId, + expect.arrayContaining([ + expect.objectContaining({ + sourceNodeSystemId: 201, + destinationNodeSystemId: 101, + }), + expect.objectContaining({ + sourceNodeSystemId: 101, + destinationNodeSystemId: 105, + }), + expect.objectContaining({ + sourceNodeSystemId: 105, + destinationNodeSystemId: 104, + }), + expect.objectContaining({ + sourceNodeSystemId: 104, + destinationNodeSystemId: 204, + }), + ]), + ); + expect(result.addedDataLinks).toEqual([movedConnection]); + }); + + it('moves an unresolved chain ending at a subsystem when its source subsystem moves', async () => { + const subsystems = [ + { + systemId: 101, + naturalId: 1, + name: 'SS1', + parentId: 102, + subgraphSystemIds: [], + }, + { + systemId: 102, + naturalId: 2, + name: 'SS2', + parentId: undefined, + subgraphSystemIds: [], + }, + { + systemId: 104, + naturalId: 4, + name: 'SS4', + parentId: undefined, + subgraphSystemIds: [], + }, + { + systemId: 105, + naturalId: 5, + name: 'SS5', + parentId: undefined, + subgraphSystemIds: [], + }, + ]; + const unresolvedSegments = [ + new SubsystemDataLink({ + systemId: 901, + sourceNodeSystemId: 201, + destinationNodeSystemId: 101, + sourcePortSystemId: 1, + destinationPortSystemId: 2, + dataLinkSystemId: null, + fileSystemId: FILE_ID, + }), + new SubsystemDataLink({ + systemId: 902, + sourceNodeSystemId: 101, + destinationNodeSystemId: 102, + sourcePortSystemId: 3, + destinationPortSystemId: 4, + dataLinkSystemId: null, + fileSystemId: FILE_ID, + }), + new SubsystemDataLink({ + systemId: 903, + sourceNodeSystemId: 102, + destinationNodeSystemId: 104, + sourcePortSystemId: 5, + destinationPortSystemId: 6, + dataLinkSystemId: null, + fileSystemId: FILE_ID, + }), + ]; + const repository = makeSubsystemRepository({ + findSubsystems: jest.fn().mockResolvedValue(subsystems), + findNodeTopology: jest.fn().mockResolvedValue([ + {systemId: 201, parentId: 101, type: 'module'}, + {systemId: 101, parentId: 102, type: 'subsystem'}, + {systemId: 102, parentId: null, type: 'subsystem'}, + {systemId: 104, parentId: null, type: 'subsystem'}, + {systemId: 105, parentId: null, type: 'subsystem'}, + ]), + findSubsystemForPatch: jest + .fn() + .mockImplementation(async (systemId: number) => { + const subsystem = makeSubsystem({systemId}); + if (systemId === 101) { + subsystem.dataPorts.push( + new DataPort({ + systemId: 3, + naturalId: 1, + portIoType: PORT_IO_TYPE.OutputInput, + isStatic: false, + name: 'SS1 data', + }), + ); + } + if (systemId === 102) { + subsystem.dataPorts.push( + new DataPort({ + systemId: 5, + naturalId: 1, + portIoType: PORT_IO_TYPE.OutputInput, + isStatic: false, + name: 'SS2 data', + }), + ); + } + return subsystem; + }), + }); + const dataLinks = makeDataLinkRepository({ + findSubsystemDataRouteContext: jest.fn().mockResolvedValue({ + subsystemDataLinks: unresolvedSegments, + nodeTypeBySystemId: new Map([ + [201, 'module'], + [101, 'subsystem'], + [102, 'subsystem'], + [104, 'subsystem'], + [105, 'subsystem'], + ]), + }), + }); + const uow = makeUow({ + subsystemRepository: repository, + dataLinkRepository: dataLinks, + }); + + const result = await new MoveSubsystemComponentsHandler( + uow, + makeIdGeneration(), + ).handle(new MoveSubsystemComponentsCommand(FILE_ID, [], [101], 105)); + + expect(repository.updateParentId).toHaveBeenCalledWith(101, 105); + expect( + dataLinks.replaceUnresolvedSubsystemDataLinkSegments, + ).toHaveBeenCalledWith( + [901, 902, 903], + expect.arrayContaining([ + expect.objectContaining({ + sourceNodeSystemId: 201, + destinationNodeSystemId: 101, + }), + expect.objectContaining({ + sourceNodeSystemId: 101, + destinationNodeSystemId: 105, + }), + expect.objectContaining({ + sourceNodeSystemId: 105, + destinationNodeSystemId: 104, + }), + ]), + FILE_ID, + ); + expect(dataLinks.deleteSubsystemDataLinks).not.toHaveBeenCalled(); + expect(dataLinks.replaceSubsystemDataLinkSegments).not.toHaveBeenCalled(); + expect(result.addedDataLinks).toEqual([]); + }); + + it('partially moves valid subsystems and reports root-to-root no-ops', async () => { + const repository = makeSubsystemRepository({ + findSubsystems: jest.fn().mockResolvedValue([ + { + systemId: 100, + naturalId: 1, + name: 'AlreadyRoot', + parentId: undefined, + subgraphSystemIds: [], + }, + { + systemId: 101, + naturalId: 2, + name: 'Nested', + parentId: 200, + subgraphSystemIds: [], + }, + { + systemId: 200, + naturalId: 3, + name: 'Parent', + parentId: undefined, + subgraphSystemIds: [], + }, + ]), + findNodeTopology: jest.fn().mockResolvedValue([ + {systemId: 100, parentId: null, type: 'subsystem'}, + {systemId: 101, parentId: 200, type: 'subsystem'}, + {systemId: 200, parentId: null, type: 'subsystem'}, + ]), + findSubsystemForPatch: jest + .fn() + .mockImplementation(async (systemId: number) => + makeSubsystem({systemId}), + ), + }); + const uow = makeUow({subsystemRepository: repository}); + + const result = await new MoveSubsystemComponentsHandler( + uow, + makeIdGeneration(), + ).handle(new MoveSubsystemComponentsCommand(FILE_ID, [], [100, 101], null)); + + expect(repository.updateParentId).toHaveBeenCalledWith(101, null); + expect(repository.updateParentId).not.toHaveBeenCalledWith(100, null); + expect(result.updatedSubsystems).toEqual([ + {systemId: 101, parentSystemId: null}, + ]); + expect(result.issues).toHaveLength(1); + expect(result.issues?.[0]?.code).toBe('ARC-SS-DUPLICATE-ROOT-MOVE'); + expect(uow.commit).toHaveBeenCalled(); + }); + + it('rejects an all-invalid root-to-root move', async () => { + const repository = makeSubsystemRepository({ + findSubsystems: jest.fn().mockResolvedValue([ + { + systemId: 100, + naturalId: 1, + name: 'AlreadyRoot', + parentId: undefined, + subgraphSystemIds: [], + }, + ]), + findNodeTopology: jest + .fn() + .mockResolvedValue([ + {systemId: 100, parentId: null, type: 'subsystem'}, + ]), + }); + const uow = makeUow({subsystemRepository: repository}); + + await expect( + new MoveSubsystemComponentsHandler(uow, makeIdGeneration()).handle( + new MoveSubsystemComponentsCommand(FILE_ID, [], [100], null), + ), + ).rejects.toMatchObject({ + issues: expect.arrayContaining([ + expect.objectContaining({code: 'ARC-SS-DUPLICATE-ROOT-MOVE'}), + ]), + }); + expect(uow.rollback).toHaveBeenCalled(); + expect(uow.commit).not.toHaveBeenCalled(); + }); + + it('reports a component from another file as a domain violation', async () => { + const repository = makeSubsystemRepository({ + findSubsystemFileSystemId: jest.fn().mockResolvedValue(99), + }); + const uow = makeUow({subsystemRepository: repository}); + + await expect( + new MoveSubsystemComponentsHandler(uow, makeIdGeneration()).handle( + new MoveSubsystemComponentsCommand(FILE_ID, [], [777], null), + ), + ).rejects.toMatchObject({ + issues: expect.arrayContaining([ + expect.objectContaining({code: 'ENTITY_WRONG_FILE'}), + ]), + }); + }); +}); diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/entity-schema/entity-table-names.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/entity-schema/entity-table-names.ts index 7ee2fe866..bc24736ab 100644 --- a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/entity-schema/entity-table-names.ts +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/entity-schema/entity-table-names.ts @@ -79,6 +79,7 @@ export const ENTITY_NAMES = { // ── Subsystem / UseCase ─────────────────────────────────────────────────── Subsystem: 'Subsystem', + SubsystemFilteredKey: 'SubsystemFilteredKey', UseCase: 'UseCase', UseCaseCategory: 'UseCaseCategory', UsecaseGkvValues: 'UsecaseGkvValues', diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/fetchers/node-overlay-fetcher.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/fetchers/node-overlay-fetcher.ts index bc2a6a3ce..58af6cea2 100644 --- a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/fetchers/node-overlay-fetcher.ts +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/fetchers/node-overlay-fetcher.ts @@ -68,4 +68,26 @@ export class NodeOverlayFetcher { }) .map(r => r.effective); } + + async fetchAll( + fileSystemId: number, + sessionId: number | null, + ): Promise { + const baseRows = (await this.manager + .getRepository(ENTITY_NAMES.Node) + .createQueryBuilder('n') + .select(['n.systemId', 'n.parentSystemId', 'n.type', 'n.fileSystemId']) + .where('n.fileSystemId = :fileSystemId', {fileSystemId}) + .getMany()) as NodeBase[]; + + if (sessionId === null) return baseRows; + + const actions = await this.editActionsSvc.getByTable( + sessionId, + ENTITY_NAMES.Node, + ); + return actions.length === 0 + ? baseRows + : this.overlay.applyToCollection(baseRows, actions).map(r => r.effective); + } } diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/fetchers/subsystem-overlay-fetcher.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/fetchers/subsystem-overlay-fetcher.ts index b4fe2d6b6..520676b17 100644 --- a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/fetchers/subsystem-overlay-fetcher.ts +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/fetchers/subsystem-overlay-fetcher.ts @@ -11,11 +11,16 @@ import { NODE_TYPE, type NodeBase, } from '../entity-schema/usecase-data/node/node.schema.js'; -import type {SubsystemBase} from '../entity-schema/usecase-data/subsystem/subsystem.js'; +import type { + SubsystemBase, + SubsystemFilteredKeyRow, +} from '../entity-schema/usecase-data/subsystem/subsystem.js'; export interface OverlaidSubsystem extends SubsystemBase { /** parentSystemId from Node.parentSystemId — undefined when the subsystem is a root. */ parentSystemId: number | undefined; + /** Effective filtered key-definition IDs for the subsystem. */ + filteredKeySystemIds: number[]; } /** @@ -56,6 +61,24 @@ export class SubsystemOverlayFetcher { .where('n.fileSystemId = :fileSystemId', {fileSystemId}) .getRawAndEntities(); + const keyRows = await this.manager + .getRepository(ENTITY_NAMES.SubsystemFilteredKey) + .createQueryBuilder('fk') + .innerJoin( + ENTITY_NAMES.Node, + 'n', + 'n.system_id = fk.subsystems_system_id', + ) + .where('n.file_system_id = :fileSystemId', {fileSystemId}) + .getMany(); + const filteredKeyIdsBySubsystem = new Map(); + for (const keyRow of keyRows) { + const ids = + filteredKeyIdsBySubsystem.get(keyRow.subsystemsSystemId) ?? []; + ids.push(keyRow.keyDefinitionSystemId); + filteredKeyIdsBySubsystem.set(keyRow.subsystemsSystemId, ids); + } + // Build parentSystemId lookup from the JOIN result. const parentSystemIdBySystemId = new Map( rawRows.raw.map((r: Record) => [ @@ -64,7 +87,14 @@ export class SubsystemOverlayFetcher { ]), ); - let rows = rawRows.entities as SubsystemBase[]; + const subsystemRows = rawRows.entities as SubsystemBase[]; + let rows: Array = + subsystemRows.map(row => ({ + ...row, + filteredKeySystemIds: [ + ...(filteredKeyIdsBySubsystem.get(row.systemId) ?? []), + ], + })); if (sessionId === null) { return this.buildResult(rows, parentSystemIdBySystemId); @@ -107,12 +137,13 @@ export class SubsystemOverlayFetcher { } private buildResult( - rows: SubsystemBase[], + rows: Array, parentSystemIdBySystemId: Map, ): OverlaidSubsystem[] { return rows.map(row => ({ ...row, parentSystemId: parentSystemIdBySystemId.get(row.systemId), + filteredKeySystemIds: row.filteredKeySystemIds ?? [], })); } } diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/subsystem/db-subsystem-query-service.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/subsystem/db-subsystem-query-service.ts index 3e63218e5..1edb2db1e 100644 --- a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/subsystem/db-subsystem-query-service.ts +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/subsystem/db-subsystem-query-service.ts @@ -14,6 +14,7 @@ import {Result, IssueFactory} from '@arc/core'; import {resolveActiveSessionId} from '../shared/session-resolver.js'; import {UseCaseQueryMappers} from '../usecase/usecase-query-mappers.js'; import {SubsystemOverlayFetcher} from '../../fetchers/subsystem-overlay-fetcher.js'; +import {PortOverlayFetcher} from '../../fetchers/port-overlay-fetcher.js'; import type {ControlLinkBase} from '../../entity-schema/usecase-data/Links/control-link.js'; import type {DataLinkBase} from '../../entity-schema/usecase-data/Links/data-link.js'; import type {UsecaseOverlayFetcher} from '../../fetchers/usecase-overlay-fetcher.js'; @@ -26,12 +27,19 @@ import type {LinkOverlayFetcher} from '../../fetchers/link-overlay-fetcher.js'; * segments provided by their respective fetchers. */ export class DbSubsystemQueryService implements SubsystemQueryService { + private readonly subsystemFetcher: SubsystemOverlayFetcher; + private readonly portFetcher: PortOverlayFetcher; + constructor( private readonly dataSource: DataSource, - private readonly subsystemFetcher: SubsystemOverlayFetcher, + subsystemFetcher: SubsystemOverlayFetcher, private readonly usecaseFetcher: UsecaseOverlayFetcher, private readonly linkFetcher: LinkOverlayFetcher, - ) {} + portFetcher: PortOverlayFetcher, + ) { + this.subsystemFetcher = subsystemFetcher; + this.portFetcher = portFetcher; + } async findAll(fileSystemId: number): Promise> { try { @@ -44,14 +52,52 @@ export class DbSubsystemQueryService implements SubsystemQueryService { sessionId, ); - return Result.ok( - subsystems.map(s => ({ - systemId: s.systemId, - name: s.name, - parentSystemId: s.parentSystemId, - filteredKeys: [], // TODO: load from SubsystemFilteredKey when filtered-by-subsystem is implemented - })), + const data = await Promise.all( + subsystems.map(async s => { + const [dataPorts, controlPorts, childSubgraphs] = await Promise.all([ + this.portFetcher.fetchDataPorts( + s.systemId, + fileSystemId, + sessionId, + ), + this.portFetcher.fetchControlPortsWithIntents( + s.systemId, + fileSystemId, + sessionId, + ), + this.findDirectChildSubgraphIds(s.systemId, fileSystemId), + ]); + return { + systemId: s.systemId, + naturalId: s.subsystemId, + name: s.name, + parentSystemId: s.parentSystemId, + subgraphSystemIds: childSubgraphs, + filteredKeys: [], // TODO: load from SubsystemFilteredKey when filtered-by-subsystem is implemented + dataPorts: dataPorts.map(port => ({ + systemId: port.systemId, + naturalId: port.naturalId, + name: port.name ?? '', + portIoType: port.portIoType, + isStatic: port.isStatic, + totalLinksAtPort: 0, + })), + controlPorts: controlPorts.map(port => ({ + systemId: port.systemId, + naturalId: port.naturalId, + name: port.name ?? '', + isStatic: port.isStatic, + allocatedIntents: port.intents.map(intent => ({ + systemId: intent.systemId, + naturalId: intent.naturalId, + name: '', + })), + totalLinksAtPort: 0, + })), + }; + }), ); + return Result.ok(data); } catch (error) { return Result.fail( IssueFactory.dbError( @@ -61,6 +107,23 @@ export class DbSubsystemQueryService implements SubsystemQueryService { } } + private async findDirectChildSubgraphIds( + subsystemSystemId: number, + fileSystemId: number, + ): Promise { + const rows: Array<{subgraphSystemId: number}> = + await this.dataSource.manager + .createQueryBuilder() + .select('m.subgraph_system_id', 'subgraphSystemId') + .from('nodes', 'n') + .innerJoin('spf_modules', 'm', 'm.system_id = n.system_id') + .where('n.parent_id = :subsystemSystemId', {subsystemSystemId}) + .andWhere('n.file_system_id = :fileSystemId', {fileSystemId}) + .distinct(true) + .getRawMany(); + return rows.map(row => Number(row.subgraphSystemId)); + } + /** * Returns virtual control-link segments from subsystem_control_links for * the given usecases. One endpoint may be a subsystem node rather than a diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/typeorm-query-services.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/typeorm-query-services.ts index fd78348ae..ec676fe47 100644 --- a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/typeorm-query-services.ts +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/typeorm-query-services.ts @@ -49,6 +49,8 @@ import {UseCaseCategoryFetcher} from '../fetchers/usecase-category-fetcher.js'; import {UsecaseGkvValuesFetcher} from '../fetchers/usecase-gkv-values-fetcher.js'; import {UsecaseOverlayFetcher} from '../fetchers/usecase-overlay-fetcher.js'; import {LinkOverlayFetcher} from '../fetchers/link-overlay-fetcher.js'; +import {PortOverlayFetcher} from '../fetchers/port-overlay-fetcher.js'; +import {IntentFetcher} from '../fetchers/intent-fetcher.js'; import {SubgraphOverlayFetcher} from '../fetchers/subgraph-overlay-fetcher.js'; import {SubgraphPropertyDataFetcher} from '../fetchers/subgraph-property-data-fetcher.js'; import {SubgraphSgkvFetcher} from '../fetchers/subgraph-sgkv-fetcher.js'; @@ -130,6 +132,11 @@ export class DbQueryServices implements QueryServices { dataSource.manager, editActionsQueryService, ); + const portOverlayFetcher = new PortOverlayFetcher( + dataSource.manager, + editActionsQueryService, + new IntentFetcher(dataSource.manager, editActionsQueryService), + ); const ckvPayloadFetcher = new CkvParameterPayloadFetcher( dataSource.manager, editActionsQueryService, @@ -266,6 +273,7 @@ export class DbQueryServices implements QueryServices { subsystemOverlayFetcher, usecaseOverlayFetcher, linkOverlayFetcher, + portOverlayFetcher, ); this.logQueryService = logQueryService; diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/control-link/control-link.repository.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/control-link/control-link.repository.ts index d6fdd4a91..13cfd78bb 100644 --- a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/control-link/control-link.repository.ts +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/control-link/control-link.repository.ts @@ -379,6 +379,129 @@ export class TypeOrmControlLinkRepository implements ControlLinkRepository { return rows.map(row => baseToControlLink(row)); } + async findAllWithSegments(fileSystemId: number): Promise { + const sessionId = this.uow.getWriteContext().session.sessionId; + const rows = await this.linkFetcher.loadControlLinkRows( + fileSystemId, + sessionId, + ); + if (rows.length === 0) return []; + const segments = await this.linkFetcher.loadSubsystemControlLinkRows( + fileSystemId, + sessionId, + {controlLinkSystemId: rows.map(row => row.systemId)}, + ); + const segmentsByLink = new Map(); + for (const segment of segments) { + const list = segmentsByLink.get(segment.controlLinkSystemId ?? 0) ?? []; + list.push(baseToSubsystemControlLink(segment)); + segmentsByLink.set(segment.controlLinkSystemId ?? 0, list); + } + return rows.map(row => + baseToControlLink(row, segmentsByLink.get(row.systemId) ?? []), + ); + } + + async replaceSubsystemControlLinkSegments( + controlLinkSystemId: number, + segments: SubsystemControlLink[], + options?: EditOptions, + ): Promise { + const sessionId = this.uow.getWriteContext().session.sessionId; + const current = await this.linkFetcher.loadSubsystemControlLinkRows( + this.uow.getWriteContext().session.fileSystemId, + sessionId, + {controlLinkSystemId}, + ); + const {session, groupId} = this.uow.getWriteContext(); + for (const segment of current) { + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.SubsystemControlLink, + targetSystemId: segment.systemId, + aggregateId: controlLinkSystemId, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + for (const segment of segments) { + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.SubsystemControlLink, + targetSystemId: segment.systemId, + aggregateId: controlLinkSystemId, + payload: { + peerNodeASystemId: segment.peerNodeASystemId, + peerNodeBSystemId: segment.peerNodeBSystemId, + nodeAPortSystemId: segment.nodeAPortSystemId, + nodeBPortSystemId: segment.nodeBPortSystemId, + controlLinkSystemId, + fileSystemId: segment.fileSystemId, + version: segment.version, + }, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + } + + async replaceUnresolvedSubsystemControlLinkSegments( + subsystemLinkSystemIds: number[], + segments: SubsystemControlLink[], + fileSystemId: number, + options?: EditOptions, + ): Promise { + if (subsystemLinkSystemIds.length === 0) return; + const sessionId = this.uow.getWriteContext().session.sessionId; + const current = await this.linkFetcher.loadSubsystemControlLinkRows( + fileSystemId, + sessionId, + {systemId: subsystemLinkSystemIds}, + ); + const {session, groupId} = this.uow.getWriteContext(); + for (const segment of current) { + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.SubsystemControlLink, + targetSystemId: segment.systemId, + aggregateId: segment.systemId, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + for (const segment of segments) { + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.SubsystemControlLink, + targetSystemId: segment.systemId, + aggregateId: segment.systemId, + payload: { + peerNodeASystemId: segment.peerNodeASystemId, + peerNodeBSystemId: segment.peerNodeBSystemId, + nodeAPortSystemId: segment.nodeAPortSystemId, + nodeBPortSystemId: segment.nodeBPortSystemId, + controlLinkSystemId: null, + fileSystemId: segment.fileSystemId, + version: segment.version, + }, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + } + async findChangedInSession( fileSystemId: number, ): Promise> { diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/data-link/data-link.repository.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/data-link/data-link.repository.ts index e7e03e14c..5821d06aa 100644 --- a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/data-link/data-link.repository.ts +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/data-link/data-link.repository.ts @@ -385,6 +385,127 @@ export class TypeOrmDataLinkRepository implements DataLinkRepository { return rows.map(row => baseToDataLink(row)); } + async findAllWithSegments(fileSystemId: number): Promise { + const sessionId = this.uow.getWriteContext().session.sessionId; + const rows = await this.linkFetcher.loadDataLinkRows( + fileSystemId, + sessionId, + ); + if (rows.length === 0) return []; + const segments = await this.linkFetcher.loadSubsystemDataLinkRows( + fileSystemId, + sessionId, + {dataLinkSystemId: rows.map(row => row.systemId)}, + ); + const segmentsByLink = new Map(); + for (const segment of segments) { + const list = segmentsByLink.get(segment.dataLinkSystemId ?? 0) ?? []; + list.push(baseToSubsystemDataLink(segment)); + segmentsByLink.set(segment.dataLinkSystemId ?? 0, list); + } + return rows.map(row => + baseToDataLink(row, segmentsByLink.get(row.systemId) ?? []), + ); + } + + async replaceSubsystemDataLinkSegments( + dataLinkSystemId: number, + segments: SubsystemDataLink[], + options?: EditOptions, + ): Promise { + const sessionId = this.uow.getWriteContext().session.sessionId; + const current = await this.linkFetcher.loadSubsystemDataLinkRows( + this.uow.getWriteContext().session.fileSystemId, + sessionId, + {dataLinkSystemId}, + ); + const {session, groupId} = this.uow.getWriteContext(); + for (const segment of current) { + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.SubsystemDataLink, + targetSystemId: segment.systemId, + aggregateId: dataLinkSystemId, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + for (const segment of segments) { + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.SubsystemDataLink, + targetSystemId: segment.systemId, + aggregateId: dataLinkSystemId, + payload: { + sourceNodeSystemId: segment.sourceNodeSystemId, + destinationNodeSystemId: segment.destinationNodeSystemId, + sourcePortSystemId: segment.sourcePortSystemId, + destinationPortSystemId: segment.destinationPortSystemId, + dataLinkSystemId, + fileSystemId: segment.fileSystemId, + }, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + } + + async replaceUnresolvedSubsystemDataLinkSegments( + subsystemLinkSystemIds: number[], + segments: SubsystemDataLink[], + fileSystemId: number, + options?: EditOptions, + ): Promise { + if (subsystemLinkSystemIds.length === 0) return; + const sessionId = this.uow.getWriteContext().session.sessionId; + const current = await this.linkFetcher.loadSubsystemDataLinkRows( + fileSystemId, + sessionId, + {systemId: subsystemLinkSystemIds}, + ); + const {session, groupId} = this.uow.getWriteContext(); + for (const segment of current) { + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.SubsystemDataLink, + targetSystemId: segment.systemId, + aggregateId: segment.systemId, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + for (const segment of segments) { + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.SubsystemDataLink, + targetSystemId: segment.systemId, + aggregateId: segment.systemId, + payload: { + sourceNodeSystemId: segment.sourceNodeSystemId, + destinationNodeSystemId: segment.destinationNodeSystemId, + sourcePortSystemId: segment.sourcePortSystemId, + destinationPortSystemId: segment.destinationPortSystemId, + dataLinkSystemId: null, + fileSystemId: segment.fileSystemId, + }, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + } + async findChangedInSession( fileSystemId: number, ): Promise> { diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/module/module.repository.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/module/module.repository.ts index 57f707691..269466a6d 100644 --- a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/module/module.repository.ts +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/module/module.repository.ts @@ -434,6 +434,26 @@ export class TypeOrmModuleRepository implements ModuleRepository { ); } + async updateParentId( + moduleSystemId: number, + parentSubsystemSystemId: number | null, + options?: EditOptions, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + await this.writer.writeDelta( + { + targetTable: ENTITY_NAMES.Node, + targetSystemId: moduleSystemId, + aggregateId: moduleSystemId, + delta: {parentSystemId: parentSubsystemSystemId}, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + async createModule(module: SpfModule, options?: EditOptions): Promise { const {session, groupId} = this.uow.getWriteContext(); const fileSystemId = module.fileSystemId; diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subgraph/subgraph.repository.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subgraph/subgraph.repository.ts index cfad8e0da..7fa33094d 100644 --- a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subgraph/subgraph.repository.ts +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subgraph/subgraph.repository.ts @@ -24,7 +24,10 @@ import {SubgraphPropertyDataFetcher} from '../../fetchers/subgraph-property-data import {ValueDefinitionFetcher} from '../../fetchers/definitions/key-value/value-definition-fetcher.js'; import {SubgraphPropertyDefinitionFetcher} from '../../fetchers/definitions/subgraph-property-definition-fetcher.js'; import {EditActionsQueryService} from '../../queries/edit-session/edit-actions-query-service.js'; -import type {SubgraphBase} from '../../entity-schema/usecase-data/subgraph/subgraph.schema.js'; +import type { + SubgraphBase, + SubgraphRow, +} from '../../entity-schema/usecase-data/subgraph/subgraph.schema.js'; import {SubgraphVcpmDataFetcher} from '../../fetchers/subgraph-vcpm-data-fetcher.js'; export class TypeOrmSubgraphRepository implements SubgraphRepository { @@ -59,6 +62,13 @@ export class TypeOrmSubgraphRepository implements SubgraphRepository { this.vcpmDataFetcher = new SubgraphVcpmDataFetcher(manager, editActionsQs); } + async findSubgraphFileSystemId(systemId: number): Promise { + const row = await this.manager + .getRepository(ENTITY_NAMES.Subgraph) + .findOne({where: {systemId}}); + return row?.fileSystemId ?? null; + } + // ── Reads ──────────────────────────────────────────────────────────────────── async subgraphExists( diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subsystem/subsystem.repository.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subsystem/subsystem.repository.ts index 70e1596ed..9f2b92cc2 100644 --- a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subsystem/subsystem.repository.ts +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subsystem/subsystem.repository.ts @@ -4,11 +4,16 @@ */ import type {EntityManager} from 'typeorm'; -import type { - EditOptions, - SubsystemControlPortRef, - SubsystemRepository, - UnitOfWork, +import { + ControlPort, + DataPort, + Subsystem, + type EditOptions, + type SubsystemControlPortRef, + type SubsystemKeyDefinition, + type SubsystemRepository, + type SubsystemSummary, + type UnitOfWork, } from '@arc/core'; import {ENTITY_NAMES} from '../../entity-schema/entity-table-names.js'; import type {PendingChangeWriter} from '../../services/pending-change-writer.js'; @@ -16,12 +21,22 @@ import {EditActionsQueryService} from '../../queries/edit-session/edit-actions-q import {IntentFetcher} from '../../fetchers/intent-fetcher.js'; import {PortOverlayFetcher} from '../../fetchers/port-overlay-fetcher.js'; import {SubsystemOverlayFetcher} from '../../fetchers/subsystem-overlay-fetcher.js'; +import {SpfModuleOverlayFetcher} from '../../fetchers/spf-module-overlay-fetcher.js'; +import {NodeOverlayFetcher} from '../../fetchers/node-overlay-fetcher.js'; +import type {NodeRow} from '../../entity-schema/usecase-data/node/node.schema.js'; +import {KeyValueDefinitionFetcher} from '../../fetchers/definitions/key-value/key-value-definition-fetcher.js'; +import {ValueDefinitionFetcher} from '../../fetchers/definitions/key-value/value-definition-fetcher.js'; export class TypeOrmSubsystemRepository implements SubsystemRepository { private readonly writer: PendingChangeWriter; private readonly uow: UnitOfWork; - private readonly portFetcher: PortOverlayFetcher; - private readonly subsystemFetcher: SubsystemOverlayFetcher; + private readonly editActions: EditActionsQueryService; + private readonly intents: IntentFetcher; + private readonly ports: PortOverlayFetcher; + private readonly subsystems: SubsystemOverlayFetcher; + private readonly modules: SpfModuleOverlayFetcher; + private readonly nodes: NodeOverlayFetcher; + private readonly keyDefinitions: KeyValueDefinitionFetcher; constructor( writer: PendingChangeWriter, @@ -31,43 +46,152 @@ export class TypeOrmSubsystemRepository implements SubsystemRepository { this.writer = writer; this.manager = manager; this.uow = uow; - const editActions = new EditActionsQueryService(this.manager); - this.subsystemFetcher = new SubsystemOverlayFetcher( + this.editActions = new EditActionsQueryService(this.manager); + this.intents = new IntentFetcher(this.manager, this.editActions); + this.ports = new PortOverlayFetcher( this.manager, - editActions, + this.editActions, + this.intents, ); - this.portFetcher = new PortOverlayFetcher( - this.manager, - editActions, - new IntentFetcher(this.manager, editActions), + this.subsystems = new SubsystemOverlayFetcher(manager, this.editActions); + this.modules = new SpfModuleOverlayFetcher(manager, this.editActions); + this.nodes = new NodeOverlayFetcher(manager, this.editActions); + this.keyDefinitions = new KeyValueDefinitionFetcher( + manager, + this.editActions, + new ValueDefinitionFetcher(manager, this.editActions), ); } private readonly manager: EntityManager; - async subsystemExists( + async findSubsystems(fileSystemId: number): Promise { + const sessionId = this.uow.getWriteContext().session.sessionId; + const rows = await this.subsystems.fetchAll(fileSystemId, sessionId); + const modules = await this.modules.fetchMany(fileSystemId, sessionId); + const nodeRows = await this.nodes.fetchMany( + modules.map(module => module.systemId), + fileSystemId, + sessionId, + ); + const parentByNode = new Map( + nodeRows.map(node => [node.systemId, node.parentSystemId]), + ); + const childrenBySubsystem = new Map>(); + for (const module of modules) { + const parentId = parentByNode.get(module.systemId); + if (parentId === undefined) continue; + const subgraphs = childrenBySubsystem.get(parentId) ?? new Set(); + subgraphs.add(module.subgraphSystemId); + childrenBySubsystem.set(parentId, subgraphs); + } + return rows.map(row => ({ + systemId: row.systemId, + naturalId: row.subsystemId ?? 0, + name: row.name, + parentId: row.parentSystemId, + subgraphSystemIds: [ + ...(childrenBySubsystem.get(row.systemId) ?? new Set()), + ], + })); + } + + async findSubsystemFileSystemId(systemId: number): Promise { + const row = await this.manager + .getRepository(ENTITY_NAMES.Node) + .findOne({where: {systemId, type: 'subsystem'}}); + return row?.fileSystemId ?? null; + } + + async findNodeTopology(fileSystemId: number) { + const sessionId = this.uow.getWriteContext().session.sessionId; + const rows = await this.nodes.fetchAll(fileSystemId, sessionId); + return rows.map(row => ({ + systemId: row.systemId, + parentId: row.parentSystemId ?? null, + type: row.type, + })); + } + + async findSubsystemForPatch( systemId: number, fileSystemId: number, - ): Promise { - const count = await this.manager - .createQueryBuilder() - .select('1') - .from(ENTITY_NAMES.Node, 'n') - .where( - 'n.systemId = :systemId AND n.fileSystemId = :fileSystemId AND n.type = :type', - {systemId, fileSystemId, type: 'subsystem'}, - ) - .getCount(); - return count > 0; + ): Promise { + const sessionId = this.uow.getWriteContext().session.sessionId; + const rows = await this.subsystems.fetchAll(fileSystemId, sessionId); + const row = rows.find(subsystem => subsystem.systemId === systemId); + if (!row) return null; + const [dataPorts, controlPorts] = await Promise.all([ + this.ports.fetchDataPorts(systemId, fileSystemId, sessionId), + this.ports.fetchControlPortsWithIntents( + systemId, + fileSystemId, + sessionId, + ), + ]); + return new Subsystem({ + systemId, + fileSystemId, + parentSystemId: row.parentSystemId, + name: row.name, + naturalId: row.subsystemId ?? 0, + filteredKeySystemIds: row.filteredKeySystemIds ?? [], + dataPorts: dataPorts.map( + port => + new DataPort({ + systemId: port.systemId, + naturalId: port.naturalId, + portIoType: port.portIoType, + isStatic: port.isStatic, + name: port.name ?? undefined, + }), + ), + controlPorts: controlPorts.map( + port => + new ControlPort({ + systemId: port.systemId, + naturalId: port.naturalId, + isStatic: port.isStatic, + nodeSystemId: systemId, + name: port.name ?? undefined, + intentSystemIds: port.intents.map(intent => intent.systemId), + intentTypeIds: port.intents.map(intent => intent.naturalId), + }), + ), + }); } - async hasSubsystems(fileSystemId: number): Promise { + async findKeyDefinitionsByIds( + keySystemIds: readonly number[], + fileSystemId: number, + ): Promise { + if (keySystemIds.length === 0) return []; const sessionId = this.uow.getWriteContext().session.sessionId; - const subsystems = await this.subsystemFetcher.fetchAll( + const keys = await this.keyDefinitions.fetchMany( + [...keySystemIds], fileSystemId, sessionId, ); - return subsystems.length > 0; + return keys.map(key => ({ + systemId: key.systemId, + keyId: key.naturalId, + name: key.name, + })); + } + + async subsystemExists( + systemId: number, + fileSystemId: number, + ): Promise { + const sessionId = this.uow.getWriteContext().session.sessionId; + const rows = await this.subsystems.fetchAll(fileSystemId, sessionId); + return rows.some(row => row.systemId === systemId); + } + + async hasSubsystems(fileSystemId: number): Promise { + const sessionId = this.uow.getWriteContext().session.sessionId; + const rows = await this.subsystems.fetchAll(fileSystemId, sessionId); + return rows.length > 0; } async clearControlPortIntents( @@ -77,32 +201,26 @@ export class TypeOrmSubsystemRepository implements SubsystemRepository { ): Promise { if (ports.length === 0) return; const {session, groupId} = this.uow.getWriteContext(); - const subsystemByControlPortId = new Map( - ports.map(port => [port.controlPortSystemId, port.subsystemSystemId]), - ); - const effectiveSubsystems = await this.subsystemFetcher.fetchAll( - fileSystemId, - session.sessionId, + const controlPortSystemIds = new Set( + ports.map(port => port.controlPortSystemId), ); - const effectiveSubsystemIds = new Set( - effectiveSubsystems.map(subsystem => subsystem.systemId), - ); - const controlPorts = await Promise.all( - [...subsystemByControlPortId.entries()] - .filter(([, subsystemSystemId]) => - effectiveSubsystemIds.has(subsystemSystemId), - ) - .map(([controlPortSystemId, subsystemSystemId]) => - this.portFetcher.fetchControlPortsWithIntents( - subsystemSystemId, - fileSystemId, - session.sessionId, - {systemId: controlPortSystemId}, - ), + const subsystemSystemIds = [ + ...new Set(ports.map(port => port.subsystemSystemId)), + ]; + const fetchedRows = await Promise.all( + subsystemSystemIds.map(subsystemSystemId => + this.ports.fetchControlPortsWithIntents( + subsystemSystemId, + fileSystemId, + session.sessionId, ), + ), ); + const rows = fetchedRows + .flat() + .filter(port => controlPortSystemIds.has(port.systemId)); - for (const port of controlPorts.flat()) { + for (const port of rows) { for (const intent of port.intents) { await this.writer.writeDelete( { @@ -118,4 +236,291 @@ export class TypeOrmSubsystemRepository implements SubsystemRepository { } } } + + async createSubsystem( + subsystem: Subsystem, + options?: EditOptions, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + const fileSystemId = subsystem.fileSystemId; + + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.Node, + targetSystemId: subsystem.systemId, + aggregateId: subsystem.systemId, + payload: { + type: 'subsystem', + parentSystemId: subsystem.parentSystemId ?? null, + fileSystemId, + }, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.Subsystem, + targetSystemId: subsystem.systemId, + aggregateId: subsystem.systemId, + payload: { + subsystemId: subsystem.naturalId, + name: subsystem.name, + fileSystemId, + }, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + + for (const port of subsystem.dataPorts) { + await this.addDataPort(port, subsystem.systemId, options); + } + for (const port of subsystem.controlPorts) { + await this.addControlPort(port, subsystem.systemId, options); + } + if (subsystem.filteredKeySystemIds.length > 0) { + await this.setFilteredKeys( + subsystem.systemId, + subsystem.filteredKeySystemIds, + options, + ); + } + } + + async deleteSubsystem( + systemId: number, + options?: EditOptions, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + const sessionId = session.sessionId; + const fileSystemId = session.fileSystemId; + const [dataPorts, controlPorts] = await Promise.all([ + this.ports.fetchDataPorts(systemId, fileSystemId, sessionId), + this.ports.fetchControlPortsWithIntents( + systemId, + fileSystemId, + sessionId, + ), + ]); + + for (const port of controlPorts) { + for (const intent of port.intents) { + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.Intent, + targetSystemId: intent.systemId, + aggregateId: systemId, + ...options, + }, + sessionId, + groupId, + this.manager, + ); + } + } + for (const port of dataPorts) { + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.DataPort, + targetSystemId: port.systemId, + aggregateId: systemId, + ...options, + }, + sessionId, + groupId, + this.manager, + ); + } + for (const port of controlPorts) { + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.ControlPort, + targetSystemId: port.systemId, + aggregateId: systemId, + ...options, + }, + sessionId, + groupId, + this.manager, + ); + } + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.Subsystem, + targetSystemId: systemId, + aggregateId: systemId, + ...options, + }, + sessionId, + groupId, + this.manager, + ); + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.Node, + targetSystemId: systemId, + aggregateId: systemId, + ...options, + }, + sessionId, + groupId, + this.manager, + ); + } + + async renameSubsystem( + systemId: number, + name: string, + options?: EditOptions, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + await this.writer.writeDelta( + { + targetTable: ENTITY_NAMES.Subsystem, + targetSystemId: systemId, + aggregateId: systemId, + delta: {name}, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + + async setFilteredKeys( + systemId: number, + keySystemIds: number[], + options?: EditOptions, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + await this.writer.writeDelta( + { + targetTable: ENTITY_NAMES.Subsystem, + targetSystemId: systemId, + aggregateId: systemId, + delta: {filteredKeySystemIds: keySystemIds}, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + + async addDataPort( + port: DataPort, + subsystemSystemId: number, + options?: EditOptions, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.DataPort, + targetSystemId: port.systemId, + aggregateId: subsystemSystemId, + payload: { + naturalId: port.naturalId, + portIoType: port.portIoType, + isStatic: port.isStatic, + name: port.name ?? '', + nodeSystemId: subsystemSystemId, + fileSystemId: session.fileSystemId, + }, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + + async removeDataPort( + portSystemId: number, + subsystemSystemId: number, + options?: EditOptions, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.DataPort, + targetSystemId: portSystemId, + aggregateId: subsystemSystemId, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + + async addControlPort( + port: ControlPort, + subsystemSystemId: number, + options?: EditOptions, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.ControlPort, + targetSystemId: port.systemId, + aggregateId: subsystemSystemId, + payload: { + naturalId: port.naturalId, + isStatic: port.isStatic, + name: port.name ?? '', + nodeSystemId: subsystemSystemId, + fileSystemId: session.fileSystemId, + }, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + + async removeControlPort( + portSystemId: number, + subsystemSystemId: number, + options?: EditOptions, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.ControlPort, + targetSystemId: portSystemId, + aggregateId: subsystemSystemId, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } + + async updateParentId( + subsystemSystemId: number, + parentSubsystemSystemId: number | null, + options?: EditOptions, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + await this.writer.writeDelta( + { + targetTable: ENTITY_NAMES.Node, + targetSystemId: subsystemSystemId, + aggregateId: subsystemSystemId, + delta: {parentSystemId: parentSubsystemSystemId}, + ...options, + }, + session.sessionId, + groupId, + this.manager, + ); + } } diff --git a/packages/infrastructure/persistence/tests/integration/queries/subsystem/db-subsystem-query-service.spec.ts b/packages/infrastructure/persistence/tests/integration/queries/subsystem/db-subsystem-query-service.spec.ts index 2bdcdf008..8c9112c1e 100644 --- a/packages/infrastructure/persistence/tests/integration/queries/subsystem/db-subsystem-query-service.spec.ts +++ b/packages/infrastructure/persistence/tests/integration/queries/subsystem/db-subsystem-query-service.spec.ts @@ -17,6 +17,8 @@ import {EditActionsQueryService} from '../../../../src/persistence-typeorm-sqlli import {SubsystemOverlayFetcher} from '../../../../src/persistence-typeorm-sqllite/fetchers/subsystem-overlay-fetcher.js'; import {UsecaseOverlayFetcher} from '../../../../src/persistence-typeorm-sqllite/fetchers/usecase-overlay-fetcher.js'; import {LinkOverlayFetcher} from '../../../../src/persistence-typeorm-sqllite/fetchers/link-overlay-fetcher.js'; +import {PortOverlayFetcher} from '../../../../src/persistence-typeorm-sqllite/fetchers/port-overlay-fetcher.js'; +import {IntentFetcher} from '../../../../src/persistence-typeorm-sqllite/fetchers/intent-fetcher.js'; import {ProjectSchema} from '../../../../src/persistence-typeorm-sqllite/entity-schema/project-data/project.schema.js'; import {ArcDbFileSchema} from '../../../../src/persistence-typeorm-sqllite/entity-schema/project-data/arc-db-file.schema.js'; import { @@ -187,6 +189,11 @@ describe('DbSubsystemQueryService segment queries (integration)', () => { new SubsystemOverlayFetcher(ds.manager, editActions), new UsecaseOverlayFetcher(ds.manager, editActions), new LinkOverlayFetcher(ds.manager, editActions), + new PortOverlayFetcher( + ds.manager, + editActions, + new IntentFetcher(ds.manager, editActions), + ), ); }); diff --git a/packages/infrastructure/persistence/tests/integration/repositories/subsystem/subsystem.repository.integration.spec.ts b/packages/infrastructure/persistence/tests/integration/repositories/subsystem/subsystem.repository.integration.spec.ts new file mode 100644 index 000000000..caf7732b2 --- /dev/null +++ b/packages/infrastructure/persistence/tests/integration/repositories/subsystem/subsystem.repository.integration.spec.ts @@ -0,0 +1,283 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import type {DataSource, QueryRunner} from 'typeorm'; +import {DataPort, PORT_IO_TYPE, Subsystem} from '@arc/core'; +import { + SESSION_MODE, + SESSION_STATUS, +} from '../../../../src/persistence-typeorm-sqllite/entity-schema/edit-session/project-session.schema.js'; +import { + setupIntegrationTest, + teardownIntegrationTest, + setupEachTest, + getTestDataSource, + getTestRepository, +} from '../../helpers/test-database-setup.js'; +import {TypeOrmSubsystemRepository} from '../../../../src/persistence-typeorm-sqllite/repositories/subsystem/subsystem.repository.js'; +import {EditActionsQueryService} from '../../../../src/persistence-typeorm-sqllite/queries/edit-session/edit-actions-query-service.js'; +import {PendingChangeWriter} from '../../../../src/persistence-typeorm-sqllite/services/pending-change-writer.js'; +import {PendingChangeCache} from '../../../../src/persistence-typeorm-sqllite/services/pending-change-cache.js'; +import {ENTITY_NAMES} from '../../../../src/persistence-typeorm-sqllite/entity-schema/entity-table-names.js'; +import {EditActionSchema} from '../../../../src/persistence-typeorm-sqllite/entity-schema/edit-session/edit-action.schema.js'; +import {ProjectSchema} from '../../../../src/persistence-typeorm-sqllite/entity-schema/project-data/project.schema.js'; +import {ArcDbFileSchema} from '../../../../src/persistence-typeorm-sqllite/entity-schema/project-data/arc-db-file.schema.js'; +import {ProjectSessionSchema} from '../../../../src/persistence-typeorm-sqllite/entity-schema/edit-session/project-session.schema.js'; +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, + afterEach, +} from '@jest/globals'; + +const FILE_ID = 100; +const ROOT_SUBSYSTEM_ID = 10; +const CHILD_SUBSYSTEM_ID = 11; +const MODULE_ID = 20; +const EXISTING_DATA_PORT_ID = 1000; +const EXISTING_CONTROL_PORT_ID = 1001; + +async function seedProjectAndFile(ds: DataSource) { + await getTestRepository(ProjectSchema).save({ + systemId: 1, + name: 'P', + description: '', + type: 'Offline', + }); + await getTestRepository(ArcDbFileSchema).save({ + systemId: FILE_ID, + projectSystemId: 1, + fileName: 'f.acdb', + description: '', + metadata: '{}', + isTarget: true, + lastReservedId: 0, + }); +} + +async function seedSession(ds: DataSource): Promise { + const row = await getTestRepository(ProjectSessionSchema).save({ + fileSystemId: FILE_ID, + userId: 'u', + clientId: 'c', + sessionMode: SESSION_MODE.Designer, + status: SESSION_STATUS.Active, + endedAt: null, + }); + return row.sessionId; +} + +async function seedSubsystemGraph(ds: DataSource) { + await ds.query( + `INSERT INTO nodes (system_id, type, parent_id, file_system_id) VALUES (?, 'subsystem', NULL, ?)`, + [ROOT_SUBSYSTEM_ID, FILE_ID], + ); + await ds.query( + `INSERT INTO nodes (system_id, type, parent_id, file_system_id) VALUES (?, 'subsystem', ?, ?)`, + [CHILD_SUBSYSTEM_ID, ROOT_SUBSYSTEM_ID, FILE_ID], + ); + await ds.query( + `INSERT INTO nodes (system_id, type, parent_id, file_system_id) VALUES (?, 'module', ?, ?)`, + [MODULE_ID, CHILD_SUBSYSTEM_ID, FILE_ID], + ); + await ds.query( + `INSERT INTO subsystems (system_id, name, subsystem_id) VALUES (?, 'Root', 1)`, + [ROOT_SUBSYSTEM_ID], + ); + await ds.query( + `INSERT INTO subsystems (system_id, name, subsystem_id) VALUES (?, 'Child', 2)`, + [CHILD_SUBSYSTEM_ID], + ); + await ds.query( + `INSERT INTO data_ports (system_id, data_port_id, port_io_type, is_static, name, node_system_id) VALUES (?, 1, 'INPUT_OUTPUT', 0, 'data', ?)`, + [EXISTING_DATA_PORT_ID, ROOT_SUBSYSTEM_ID], + ); + await ds.query( + `INSERT INTO control_ports (system_id, port_id, is_static, node_system_id) VALUES (?, 1, 0, ?)`, + [EXISTING_CONTROL_PORT_ID, ROOT_SUBSYSTEM_ID], + ); +} + +function makeUow(sessionId: number) { + return { + getWriteContext: () => ({ + session: { + sessionId, + fileSystemId: FILE_ID, + mode: SESSION_MODE.Designer, + projectId: '1', + }, + groupId: 'test-group', + }), + } as any; +} + +function makeRepo( + manager: QueryRunner['manager'], + sessionId: number, +): TypeOrmSubsystemRepository { + return new TypeOrmSubsystemRepository( + new PendingChangeWriter( + new EditActionsQueryService(manager), + new PendingChangeCache(), + ), + manager, + makeUow(sessionId), + ); +} + +async function getActiveActions(qr: QueryRunner, sessionId: number) { + return qr.manager + .getRepository(EditActionSchema) + .createQueryBuilder('editAction') + .where('editAction.sessionId = :sessionId', {sessionId}) + .andWhere('editAction.validUntil IS NULL') + .orderBy('editAction.changeId', 'ASC') + .getMany(); +} + +describe('TypeOrmSubsystemRepository (integration)', () => { + let ds: DataSource; + let qr: QueryRunner; + let sessionId: number; + + beforeAll(async () => { + await setupIntegrationTest(); + }); + afterAll(async () => { + await teardownIntegrationTest(); + }); + beforeEach(async () => { + await setupEachTest(); + ds = getTestDataSource(); + await seedProjectAndFile(ds); + await seedSubsystemGraph(ds); + sessionId = await seedSession(ds); + qr = ds.createQueryRunner(); + await qr.connect(); + }); + afterEach(async () => { + await qr.release(); + }); + + it('returns effective subsystem summaries with hierarchy parents', async () => { + const summaries = await makeRepo(qr.manager, sessionId).findSubsystems( + FILE_ID, + ); + + expect(summaries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + systemId: ROOT_SUBSYSTEM_ID, + naturalId: 1, + name: 'Root', + parentId: undefined, + subgraphSystemIds: [], + }), + expect.objectContaining({ + systemId: CHILD_SUBSYSTEM_ID, + naturalId: 2, + name: 'Child', + parentId: ROOT_SUBSYSTEM_ID, + subgraphSystemIds: [], + }), + ]), + ); + }); + + it('returns session-aware node topology', async () => { + const repo = makeRepo(qr.manager, sessionId); + const before = await repo.findNodeTopology(FILE_ID); + expect(before).toEqual( + expect.arrayContaining([ + {systemId: ROOT_SUBSYSTEM_ID, parentId: null, type: 'subsystem'}, + { + systemId: CHILD_SUBSYSTEM_ID, + parentId: ROOT_SUBSYSTEM_ID, + type: 'subsystem', + }, + {systemId: MODULE_ID, parentId: CHILD_SUBSYSTEM_ID, type: 'module'}, + ]), + ); + + await makeWriter(qr.manager).writeDelta( + { + targetTable: ENTITY_NAMES.Node, + targetSystemId: MODULE_ID, + aggregateId: MODULE_ID, + delta: {parentSystemId: ROOT_SUBSYSTEM_ID}, + }, + sessionId, + 'move-group', + qr.manager, + ); + + const after = await repo.findNodeTopology(FILE_ID); + expect(after.find(row => row.systemId === MODULE_ID)?.parentId).toBe( + ROOT_SUBSYSTEM_ID, + ); + }); + + it('loads subsystem ports and applies staged port changes', async () => { + const repo = makeRepo(qr.manager, sessionId); + const subsystem = await repo.findSubsystemForPatch( + ROOT_SUBSYSTEM_ID, + FILE_ID, + ); + expect(subsystem).toBeInstanceOf(Subsystem); + expect(subsystem?.dataPorts).toHaveLength(1); + expect(subsystem?.dataPorts[0].systemId).toBe(EXISTING_DATA_PORT_ID); + expect(subsystem?.controlPorts).toHaveLength(1); + expect(subsystem?.controlPorts[0].systemId).toBe(EXISTING_CONTROL_PORT_ID); + + await repo.addDataPort( + new DataPort({ + systemId: 1002, + dataPortId: 2, + portIoType: PORT_IO_TYPE.OutputInput, + isStatic: false, + name: 'new-data', + }), + ROOT_SUBSYSTEM_ID, + ); + + const updated = await repo.findSubsystemForPatch( + ROOT_SUBSYSTEM_ID, + FILE_ID, + ); + expect(updated?.dataPorts.map(port => port.systemId)).toEqual( + expect.arrayContaining([EXISTING_DATA_PORT_ID, 1002]), + ); + }); + + it('stages parent and port writes in one session group', async () => { + const repo = makeRepo(qr.manager, sessionId); + await repo.updateParentId(MODULE_ID, ROOT_SUBSYSTEM_ID); + await repo.removeDataPort(EXISTING_DATA_PORT_ID, ROOT_SUBSYSTEM_ID); + + const actions = await getActiveActions(qr, sessionId); + expect( + actions.map(action => [action.targetTable, action.targetSystemId]), + ).toEqual( + expect.arrayContaining([ + [ENTITY_NAMES.Node, MODULE_ID], + [ENTITY_NAMES.DataPort, EXISTING_DATA_PORT_ID], + ]), + ); + expect(new Set(actions.map(action => action.groupId))).toEqual( + new Set(['test-group']), + ); + }); +}); + +function makeWriter(manager: QueryRunner['manager']): PendingChangeWriter { + return new PendingChangeWriter( + new EditActionsQueryService(manager), + new PendingChangeCache(), + ); +}