From f5a3be841be9f33647aa0a902fa939803416eebd Mon Sep 17 00:00:00 2001 From: Amit Gupta Date: Wed, 2 Sep 2026 10:44:43 +0530 Subject: [PATCH 1/3] docs(subsystem): add requirements and write API LLD Document subsystem write requirements and CQRS API design. Cover create, delete, patch, filtered-key, and component moves. Signed-off-by: Amit Gupta --- docs/subsystem/design/subsystem-write-lld.md | 885 ++++++++++++++++++ .../requirements/subsystem-requirements.md | 344 +++++++ 2 files changed, 1229 insertions(+) create mode 100644 docs/subsystem/design/subsystem-write-lld.md create mode 100644 docs/subsystem/requirements/subsystem-requirements.md diff --git a/docs/subsystem/design/subsystem-write-lld.md b/docs/subsystem/design/subsystem-write-lld.md new file mode 100644 index 000000000..d91ff922a --- /dev/null +++ b/docs/subsystem/design/subsystem-write-lld.md @@ -0,0 +1,885 @@ + + +# 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; + moveComponents( + subgraphSystemIds: number[], + subsystemSystemIds: number[], + targetSubsystemSystemId: number | null, + options?: EditOptions, + ): Promise; +} +``` + +Command-handler method coverage: + +| Command handler | Repository methods | +|-----------------|--------------------| +| `CreateSubsystemHandler` | `createSubsystem` | +| `DeleteSubsystemHandler` | `deleteSubsystem` | +| `PatchSubsystemHandler` | `renameSubsystem`, `addDataPort`, `removeDataPort`, `addControlPort`, `removeControlPort` | +| `SetSubsystemFilteredKeysHandler` | `setFilteredKeys` | +| `MoveSubsystemComponentsHandler` | `moveComponents` | + +### 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. + +`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. resolvedName = name.trim() === '' + ? `SS_0x${subsystem.subsystemId padded to 8 hex digits}` // reset to default + : name +5b. If resolvedName.length > 255 → throw InvalidOperationException +5c. Validate global case-insensitive uniqueness through the session-aware read service + If taken → throw DomainRuleViolationException (duplicate name, I1) +5d. await repo.renameSubsystem(subsystemSystemId, resolvedName) + +Data port count (inputDataPortCount and/or outputDataPortCount defined): +6. applyDataPortCountChange(PORT_IO_TYPE.Input, inputDataPortCount) + applyDataPortCountChange(PORT_IO_TYPE.Output, outputDataPortCount) + (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)`:** + +``` +current = subsystem.dataPorts filtered by direction +if requested === undefined: return (no-op) +if requested === current.length: return (no-op) + +if requested > current.length: + allPortIds = Set of all subsystem.dataPorts[*].dataPortId (both directions) + for _ in 0..(requested - current.length): + portId = minAvailableId(allPortIds) // smallest positive integer not in set + allPortIds.add(portId) + portSystemId = await idGeneration.getNextId(fileSystemId) + await repo.addDataPort( + new DataPort({ systemId: portSystemId, dataPortId: portId, portIoType: direction, isStatic: false, name: '' }), + subsystemSystemId, + ) + +if requested < current.length: + occupiedIds = await dataLinkRepo.getLinksByPortSystemIds(current.map(p => p.systemId), fileSystemId) + .map(link => link.portSystemId) + unoccupied = current.filter(p => !occupiedIds.has(p.systemId)) + toRemove = current.length - requested + if unoccupied.length < toRemove: + return IssueFactory.occupiedPortCannotBeRemoved(...) // caller records a per-direction issue + candidates = unoccupied.sortBy(p => p.dataPortId descending).slice(0, toRemove) + for each port in candidates: await repo.removeDataPort(port.systemId, subsystemSystemId) +``` + +**Port ID gap-filling:** `minAvailableId(existingIds)` returns the smallest integer ≥ 1 not +present in the set. Input and output data ports share a single ID space per subsystem. + +**`applyControlPortCountChange(requested)`** follows the same pattern using `controlPorts` and +`controlLinkRepo.getLinksByPortSystemIds`. + +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. await repo.moveComponents( + subgraphSystemIds, + subsystemSystemIds, + targetSubsystemSystemId, + ) + +All component re-parenting uses the current write context and the same transaction/group ID. + +Link reconstruction (after all re-parenting): +8. Identify all data links and control links that now cross the new boundary + without passing through a boundary port — these are invalid. +9. Remove each invalid link → DataLinkRepository / ControlLinkRepository (tracked in removedDataLinks / removedControlLinks) +10. For each removed link, construct replacement links that route through the + subsystem's boundary ports — new links are staged via the respective link + repository (tracked in addedDataLinks / addedControlLinks) +11. Any subsystems whose port wiring changed are tracked in subsystemPortChanges + +12. await uow.commit() +13. 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 handler performs +the required re-parenting through `SubsystemEditRepository.moveComponents(...)` 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 the five-command write contracts, including moveComponents + +packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subsystem/ + subsystem.repository.ts ← add write method implementations + +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..5126d9d38 --- /dev/null +++ b/docs/subsystem/requirements/subsystem-requirements.md @@ -0,0 +1,344 @@ +# 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. +To remove a name, pass an empty string — the system resets the name to the auto-generated default (`SS_0x{id:X8}`). +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. \ No newline at end of file From 7c31a848a79c8fa2ce015b29732cd56a7781da3e Mon Sep 17 00:00:00 2001 From: Amit Gupta Date: Thu, 3 Sep 2026 15:51:44 +0530 Subject: [PATCH 2/3] docs(subsystem): address write API review feedback Clarify blank-name behavior, reuse module port helpers, and move component reparenting orchestration into core. Document handling for resolved and unresolved subsystem link segments. Signed-off-by: Amit Gupta --- docs/subsystem/design/subsystem-write-lld.md | 141 +++++++++++------- .../requirements/subsystem-requirements.md | 5 +- 2 files changed, 88 insertions(+), 58 deletions(-) diff --git a/docs/subsystem/design/subsystem-write-lld.md b/docs/subsystem/design/subsystem-write-lld.md index d91ff922a..9511e9d20 100644 --- a/docs/subsystem/design/subsystem-write-lld.md +++ b/docs/subsystem/design/subsystem-write-lld.md @@ -142,15 +142,20 @@ export interface SubsystemEditRepository { removeDataPort(portSystemId: number, subsystemSystemId: number, options?: EditOptions): Promise; addControlPort(port: ControlPort, subsystemSystemId: number, options?: EditOptions): Promise; removeControlPort(portSystemId: number, subsystemSystemId: number, options?: EditOptions): Promise; - moveComponents( - subgraphSystemIds: number[], - subsystemSystemIds: number[], - targetSubsystemSystemId: number | null, + 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 | @@ -159,13 +164,15 @@ Command-handler method coverage: | `DeleteSubsystemHandler` | `deleteSubsystem` | | `PatchSubsystemHandler` | `renameSubsystem`, `addDataPort`, `removeDataPort`, `addControlPort`, `removeControlPort` | | `SetSubsystemFilteredKeysHandler` | `setFilteredKeys` | -| `MoveSubsystemComponentsHandler` | `moveComponents` | +| `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. @@ -346,17 +353,16 @@ export class PatchSubsystemCommand extends BaseCommand { 4. If absent → throw ResourceNotFoundException Rename (if name is defined): -5a. resolvedName = name.trim() === '' - ? `SS_0x${subsystem.subsystemId padded to 8 hex digits}` // reset to default - : name -5b. If resolvedName.length > 255 → throw InvalidOperationException -5c. Validate global case-insensitive uniqueness through the session-aware read service +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. await repo.renameSubsystem(subsystemSystemId, resolvedName) +5d. Otherwise await repo.renameSubsystem(subsystemSystemId, name) Data port count (inputDataPortCount and/or outputDataPortCount defined): -6. applyDataPortCountChange(PORT_IO_TYPE.Input, inputDataPortCount) - applyDataPortCountChange(PORT_IO_TYPE.Output, outputDataPortCount) +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): @@ -377,7 +383,7 @@ direction failed, or `Result.ok({groupId})` when all requested changes succeeded #### Port count change algorithm -**`applyDataPortCountChange(direction, requested)`:** +**`applyDataPortCountChange(direction, requested, allocatedDataPortIds)`:** ``` current = subsystem.dataPorts filtered by direction @@ -385,32 +391,44 @@ if requested === undefined: return (no-op) if requested === current.length: return (no-op) if requested > current.length: - allPortIds = Set of all subsystem.dataPorts[*].dataPortId (both directions) - for _ in 0..(requested - current.length): - portId = minAvailableId(allPortIds) // smallest positive integer not in set - allPortIds.add(portId) + 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: - occupiedIds = await dataLinkRepo.getLinksByPortSystemIds(current.map(p => p.systemId), fileSystemId) - .map(link => link.portSystemId) - unoccupied = current.filter(p => !occupiedIds.has(p.systemId)) - toRemove = current.length - requested - if unoccupied.length < toRemove: - return IssueFactory.occupiedPortCannotBeRemoved(...) // caller records a per-direction issue - candidates = unoccupied.sortBy(p => p.dataPortId descending).slice(0, toRemove) - for each port in candidates: await repo.removeDataPort(port.systemId, subsystemSystemId) -``` - -**Port ID gap-filling:** `minAvailableId(existingIds)` returns the smallest integer ≥ 1 not -present in the set. Input and output data ports share a single ID space per subsystem. - -**`applyControlPortCountChange(requested)`** follows the same pattern using `controlPorts` and -`controlLinkRepo.getLinksByPortSystemIds`. + 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. @@ -518,28 +536,32 @@ For each id in subgraphSystemIds: 6a. id already a direct child of target location → DomainRuleViolationException (duplicate child, I2) After all validations pass: -7. await repo.moveComponents( - subgraphSystemIds, - subsystemSystemIds, - targetSubsystemSystemId, - ) +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. -All component re-parenting uses the current write context and the same transaction/group ID. +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): -8. Identify all data links and control links that now cross the new boundary - without passing through a boundary port — these are invalid. -9. Remove each invalid link → DataLinkRepository / ControlLinkRepository (tracked in removedDataLinks / removedControlLinks) -10. For each removed link, construct replacement links that route through the - subsystem's boundary ports — new links are staged via the respective link - repository (tracked in addedDataLinks / addedControlLinks) -11. Any subsystems whose port wiring changed are tracked in subsystemPortChanges - -12. await uow.commit() -13. return { groupId, updatedModules, updatedSubsystems, - addedDataLinks, removedDataLinks, - addedControlLinks, removedControlLinks, - subsystemPortChanges } +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 @@ -756,8 +778,9 @@ out-of-range unsigned IDs with `BadRequestException`. They are the only HTTP-to- 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 handler performs -the required re-parenting through `SubsystemEditRepository.moveComponents(...)` inside the +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. @@ -853,10 +876,16 @@ packages/api/src/presentation/rest/modules/subsystem/ ``` packages/core/src/application/ports/persistence/repositories/subsystem/ - subsystem.repository.ts ← add the five-command write contracts, including moveComponents + 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 + 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 diff --git a/docs/subsystem/requirements/subsystem-requirements.md b/docs/subsystem/requirements/subsystem-requirements.md index 5126d9d38..d1ddf1bee 100644 --- a/docs/subsystem/requirements/subsystem-requirements.md +++ b/docs/subsystem/requirements/subsystem-requirements.md @@ -122,7 +122,8 @@ Use the move-out operation (FR-SS-08) to relocate children before deleting. #### FR-SS-03: Rename subsystem A subsystem's name can be updated by its system ID. -To remove a name, pass an empty string — the system resets the name to the auto-generated default (`SS_0x{id:X8}`). +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. @@ -341,4 +342,4 @@ constructs new links as needed. child list. **I3 — Occupied port protection:** Ports with at least one active connection (data or control) -cannot be removed individually or via count reduction. \ No newline at end of file +cannot be removed individually or via count reduction. From d72ec41de4508a535c186b540caa15bfe78013b0 Mon Sep 17 00:00:00 2001 From: Amit Gupta Date: Thu, 10 Sep 2026 14:13:07 +0530 Subject: [PATCH 3/3] Add subsystem commands and API coverage Implement repository-backed subsystem create, patch, move, and filtered-key and delete commands with API and persistence support. Add core unit tests and API E2E coverage for validation and lifecycle behavior. Verify core and API unit, integration, and E2E test suites. Signed-off-by: Amit Gupta --- docs/swagger-api.json | 31 +- .../request/create-subsystem-request.dto.ts | 3 +- .../request/patch-subsystem-request.dto.ts | 13 + .../modules/subsystem/subsystem.controller.ts | 354 +++++++++++-- .../e2e/subsystem/subsystem-crud.e2e-spec.ts | 301 +++++++++++ .../registries/command-handler-registry.ts | 31 ++ .../subsystem/subsystem-read-model.ts | 6 + .../control-link/control-link.repository.ts | 8 + .../data-link/data-link.repository.ts | 8 + .../repositories/module/module.repository.ts | 5 + .../subsystem/subsystem.repository.ts | 76 ++- .../resolve-port-count-change.ts | 6 +- .../patch/patch-spf-module.handler.ts | 2 +- .../spf-module/query/spf-module-dto.ts | 4 +- .../create/create-subsystem.command.ts | 24 + .../create/create-subsystem.handler.ts | 117 ++++ .../delete/delete-subsystem.command.ts | 23 + .../delete/delete-subsystem.handler.ts | 84 +++ .../move/move-subsystem-components.command.ts | 25 + .../move/move-subsystem-components.handler.ts | 205 +++++++ .../subsystem/move/move-subsystem-impact.ts | 389 ++++++++++++++ .../patch/patch-subsystem.command.ts | 27 + .../patch/patch-subsystem.handler.ts | 290 ++++++++++ .../set-subsystem-filtered-keys.command.ts | 24 + .../set-subsystem-filtered-keys.handler.ts | 74 +++ .../subsystem/subsystem-helpers.ts | 61 +++ packages/core/src/index.ts | 9 + packages/core/src/shared/issues/factories.ts | 55 ++ .../src/shared/issues/operational-codes.ts | 4 + .../patch/resolve-port-count-change.spec.ts | 2 +- .../move/move-subsystem-impact.spec.ts | 91 ++++ .../subsystem/subsystem-handlers.spec.ts | 398 ++++++++++++++ .../entity-schema/entity-table-names.ts | 1 + .../fetchers/node-overlay-fetcher.ts | 22 + .../fetchers/subsystem-overlay-fetcher.ts | 37 +- .../subsystem/db-subsystem-query-service.ts | 81 ++- .../queries/typeorm-query-services.ts | 8 + .../control-link/control-link.repository.ts | 72 +++ .../data-link/data-link.repository.ts | 71 +++ .../repositories/module/module.repository.ts | 20 + .../subsystem/subsystem.repository.ts | 499 ++++++++++++++++-- .../db-subsystem-query-service.spec.ts | 7 + .../subsystem.repository.integration.spec.ts | 283 ++++++++++ 43 files changed, 3738 insertions(+), 113 deletions(-) create mode 100644 packages/api/tests/e2e/subsystem/subsystem-crud.e2e-spec.ts rename packages/core/src/application/usecase-designer/{spf-module/patch => shared}/resolve-port-count-change.ts (92%) create mode 100644 packages/core/src/application/usecase-designer/subsystem/create/create-subsystem.command.ts create mode 100644 packages/core/src/application/usecase-designer/subsystem/create/create-subsystem.handler.ts create mode 100644 packages/core/src/application/usecase-designer/subsystem/delete/delete-subsystem.command.ts create mode 100644 packages/core/src/application/usecase-designer/subsystem/delete/delete-subsystem.handler.ts create mode 100644 packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-components.command.ts create mode 100644 packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-components.handler.ts create mode 100644 packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-impact.ts create mode 100644 packages/core/src/application/usecase-designer/subsystem/patch/patch-subsystem.command.ts create mode 100644 packages/core/src/application/usecase-designer/subsystem/patch/patch-subsystem.handler.ts create mode 100644 packages/core/src/application/usecase-designer/subsystem/set-filtered-keys/set-subsystem-filtered-keys.command.ts create mode 100644 packages/core/src/application/usecase-designer/subsystem/set-filtered-keys/set-subsystem-filtered-keys.handler.ts create mode 100644 packages/core/src/application/usecase-designer/subsystem/subsystem-helpers.ts create mode 100644 packages/core/tests/unit/application/usecase-designer/subsystem/move/move-subsystem-impact.spec.ts create mode 100644 packages/core/tests/unit/application/usecase-designer/subsystem/subsystem-handlers.spec.ts create mode 100644 packages/infrastructure/persistence/tests/integration/repositories/subsystem/subsystem.repository.integration.spec.ts 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/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..48f78979d 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,52 @@ 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'); + return toApiResult(Result.ok(result), 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 +361,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 +397,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 +436,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 +486,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 +548,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 +583,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..65dba7312 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,14 @@ export interface ControlLinkRepository { */ findIntraUcLinksByFile(fileSystemId: number): Promise; + findAllWithSegments(fileSystemId: number): Promise; + + replaceSubsystemControlLinkSegments( + controlLinkSystemId: number, + segments: SubsystemControlLink[], + 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..70a8fedc1 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,14 @@ export interface DataLinkRepository { */ findIntraUcLinksByFile(fileSystemId: number): Promise; + findAllWithSegments(fileSystemId: number): Promise; + + replaceSubsystemDataLinkSegments( + dataLinkSystemId: number, + segments: SubsystemDataLink[], + 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/subsystem/subsystem.repository.ts b/packages/core/src/application/ports/persistence/repositories/subsystem/subsystem.repository.ts index a6977f76b..324c20815 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,49 @@ * 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; + 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 +54,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..08e157fd3 --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-components.handler.ts @@ -0,0 +1,205 @@ +/* + * 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 {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}>; +} & 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 subsystemIds = new Set(subsystems.map(item => item.systemId)); + for (const systemId of command.subsystemSystemIds) { + if (!subsystemIds.has(systemId)) { + throw new ResourceNotFoundException( + `Subsystem ${systemId} not found.`, + [IssueFactory.notFound(ISSUE_ENTITY_TYPE.Subsystem, systemId)], + ); + } + } + if ( + command.targetSubsystemSystemId !== null && + !subsystemIds.has(command.targetSubsystemSystemId) + ) { + throw new ResourceNotFoundException( + `Subsystem ${command.targetSubsystemSystemId} not found.`, + [ + IssueFactory.notFound( + ISSUE_ENTITY_TYPE.Subsystem, + command.targetSubsystemSystemId, + ), + ], + ); + } + + if (command.targetSubsystemSystemId !== null) { + for (const componentSystemId of command.subsystemSystemIds) { + if ( + componentSystemId === command.targetSubsystemSystemId || + isDescendant( + command.targetSubsystemSystemId, + componentSystemId, + subsystems, + ) + ) { + throw new DomainRuleViolationException([ + IssueFactory.circularSubsystemHierarchy( + componentSystemId, + command.targetSubsystemSystemId, + ), + ]); + } + } + + for (const componentSystemId of command.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 updatedModules: MoveSubsystemComponentsResult['updatedModules'] = + []; + for (const subgraphSystemId of command.subgraphSystemIds) { + if ( + !(await this.uow + .getSubgraphRepository() + .subgraphExists(subgraphSystemId, command.fileSystemId)) + ) { + throw new ResourceNotFoundException( + `Subgraph ${subgraphSystemId} not found.`, + [ + IssueFactory.notFound( + ISSUE_ENTITY_TYPE.Subgraph, + subgraphSystemId, + ), + ], + ); + } + const modules = await this.uow + .getModuleRepository() + .findModulesBySubgraphIds([subgraphSystemId], command.fileSystemId); + for (const module of modules) { + 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 command.subsystemSystemIds) { + await this.uow + .getSubsystemRepository() + .updateParentId(subsystemSystemId, command.targetSubsystemSystemId); + updatedSubsystems.push({ + systemId: subsystemSystemId, + parentSystemId: command.targetSubsystemSystemId, + }); + } + + 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, + }; + } 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..2290626e6 --- /dev/null +++ b/packages/core/src/application/usecase-designer/subsystem/move/move-subsystem-impact.ts @@ -0,0 +1,389 @@ +/* + * 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 {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 RouteState = { + nodeSequence: number[]; + requiredPortType: Map< + number, + typeof PORT_IO_TYPE.OutputInput | typeof PORT_IO_TYPE.InputOutput + >; +}; + +type DataRouteChange = { + link: DataLink; + oldSegments: SubsystemDataLink[]; + newSegments: SubsystemDataLink[]; +}; + +type ControlRouteChange = { + link: ControlLink; + oldSegments: SubsystemControlLink[]; + newSegments: SubsystemControlLink[]; +}; + +function routeSignature(route: RouteState): string { + return route.nodeSequence.join(':'); +} + +function getRoute( + sourceNodeId: number, + destinationNodeId: number, + parentByNode: Map, +): RouteState { + return SubsystemBoundaryPathService.compute({ + sourceNodeSystemId: sourceNodeId, + destinationNodeSystemId: destinationNodeId, + nodeParentMap: parentByNode, + }); +} + +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 = + await dependencies.dataLinkRepository.findAllWithSegments(fileSystemId); + const controlLinks = + await dependencies.controlLinkRepository.findAllWithSegments(fileSystemId); + + 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 route of dataRoutes) { + for (const portId of collectDataPortIds(route.oldSegments, 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, + ); + } + } + for (const route of controlRoutes) { + for (const portId of collectControlPortIds( + route.oldSegments, + 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..e60b81587 100644 --- a/packages/core/src/shared/issues/factories.ts +++ b/packages/core/src/shared/issues/factories.ts @@ -129,6 +129,61 @@ 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, + }, + }; + }, + 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..15bee2c2f 100644 --- a/packages/core/src/shared/issues/operational-codes.ts +++ b/packages/core/src/shared/issues/operational-codes.ts @@ -29,6 +29,10 @@ 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', } 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..34e64cf9a --- /dev/null +++ b/packages/core/tests/unit/application/usecase-designer/subsystem/move/move-subsystem-impact.spec.ts @@ -0,0 +1,91 @@ +/* + * 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 {NodeType} from '../../../../../../src/domain/entities/usecase-data/node/node.js'; +import {Subsystem} from '../../../../../../src/domain/entities/usecase-data/subsystem/subsystem.js'; +import type {ControlLink} from '../../../../../../src/domain/entities/usecase-data/links/control-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]), + replaceSubsystemDataLinkSegments: jest + .fn() + .mockImplementation((_id, segments) => { + replacedSegments.push(segments); + }), + } as never, + controlLinkRepository: { + findAllWithSegments: jest.fn().mockResolvedValue([] as ControlLink[]), + replaceSubsystemControlLinkSegments: 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, + }); + }); +}); 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..1b568a87e --- /dev/null +++ b/packages/core/tests/unit/application/usecase-designer/subsystem/subsystem-handlers.spec.ts @@ -0,0 +1,398 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {describe, expect, it, jest} from '@jest/globals'; +import {DataPort, PORT_IO_TYPE, 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, + parentId: undefined, + name: 'Subsystem', + subsystemId: 1, + filteredKeySystemIds: [], + dataPorts: [], + controlPorts: [], + ...overrides, + }); +} + +function makeSubsystemRepository( + overrides: Record = {}, +): SubsystemRepository { + return { + findSubsystems: jest.fn().mockResolvedValue([]), + 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([]), + replaceSubsystemDataLinkSegments: jest.fn(), + ...overrides, + } as unknown as DataLinkRepository; +} + +function makeControlLinkRepository( + overrides: Record = {}, +): ControlLinkRepository { + return { + getLinksByPortSystemIds: jest.fn().mockResolvedValue([]), + findAllWithSegments: jest.fn().mockResolvedValue([]), + replaceSubsystemControlLinkSegments: jest.fn(), + ...overrides, + } as unknown as ControlLinkRepository; +} + +function makeUow( + options: { + subsystemRepository?: SubsystemRepository; + dataLinkRepository?: DataLinkRepository; + controlLinkRepository?: ControlLinkRepository; + moduleRepository?: 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({ + findModulesBySubgraphId: jest.fn().mockResolvedValue([]), + updateParentId: jest.fn(), + ...options.moduleRepository, + }), + } as unknown as UnitOfWork; +} + +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([]); + }); +}); 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..9bc9c92fb 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,78 @@ 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 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..d1f678813 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,77 @@ 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 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/subsystem/subsystem.repository.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subsystem/subsystem.repository.ts index 70e1596ed..2021ef89d 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,21 @@ 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 {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 +45,145 @@ 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 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 +193,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 +228,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(), + ); +}