diff --git a/docs/module-write/design/add-module-design.md b/docs/module-write/design/add-module-design.md index 728568c63..be8ab1425 100644 --- a/docs/module-write/design/add-module-design.md +++ b/docs/module-write/design/add-module-design.md @@ -80,7 +80,7 @@ All downstream files that construct or read this field are updated in the same c ### 3.2 Container property IDs — use existing constants -**No new file.** The constants already exist in `packages/core/src/application/file-operations/shared/constants/spf-ids.ts`: +**No new file.** The constants already exist in `packages/core/src/domain/entities/definitions/spf-ids.ts`: ```ts export const CONTAINER_PROP_ID_STACK_SIZE = 0x08_00_10_13; diff --git a/docs/property-data/design/remove-query-services-from-subgraph-command-handlers-design.md b/docs/property-data/design/remove-query-services-from-subgraph-command-handlers-design.md new file mode 100644 index 000000000..85bd3d1fc --- /dev/null +++ b/docs/property-data/design/remove-query-services-from-subgraph-command-handlers-design.md @@ -0,0 +1,59 @@ +# Remove QueryServices from Subgraph Command Handlers + +## Requirements + +### Functional Requirements + +| ID | Requirement | +|----|-------------| +| FR-01 | `UpdateSubgraphPropertyHandler`, `UpdateSubgraphScenarioHandler`, and `UpdateSubgraphVsidHandler` must not receive `QueryServices`. | +| FR-02 | The `SubgraphRepository` port must expose the definition reads required by those handlers. | +| FR-03 | The TypeORM Subgraph repository must implement those reads using existing persistence fetchers/query logic and the active edit-session overlay. | +| FR-04 | Command-handler registration must stop passing `QueryServices` to the three affected handlers. | +| FR-05 | Existing results, validation, error behavior, and transaction behavior must remain unchanged. | + +### Invariants + +**I1:** `packages/core` may depend only on core ports and read models; it must not depend on TypeORM or persistence adapters. + +**I2:** Unrelated command handlers that still require `QueryServices` remain unchanged. + +### Out of Scope + +- Redesigning `addProperty` to accept a prepared payload. +- Moving zero-CKV/default-data business logic from persistence into core. +- Refactoring all query-service consumers in the application. + +## Design + +### Core repository contract + +Extend `SubgraphRepository` with these read methods: + +- `getAllSubgraphPropertyDefinitionsSummary(fileSystemId, propertyNaturalId?)` +- `getSubgraphPropertiesWithElements(fileSystemId)` +- `getSubgraphPropertyWithElements(propertySystemId, fileSystemId)` +- `getAllVcpmModuleDefinitions(fileSystemId)` + +The property methods reuse the existing core read-model and `Result` types. The VCPM method returns `VcpmModuleDefinitionWithParamsReadModel[]`. + +### Persistence adapter + +`TypeOrmSubgraphRepository` will: + +- Construct and use `SubgraphPropertyDefinitionFetcher` for property-definition reads. +- Pass the active write-session ID to preserve overlay behavior. +- Map persistence rows to the existing core read models and `Result` values. +- Move the existing VCPM definition query logic into repository methods without changing its result shape. + +### Handlers and registration + +The three handlers will obtain the Subgraph repository once from `UnitOfWork` and use it for all required definition reads. Their constructors will accept only `UnitOfWork`. + +The command registry will continue exposing `QueryServices` to unrelated handlers, but the three Subgraph factories will no longer pass it. + +### Verification + +- Update affected unit-test mocks to implement the repository read methods. +- Add or update persistence integration coverage for property and VCPM definition reads through `TypeOrmSubgraphRepository`. +- Run core and persistence typechecks/tests. Existing unrelated failures will be reported separately. diff --git a/docs/property-data/design/set-subgraph-property-design.md b/docs/property-data/design/set-subgraph-property-design.md new file mode 100644 index 000000000..5f3f7b9e5 --- /dev/null +++ b/docs/property-data/design/set-subgraph-property-design.md @@ -0,0 +1,1248 @@ + + +# Set Subgraph Property Data — Low-Level Design (Draft) + +**Feature folder:** `docs/property-data/` +**Status:** DRAFT +**Date:** 2026-08-27 + +--- + +## Requirements + +Requirements source: [../set-subgraph-property-requirements.md](../set-subgraph-property-requirements.md) + +| ID | Requirement | +|---|---| +| FR-SG-NAME | `PATCH /subgraphs/:id/name` — write subgraph name; staged; re-query → SubgraphPropertiesResponseDto | +| FR-SG-PROP | `PATCH /subgraphs/:id/properties/:propSystemId` — generic property update; reserved guard for scenario + VSID → 400; staged; re-query → PropertyResponseDto | +| FR-SG-VSID | `PATCH /subgraphs/:id/vsid` — BFS propagation to all connected voice subgraphs; staged; returns affectedSubgraphSystemIds | +| FR-CCR-01 | Active session required → 403 | +| FR-CCR-02 | DESIGNER / DIFF_MERGE modes only → 403 | +| FR-CCR-03/04 | All writes staged; visible via overlay read immediately | +| FR-CCR-05 | groupId in all responses | + +--- + +## Section 0: Prerequisite — `serializeDefaultParameterData` in `serialize-elements.ts` + +**Context:** `build-subgraph-with-defaults.ts` seeds all `SubgraphPropertyData` blobs as `null` because the utility to build a default binary payload doesn't exist yet (see the `TODO(add-module-calibration-defaults)` comment). The `setPropertyData` infra method resolves the property row by calling `SubgraphPropertyDataFetcher.fetchMany` and throws if the row is not found — but the row _will_ exist since it is created at subgraph-creation time. The problem is its `payload` is `null`, which is valid for reads but means the blob column is empty. This is fine for reads but the section is called out here because the commit `a54340d` from the `feature/use-case-designer` branch already implements `serializeDefaultParameterData`; this PR should take those changes directly rather than waiting for that branch to merge. + +### 0.1 Files changed + +| File | Change | +|---|---| +| `packages/core/src/application/usecase-designer/shared/serialize-elements.ts` | Add `serializeDefaultParameterData` + `buildDefaultElements` helpers (ported from commit `a54340d`) | +| `packages/core/src/application/usecase-designer/subgraph/build-subgraph-with-defaults.ts` | Replace `null` placeholder with `serializeDefaultParameterData(propDef)` | + +### 0.2 `serializeDefaultParameterData` + +**File:** `packages/core/src/application/usecase-designer/shared/serialize-elements.ts` (modified) + +Appended after the existing `serializeParameterData` function: + +```typescript +/** + * Builds a binary blob from the default values declared in a parameter + * definition's elementsStructure. Used to seed property rows at entity + * creation time so they always have a valid (if default) payload. + */ +export function serializeDefaultParameterData( + definition: ParameterDefinitionBase, +): SerializeResult { + let schema: DefinitionElement[]; + try { + schema = convertParamDefinition(definition.elementsStructure); + } catch { + return {ok: false, error: 'Failed to parse elementsStructure JSON'}; + } + const defaultInputs = schema.map(def => buildDefaultElement(def)); + return serializeParameterData(definition, defaultInputs); +} + +function buildDefaultElement(def: DefinitionElement): ElementCalData { + switch (def.elementType) { + case PARAMETER_ELEMENT_TYPE.ConfigElement: + return { + name: def.name ?? '', + description: def.description ?? '', + isReadOnly: def.isReadOnly ?? false, + type: PARAMETER_ELEMENT_TYPE.ConfigElement, + dataType: def.dataType, + value: def.defaultValue ?? '0', + } satisfies ConfigElementData; + + case PARAMETER_ELEMENT_TYPE.Struct: + return { + name: def.name, + description: def.description ?? '', + isReadOnly: false, + type: PARAMETER_ELEMENT_TYPE.Struct, + structType: def.structureType, + value: def.elements.map(e => buildDefaultElement(e)), + } satisfies StructData; + + case PARAMETER_ELEMENT_TYPE.ElementArray: + case PARAMETER_ELEMENT_TYPE.StructArray: { + const length = def.arrayLength ?? 0; + return { + name: def.name, + description: def.description ?? '', + isReadOnly: false, + type: PARAMETER_ELEMENT_TYPE.ElementArray, + template: [], + value: Array.from({length}, () => buildDefaultElement(def.template)), + } satisfies ElementArrayData; + } + } +} +``` + +The `serializeParameterData` call in `serializeDefaultParameterData` reuses the existing serialization path — no duplication of write logic. + +**New imports needed** in `serialize-elements.ts`: +```typescript +import type { + ConfigElementData, + StructData, + ElementArrayData, +} from '../../../domain/entities/definitions/common/types/element-data.js'; +``` +(These are already imported at the top of the file — verify before adding.) + +### 0.3 Wire into `build-subgraph-with-defaults.ts` + +**File:** `packages/core/src/application/usecase-designer/subgraph/build-subgraph-with-defaults.ts` (modified) + +Replace the `null` placeholder: + +```typescript +import {serializeDefaultParameterData} from '../shared/serialize-elements.js'; + +// inside buildSubgraphWithDefaults: +const properties = propertyDefinitions.map(propDef => { + const serialized = serializeDefaultParameterData(propDef); + return new SubgraphPropertyData( + propDef.systemId, + serialized.ok ? serialized.value : null, + ); +}); +``` + +`propDef` satisfies `ParameterDefinitionBase` (`systemId`, `isReadOnly`, `elementsStructure`) — the existing `SubgraphPropertyDefinitionRecord` type already extends it. + +If serialization fails for a given property definition (malformed `elementsStructure`), the blob falls back to `null` rather than throwing — matching the existing behaviour and avoiding a hard failure at subgraph creation time. + +--- + +## Section 1: Architecture & Call Flow + +The write path follows hexagonal + CQRS using **Command + CommandBus + UnitOfWork**. All three endpoints reuse existing command/handler stubs — the stubs are implemented (no new files for commands or handlers). The re-query after write uses the existing `GetSubgraphPropertiesQuery`. + +### 1.1 High-Level Workflow Diagram + +#### PATCH /name + +```mermaid +flowchart TD + A([Client PATCH /name]) --> B[SessionGuard: resolve session] + B -->|No session| C([HTTP 403]) + B -->|Session found| D[CommandBus: check allowedModes] + D -->|Mode not allowed| C + D -->|Mode OK| E[subgraphExists → 404 if false] + E -->|Not found| F([HTTP 404]) + E -->|Found| G[writeDelta on Subgraph row: name] + G --> H[Re-query via GetSubgraphPropertiesQuery] + H --> I([HTTP 200 SubgraphPropertiesResponseDto]) +``` + +#### PATCH /properties/:propSystemId + +```mermaid +flowchart TD + A([Client PATCH /properties/:propSystemId]) --> B[SessionGuard] + B -->|No session| C([HTTP 403]) + B -->|OK| D[CommandBus: check allowedModes] + D -->|Not allowed| C + D -->|OK| E[subgraphExists → 404] + E -->|Not found| F([HTTP 404]) + E -->|Found| G[getSubgraphPropertyWithElements → 404 if not found] + G -->|Not found| F + G -->|Found| H{Reserved property?} + H -->|scenario or VSID| I([HTTP 400 — use dedicated endpoint]) + H -->|Not reserved| J[serializeParameterData → 400 if fail] + J -->|Fail| K([HTTP 400]) + J -->|OK| L[setPropertyData on SubgraphPropertyData row] + L --> M[Re-query via GetSubgraphPropertyQuery] + M --> N([HTTP 200 PropertyResponseDto]) +``` + +#### PATCH /vsid + +```mermaid +flowchart TD + A([Client PATCH /vsid]) --> B[SessionGuard] + B -->|No session| C([HTTP 403]) + B -->|OK| D[CommandBus: check allowedModes] + D -->|Not allowed| C + D -->|OK| E[subgraphExists → 404] + E -->|Not found| F([HTTP 404]) + E -->|Found| G[Read current VSID from overlay] + G -->|Same value| H([HTTP 200 empty affectedSubgraphSystemIds]) + G -->|Different| I[BFS via use_case_subgraphs + usecase_gkv_values → find all linked voice subgraphs] + I --> J[setPropertyData for target + all BFS subgraphs atomically] + J --> K([HTTP 200 UpdateVsidResponseDto with affectedSubgraphSystemIds]) +``` + +### 1.2 File and Folder Organization + +Files annotated **(existing)** already exist; **(modified)** means an existing file is changed; **(new)** means a new file. + +#### Presentation Layer +``` +packages/api/src/presentation/rest/modules/subgraph/ +└── subgraph.controller.ts (modified — implement name + property + vsid stubs) +``` + +#### Core Shared +``` +packages/core/src/application/usecase-designer/ +├── shared/ +│ └── serialize-elements.ts (modified — add serializeDefaultParameterData + buildDefaultElement) +└── subgraph/ + └── build-subgraph-with-defaults.ts (modified — replace null placeholder with serializeDefaultParameterData) +``` + +#### Core Layer +``` +packages/core/src/application/ +├── ports/persistence/repositories/subgraph/ +│ └── subgraph.repository.ts (modified — add rename, setPropertyData, getAggregate, getSubgraphIdsInSameUsecases) +├── ports/persistence/query-services/subgraph-property-definition/ +│ └── subgraph-property-def-query-service.ts (modified — add getSubgraphPropertyWithElements) +├── orchestration/cqrs/registries/ +│ ├── command-handler-registry.ts (modified — inject queryServices into UpdateSubgraphPropertyHandler and UpdateSubgraphVsidHandler) +│ └── query-handler-registry.ts (modified — register GetSubgraphPropertyHandler) +└── usecase-designer/subgraph/ + ├── file-operations/shared/constants/ + │ └── spf-ids.ts (canonical SUB_GRAPH_PROP_ID_SCENARIO_ID, SUB_GRAPH_PROP_ID_VSID, scenario value constants) + ├── dto/ + │ └── subgraph-write-result-types.ts (modified — add groupId to VsidUpdateDtoSchema) + ├── get-property/ + │ ├── get-subgraph-property.query.ts (new — single property by propertyDefinitionSystemId) + │ └── get-subgraph-property.handler.ts (new — returns PropertyDataDto for single property) + ├── patch/ + │ └── patch-subgraph.handler.ts (modified — implement rename logic, return { groupId }) + ├── update-property/ + │ ├── update-subgraph-property.command.ts (modified — data: unknown[] → elements: ParameterElementSummaryDto[]) + │ └── update-subgraph-property.handler.ts (modified — implement logic + reserved guard) + └── update-vsid/ + ├── update-subgraph-vsid.command.ts (modified — data: unknown[] → elements: ParameterElementSummaryDto[]) + └── update-subgraph-vsid.handler.ts (modified — implement BFS propagation logic) +``` + +#### Infrastructure Layer +``` +packages/infrastructure/persistence/src/persistence-typeorm-sqllite/ +├── repositories/subgraph/ +│ └── subgraph.repository.ts (modified — implement rename, setPropertyData, getAggregate, getSubgraphIdsInSameUsecases) +└── queries/subgraph-property-definition/ + └── db-subgraph-property-def-query-service.ts (modified — implement getSubgraphPropertyWithElements) +``` + +No schema changes — no migration needed. + +### 1.3 Layer Responsibilities + +``` +Presentation (API) + PATCH /name: + → @UseGuards(SessionGuard) + → new PatchSubgraphCommand(subgraphSystemId, dto.name) + → CommandBus.execute(command, session) — returns { groupId: string } + → re-query via GetSubgraphPropertiesQuery → SubgraphPropertiesResponseDto → 200 + + PATCH /properties/:propSystemId: + → @UseGuards(SessionGuard) + → new UpdateSubgraphPropertyCommand(subgraphSystemId, propSystemId, dto.elements) + → CommandBus.execute(command, session) — returns void + → re-query via GetSubgraphPropertyQuery(projectId, subgraphSystemId, propSystemId) → PropertyDataDto → PropertyResponseDto → 200 + + PATCH /vsid: + → @UseGuards(SessionGuard) + → new UpdateSubgraphVsidCommand(subgraphSystemId, dto.elements) + → CommandBus.execute(command, session) — returns VsidUpdateDto + → toApiResult(Result.ok(result)) → UpdateVsidResponseDto → 200 + +Core (Application) + PatchSubgraphHandler (implements rename): + fileSystemId = uow.getWriteContext().session.fileSystemId + 1. subgraphExists(subgraphSystemId, fileSystemId) → 404 if false + 2. uow.getSubgraphRepository().rename(subgraphSystemId, command.name) + — no transaction needed (single delta write) + + UpdateSubgraphPropertyHandler: + fileSystemId = uow.getWriteContext().session.fileSystemId + 1. subgraphExists(subgraphSystemId, fileSystemId) → 404 if false + 2. queryServices.subgraphPropertyDefQueryService.getSubgraphPropertyWithElements( + propertySystemId, fileSystemId) → 404 if fail + 3. Reserved guard: if propertyId === SCENARIO_ID or VSID_ID → throw InvalidOperationException → 400 + 4. serializeParameterData(propDef, command.elements) → 400 if fail + 5. uow.getSubgraphRepository().setPropertyData(subgraphSystemId, propertySystemId, payload) + — no transaction needed (single delta write) + + UpdateSubgraphVsidHandler: + fileSystemId = uow.getWriteContext().session.fileSystemId + 1. getAggregate(subgraphSystemId, fileSystemId) → 404 if null + 2. Resolve VSID + Scenario property definitions via subgraphPropertyDefQueryService + 3. No-op if current VSID === requested VSID → return VsidUpdateDto { groupId, affectedSubgraphSystemIds: [] } + 4. BFS: getSubgraphIdsInSameUsecases(subgraphSystemId, fileSystemId) + → for each hop: skip non-voice subgraphs, skip already-processed, skip if VSID already matches + → zero-GKV usecases skipped inside getSubgraphIdsInSameUsecases + 5. Write new VSID to target + all BFS-discovered subgraphs atomically: + uow.startTransaction() + try: + setPropertyData for each affected subgraph (Promise.all safe — same QueryRunner) + uow.commit() + catch: + if uow.isInTransaction() → uow.rollback(); throw + 6. Return VsidUpdateDto { groupId, affectedSubgraphSystemIds } + +Infrastructure (Persistence) + TypeOrmSubgraphRepository.rename: + → writeDelta({ targetTable: Subgraph, targetSystemId: subgraphSystemId, + aggregateId: subgraphSystemId, delta: { name } }) + + TypeOrmSubgraphRepository.setPropertyData: + → SubgraphPropertyDataFetcher.fetchMany([subgraphSystemId], sessionId) to resolve prop row systemId + → prop row not found → throw (property must exist at subgraph creation) + → writeDelta({ targetTable: SubgraphPropertyData, targetSystemId: prop.systemId, + aggregateId: subgraphSystemId, delta: { payload: data } }) + + TypeOrmSubgraphRepository.getAggregate: + → delegates to SubgraphOverlayFetcher.fetchOne(subgraphSystemId, fileSystemId, sessionId) + → returns OverlaidSubgraph | null (properties array included) +``` + +--- + +## Section 2: Presentation Layer + +**File:** `packages/api/src/presentation/rest/modules/subgraph/subgraph.controller.ts` (modified) + +### 2.1 PATCH /name + +The existing `patchSubgraph` method (`PATCH /:subgraphSystemId`) already handles the `PatchSubgraphCommand`. It is refactored to re-query and return `SubgraphPropertiesResponseDto` instead of throwing `NotImplementedException`. + +```typescript +@Patch('/:subgraphSystemId') +@UseGuards(SessionGuard) +async patchSubgraph( + @Param('projectId') projectId: string, + @Param('subgraphSystemId', ParseIntPipe) subgraphSystemId: number, + @Body() dto: PatchSubgraphRequestDto, + @ArcSession() session: ActiveSession, +): Promise> { + await this.commandBus.execute<{groupId: string}>( + new PatchSubgraphCommand(subgraphSystemId, dto.name), + session, + ); + const query = new GetSubgraphPropertiesQuery( + Number.parseInt(projectId, 10), + subgraphSystemId, + 'api-client', + ); + const result = await this.queryBus.execute>(query); + return toApiResult(result); +} +``` + +`PatchSubgraphRequestDto` already exists with `name?: string` — no change needed. + +### 2.2 PATCH /properties/:propSystemId + +The existing `updateSubgraphProperty` stub is implemented. Uses a new `GetSubgraphPropertyQuery` (singular — by property definition systemId) for the re-query, mirroring the `GetContainerPropertyQuery` pattern from `set-container-property-design.md`: + +```typescript +@Patch('/:subgraphSystemId/properties/:propSystemId') +@UseGuards(SessionGuard) +async updateSubgraphProperty( + @Param('projectId') projectId: string, + @Param('subgraphSystemId', ParseIntPipe) subgraphSystemId: number, + @Param('propSystemId', ParseIntPipe) propSystemId: number, + @Body() dto: UpdatePropertyRequestDto, + @ArcSession() session: ActiveSession, +): Promise> { + await this.commandBus.execute( + new UpdateSubgraphPropertyCommand(subgraphSystemId, propSystemId, dto.elements), + session, + ); + const query = new GetSubgraphPropertyQuery( + Number.parseInt(projectId, 10), + subgraphSystemId, + propSystemId, + 'api-client', + ); + const result = await this.queryBus.execute>(query); + return toApiResult(result, data => mapPropertyToDto(data)); +} +``` + +`GetSubgraphPropertyQuery` takes `(projectId, subgraphSystemId, propertyDefinitionSystemId, clientId)` — re-queries the single updated property by its definition systemId. `mapPropertyToDto` converts `PropertyDataDto` → `PropertyResponseDto` (same mapper used in the container LLD). + +### 2.3 PATCH /vsid + +The existing `setSubgraphVsid` stub already calls `UpdateSubgraphVsidCommand` and maps `VsidUpdateDto` to `UpdateVsidResponseDto`. The command constructor is updated to accept `elements` instead of `[dto]`. + +```typescript +@Patch('/:subgraphSystemId/vsid') +@UseGuards(SessionGuard) +async setSubgraphVsid( + @Param('subgraphSystemId', ParseIntPipe) subgraphSystemId: number, + @Body() dto: UpdatePropertyRequestDto, + @ArcSession() session: ActiveSession, +): Promise> { + const result = await this.commandBus.execute( + new UpdateSubgraphVsidCommand(subgraphSystemId, dto.elements), + session, + ); + return toApiResult(Result.ok(result)); +} +``` + +--- + +## Section 3: Core Layer + +### 3.1 SubgraphPropertyIds constants + +**File:** `packages/core/src/domain/entities/definitions/spf-ids.ts` + +```typescript +export const SUB_GRAPH_PROP_ID_SCENARIO_ID = 0x08001010; +export const SUB_GRAPH_PROP_ID_VSID = 0x080010CC; + +export const SUB_GRAPH_PROP_ID_SCENARIO_VALUE_AUDIO_PLAYBACK = 0x00000001; +export const SUB_GRAPH_PROP_ID_SCENARIO_VALUE_AUDIO_RECORDING = 0x00000002; +export const SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL = 0x00000003; +``` + +The guard uses `propDef.propertyId` (natural key from the definition row), not `propertySystemId`. + +### 3.2 PatchSubgraphHandler (rename) + +**File:** `packages/core/src/application/usecase-designer/subgraph/patch/patch-subgraph.handler.ts` (modified) + +```typescript +export class PatchSubgraphHandler implements CommandHandler { + constructor(private readonly uow: UnitOfWork) {} + + async handle(command: PatchSubgraphCommand): Promise<{groupId: string}> { + const {session, groupId} = this.uow.getWriteContext(); + const fileSystemId = session.fileSystemId; + + const exists = await this.uow.getSubgraphRepository() + .subgraphExists(command.subgraphSystemId, fileSystemId); + if (!exists) { + throw new ResourceNotFoundException( + `Subgraph ${command.subgraphSystemId} not found`, + ); + } + + if (command.name !== undefined) { + await this.uow.getSubgraphRepository() + .rename(command.subgraphSystemId, command.name); + } + + return {groupId}; + } +} +``` + +### 3.3 UpdateSubgraphPropertyCommand (modified) + +**File:** `packages/core/src/application/usecase-designer/subgraph/update-property/update-subgraph-property.command.ts` (modified) + +`data: unknown[]` → `elements: ParameterElementSummaryDto[]`: + +```typescript +export class UpdateSubgraphPropertyCommand extends BaseCommand { + static override readonly requiresSession = true; + static override readonly allowedModes: readonly SessionMode[] = [ + SESSION_MODE.Designer, + SESSION_MODE.DiffMerge, + ]; + + constructor( + public readonly subgraphSystemId: number, + public readonly propertySystemId: number, + public readonly elements: ParameterElementSummaryDto[], + ) { + super(); + } +} +``` + +### 3.4 UpdateSubgraphPropertyHandler + +**File:** `packages/core/src/application/usecase-designer/subgraph/update-property/update-subgraph-property.handler.ts` (modified) + +```typescript +export class UpdateSubgraphPropertyHandler implements CommandHandler< + UpdateSubgraphPropertyCommand, + void +> { + constructor( + private readonly uow: UnitOfWork, + private readonly queryServices: QueryServices, + ) {} + + async handle(command: UpdateSubgraphPropertyCommand): Promise { + const {session} = this.uow.getWriteContext(); + const fileSystemId = session.fileSystemId; + + // Step 1: subgraph existence + const exists = await this.uow.getSubgraphRepository() + .subgraphExists(command.subgraphSystemId, fileSystemId); + if (!exists) { + throw new ResourceNotFoundException( + `Subgraph ${command.subgraphSystemId} not found`, + ); + } + + // Step 2: property definition existence (with elementsStructure for serialization) + const defResult = await this.queryServices.subgraphPropertyDefQueryService + .getSubgraphPropertyWithElements(command.propertySystemId, fileSystemId); + if (defResult.kind === RESULT_KIND.Fail) { + throw new ResourceNotFoundException( + `Property definition ${command.propertySystemId} not found`, + ); + } + const propDef = defResult.data; + + // Step 3: reserved property guard (enforced here, not in controller) + if ( + propDef.propertyId === SUB_GRAPH_PROP_ID_SCENARIO_ID || + propDef.propertyId === SUB_GRAPH_PROP_ID_VSID + ) { + const endpoint = + propDef.propertyId === SUB_GRAPH_PROP_ID_SCENARIO_ID + ? 'PATCH /subgraphs/:id/scenario' + : 'PATCH /subgraphs/:id/vsid'; + throw new InvalidOperationException( + `Property ${propDef.name} is reserved. Use ${endpoint} instead.`, + ); + } + + // Step 4: serialize elements → Uint8Array + const serialized = serializeParameterData(propDef, command.elements); + if (!serialized.ok) { + throw new BadRequestException(serialized.error); + } + + // Step 5: staged write + await this.uow.getSubgraphRepository() + .setPropertyData(command.subgraphSystemId, command.propertySystemId, serialized.value); + } +} +``` + +Registry entry: +```typescript +this.commandHandlerFactories.set(UpdateSubgraphPropertyCommand, { + create: deps => new UpdateSubgraphPropertyHandler(deps.uow, deps.queryServices), +}); +``` + +### 3.5 UpdateSubgraphVsidCommand (modified) + +**File:** `packages/core/src/application/usecase-designer/subgraph/update-vsid/update-subgraph-vsid.command.ts` (modified) + +`data: unknown[]` → `elements: ParameterElementSummaryDto[]`: + +```typescript +export class UpdateSubgraphVsidCommand extends BaseCommand { + static override readonly requiresSession = true; + static override readonly allowedModes: readonly SessionMode[] = [ + SESSION_MODE.Designer, + SESSION_MODE.DiffMerge, + ]; + + constructor( + public readonly subgraphSystemId: number, + public readonly elements: ParameterElementSummaryDto[], + ) { + super(); + } +} +``` + +### 3.6 UpdateSubgraphVsidHandler + +**File:** `packages/core/src/application/usecase-designer/subgraph/update-vsid/update-subgraph-vsid.handler.ts` (modified) + +The BFS algorithm propagates the new VSID to all Voice subgraphs connected via the same non-zero-GKV usecases: + +1. Start with the target subgraph in a queue. +2. For each subgraph dequeued: find all usecases containing it (`use_case_subgraphs`). +3. Skip zero-GKV usecases (those with no rows in `usecase_gkv_values`). +4. For each non-zero-GKV usecase: find all other subgraphs in that usecase. +5. Filter to **Voice subgraphs only** (scenario === Voice) — non-voice subgraphs are skipped. +6. Skip subgraphs already processed, and skip subgraphs whose VSID already equals the new value. +7. Add newly found subgraphs to the queue and mark them processed. +8. Write new VSID to the target + all collected subgraphs atomically. + +```typescript +export class UpdateSubgraphVsidHandler implements CommandHandler< + UpdateSubgraphVsidCommand, + VsidUpdateDto +> { + constructor( + private readonly uow: UnitOfWork, + private readonly queryServices: QueryServices, + ) {} + + async handle(command: UpdateSubgraphVsidCommand): Promise { + const {session, groupId} = this.uow.getWriteContext(); + const fileSystemId = session.fileSystemId; + + // Step 1: subgraph existence + load properties + const subgraph = await this.uow.getSubgraphRepository() + .getAggregate(command.subgraphSystemId, fileSystemId); + if (!subgraph) { + throw new ResourceNotFoundException( + `Subgraph ${command.subgraphSystemId} not found`, + ); + } + + // Step 2: resolve VSID + Scenario property definitions + const vsidDefResult = await this.queryServices.subgraphPropertyDefQueryService + .getAllSubgraphPropertyDefinitionsSummary(fileSystemId, SUB_GRAPH_PROP_ID_VSID); + if (vsidDefResult.kind === RESULT_KIND.Fail || vsidDefResult.data.length === 0) { + throw new ResourceNotFoundException('VSID property definition not found'); + } + const vsidDef = vsidDefResult.data[0]; + + const scenarioDefResult = await this.queryServices.subgraphPropertyDefQueryService + .getAllSubgraphPropertyDefinitionsSummary(fileSystemId, SUB_GRAPH_PROP_ID_SCENARIO_ID); + const scenarioDef = scenarioDefResult.kind !== RESULT_KIND.Fail + ? scenarioDefResult.data[0] + : undefined; + + // Step 3: read current VSID on target subgraph + const vsidProp = subgraph.properties.find( + p => p.propertySystemId === vsidDef.systemId, + ); + const currentVsid = vsidProp?.payload + ? new BinaryDataReader(vsidProp.payload as Uint8Array).readUInt32() + : undefined; + + // Step 4: extract requested VSID — elements[0].value is uint32 as string + const requestedVsid = Number(command.elements[0]?.value); + + // Step 5: no-op if same value + if (currentVsid === requestedVsid) { + return {groupId, affectedSubgraphSystemIds: []}; + } + + // Step 6: serialize new VSID payload + const vsidDefWithElements = await this.queryServices.subgraphPropertyDefQueryService + .getSubgraphPropertyWithElements(vsidDef.systemId, fileSystemId); + if (vsidDefWithElements.kind === RESULT_KIND.Fail) { + throw new ResourceNotFoundException('VSID property definition (with elements) not found'); + } + const serialized = serializeParameterData(vsidDefWithElements.data, command.elements); + if (!serialized.ok) { + throw new BadRequestException(serialized.error); + } + + // Step 7: BFS across use_case_subgraphs + usecase_gkv_values + // getSubgraphIdsInSameUsecases encapsulates the two-table query and zero-GKV filter. + const processedIds = new Set([command.subgraphSystemId]); + const toWrite = new Set([command.subgraphSystemId]); + const queue: number[] = [command.subgraphSystemId]; + + while (queue.length > 0) { + const currentId = queue.shift()!; + const linkedIds = await this.uow.getSubgraphRepository() + .getSubgraphIdsInSameUsecases(currentId, fileSystemId); + + for (const linkedId of linkedIds) { + if (processedIds.has(linkedId)) continue; + processedIds.add(linkedId); + + // Load subgraph properties to check scenario + current VSID + const linkedSg = await this.uow.getSubgraphRepository() + .getAggregate(linkedId, fileSystemId); + if (!linkedSg) continue; + + // Skip non-voice subgraphs + if (scenarioDef) { + const scenarioProp = linkedSg.properties.find( + p => p.propertySystemId === scenarioDef.systemId, + ); + const scenarioValue = scenarioProp?.payload + ? new BinaryDataReader(scenarioProp.payload as Uint8Array).readUInt32() + : undefined; + if (scenarioValue !== SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL) continue; + } + + // Skip if VSID already matches (but still BFS-expand from it) + const linkedVsidProp = linkedSg.properties.find( + p => p.propertySystemId === vsidDef.systemId, + ); + const linkedVsid = linkedVsidProp?.payload + ? new BinaryDataReader(linkedVsidProp.payload as Uint8Array).readUInt32() + : undefined; + + if (linkedVsid !== requestedVsid) { + toWrite.add(linkedId); + } + queue.push(linkedId); // always expand BFS from this subgraph + } + } + + // Step 8: write new VSID to all collected subgraphs atomically + await this.uow.startTransaction(); + try { + await Promise.all( + [...toWrite].map(sgId => + this.uow.getSubgraphRepository() + .setPropertyData(sgId, vsidDef.systemId, serialized.value), + ), + ); + await this.uow.commit(); + } catch (error) { + if (this.uow.isInTransaction()) await this.uow.rollback(); + throw error; + } + + return { + groupId, + affectedSubgraphSystemIds: [...toWrite].map(String), + }; + } +} +``` + +**Note — `VsidUpdateDtoSchema` needs `groupId`:** The existing schema in `subgraph-write-result-types.ts` is: +```typescript +export const VsidUpdateDtoSchema = z.object({ + affectedSubgraphSystemIds: z.array(z.string()), +}); +``` +This is missing `groupId` (required by FR-CCR-05). It must be extended to: +```typescript +export const VsidUpdateDtoSchema = z.object({ + groupId: z.string(), + affectedSubgraphSystemIds: z.array(z.string()), +}); +``` +**File:** `packages/core/src/application/usecase-designer/subgraph/dto/subgraph-write-result-types.ts` (modified) + +**New port method on `SubgraphRepository`** (see Section 3.7): +```typescript +getSubgraphIdsInSameUsecases(subgraphSystemId: number, fileSystemId: number): Promise +``` +This encapsulates: +1. `use_case_subgraphs WHERE subgraphSystemId = X` → set of `usecaseSystemId`s +2. Filter out zero-GKV usecases: keep only those with rows in `usecase_gkv_values` +3. `use_case_subgraphs WHERE usecaseSystemId IN kept-set` → set of other `subgraphSystemId`s + +### 3.7 SubgraphRepository Port Extensions + +**Read model:** `packages/core/src/application/ports/persistence/query-services/subgraph-property-definition/subgraph-property-definition-with-elements-read-model.ts` + +**Repository port:** `packages/core/src/application/ports/persistence/repositories/subgraph/subgraph.repository.ts` (modified) + +```typescript +export interface SubgraphWithProperties { + systemId: number; + properties: Array<{ + systemId: number; + propertySystemId: number; + payload: Uint8Array | null; + }>; +} + +export interface SubgraphRepository { + subgraphExists(systemId: number, fileSystemId: number): Promise; + createSubgraph(subgraph: Subgraph, options?: EditOptions): Promise; + + // Returns subgraph with property rows (overlay-aware). null if not found. + getAggregate( + subgraphSystemId: number, + fileSystemId: number, + ): Promise; + + // Stages a name delta on the Subgraph row. + rename( + subgraphSystemId: number, + name: string, + ): Promise; + + // Stages a payload delta on an existing SubgraphPropertyData row. + // Throws if property row does not exist in the effective state. + setPropertyData( + subgraphSystemId: number, + propertySystemId: number, + data: Uint8Array, + ): Promise; + + // Returns all subgraphSystemIds that share at least one non-zero-GKV usecase + // with the given subgraph. Used by the VSID BFS. + // Step 1: use_case_subgraphs WHERE subgraphSystemId = X → usecaseSystemIds + // Step 2: filter out zero-GKV usecases (no rows in usecase_gkv_values) + // Step 3: use_case_subgraphs WHERE usecaseSystemId IN kept-set → other subgraphSystemIds + // Does NOT include the input subgraphSystemId itself. + getSubgraphIdsInSameUsecases( + subgraphSystemId: number, + fileSystemId: number, + ): Promise; +} +``` + +--- + +### 3.8 SubgraphPropertyDefQueryService Port Extension + +**File:** `packages/core/src/application/ports/persistence/query-services/subgraph-property-definition/subgraph-property-def-query-service.ts` (modified) + +Add one method — mirrors the existing `getContainerPropertyDefinitionWithElements` on `ContainerPropertyDefQueryService`: + +```typescript +export interface SubgraphPropertyDefQueryService { + // ... existing methods ... + + // Returns a single subgraph property definition including elementsStructure. + // Result.fail with ERROR_CODES.ENTITY_NOT_FOUND if not found. + getSubgraphPropertyWithElements( + propertySystemId: number, + fileSystemId: number, + ): Promise>; +} +``` + +`SubgraphPropertyDefinitionWithElementsReadModel` already exists at: +`packages/core/src/application/ports/persistence/query-services/subgraph-property-definition/subgraph-property-definition-with-elements-read-model.ts` + +No new types needed. + +**Infra implementation** — `DbSubgraphPropertyDefQueryService` (modified): + +Delegates to the existing `fetcher.fetchAll`, filters in memory — same pattern as the existing `getSubgraphPropertyDefinition` method in the same class: + +```typescript +async getSubgraphPropertyWithElements( + propertySystemId: number, + fileSystemId: number, +): Promise> { + try { + const session = await this.sessionRepo.findActiveSessionByFileSystemId(fileSystemId); + const rows = await this.fetcher.fetchAll(fileSystemId, session?.sessionId ?? null); + const match = rows.find(r => r.systemId === propertySystemId); + return match + ? Result.ok(this.toDetailWithElementsReadModel(match)) + : Result.fail({ + code: ERROR_CODES.ENTITY_NOT_FOUND, + message: `SubgraphPropertyDefinition not found for systemId=${propertySystemId}`, + severity: IssueSeverity.Error, + }); + } catch (error) { + return Result.fail({ + code: ERROR_CODES.INTERNAL_ERROR, + message: error instanceof Error ? error.message : 'Failed to load subgraph property definition', + severity: IssueSeverity.Error, + }); + } +} +``` + +`toDetailWithElementsReadModel` already exists in `DbSubgraphPropertyDefQueryService` — no new mapper needed. + +--- + +### 3.9 GetSubgraphPropertyQuery and Handler (singular) + +Mirrors `GetContainerPropertyQuery` from `set-container-property-design.md` exactly. + +**Files:** +- `packages/core/src/application/usecase-designer/subgraph/get-property/get-subgraph-property.query.ts` (new) +- `packages/core/src/application/usecase-designer/subgraph/get-property/get-subgraph-property.handler.ts` (new) + +```typescript +export class GetSubgraphPropertyQuery extends BaseQuery { + public readonly projectId: number; + public readonly subgraphSystemId: number; + public readonly propertySystemId: number; // property definition systemId + + constructor( + projectId: number, + subgraphSystemId: number, + propertySystemId: number, + clientId: string, + ) { + super(clientId); + this.projectId = projectId; + this.subgraphSystemId = subgraphSystemId; + this.propertySystemId = propertySystemId; + } +} +``` + +```typescript +export class GetSubgraphPropertyHandler implements QueryHandler< + GetSubgraphPropertyQuery, + Promise> +> { + constructor(private readonly queryServices: QueryServices) {} + + async handle(query: GetSubgraphPropertyQuery): Promise> { + const fileSystemId = await this.queryServices.projectQueryService + .getFileIdByProjectId(query.projectId); + + // Step 1: load all property payloads for the subgraph (overlay-aware) + const payloadsResult = await this.queryServices.subgraphQueryService + .findPropertyPayloads(query.subgraphSystemId, fileSystemId); + if (payloadsResult.kind === RESULT_KIND.Fail) { + throw new Error(payloadsResult.issues[0]?.message ?? 'Failed to load subgraph properties'); + } + if (payloadsResult.data === null) { + throw new ResourceNotFoundException(`Subgraph ${query.subgraphSystemId} not found`); + } + + // Step 2: find the specific property payload by definition systemId + const payload = payloadsResult.data.find( + p => p.propertySystemId === query.propertySystemId, + ); + if (!payload) { + throw new ResourceNotFoundException( + `Property ${query.propertySystemId} not found on subgraph ${query.subgraphSystemId}`, + ); + } + + // Step 3: load definition with elementsStructure for parsing + const defResult = await this.queryServices.subgraphPropertyDefQueryService + .getSubgraphPropertyWithElements(query.propertySystemId, fileSystemId); + if (defResult.kind === RESULT_KIND.Fail) { + throw new ResourceNotFoundException(`Property definition ${query.propertySystemId} not found`); + } + + // Step 4: parse elements from binary payload + const elements = payload.payload !== null + ? parseParameterData(payload.payload, defResult.data.elementsStructure) + : []; + + return Result.ok({ + systemId: payload.systemId, + propertyId: defResult.data.propertyId, + propertyName: defResult.data.name, + elements, + }); + } +} +``` + +Registration in `query-handler-registry.ts`: +```typescript +this.queryHandlerFactories.set(GetSubgraphPropertyQuery, { + create: deps => new GetSubgraphPropertyHandler(deps.queryServices), +}); +``` + +--- + +## Section 4: Infrastructure Layer + +**File:** `packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subgraph/subgraph.repository.ts` (modified) + +### 4.1 getAggregate + +Delegates to `SubgraphOverlayFetcher.fetchOne` — properties now returned as `SubgraphPropertyDataBase[]` +via the injected `SubgraphPropertyDataFetcher`. + +```typescript +async getAggregate( + subgraphSystemId: number, + fileSystemId: number, +): Promise { + const sessionId = this.uow.getWriteContext().session.sessionId; + const overlaid = await this.subgraphFetcher.fetchOne( + subgraphSystemId, + fileSystemId, + sessionId, + ); + if (!overlaid) return null; + return { + systemId: overlaid.systemId, + properties: overlaid.properties.map(p => ({ + systemId: p.systemId, + propertySystemId: p.subgraphPropertySystemId, + payload: p.payload as Uint8Array | null, + })), + }; +} +``` + +**Constructor note:** `SubgraphOverlayFetcher` now requires four dependencies: +`manager, editActionsSvc, SubgraphPropertyDataFetcher, SubgraphSgkvFetcher`. +The repository constructor must inject all four — same pattern as `TypeOrmContainerRepository`. + +### 4.2 rename + +```typescript +async rename( + subgraphSystemId: number, + name: string, +): Promise { + const {session, groupId} = this.uow.getWriteContext(); + await this.writer.writeDelta( + { + targetTable: ENTITY_NAMES.Subgraph, + targetSystemId: subgraphSystemId, + aggregateId: subgraphSystemId, + delta: {name}, + }, + session.sessionId, + groupId, + this.manager, + ); +} +``` + +### 4.3 setPropertyData + +Uses `SubgraphPropertyDataFetcher.fetchMany` to resolve the property row `systemId` — avoids +loading the full subgraph row just to find a property PK. + +```typescript +async setPropertyData( + subgraphSystemId: number, + propertySystemId: number, + data: Uint8Array, +): Promise { + const {session, groupId} = this.uow.getWriteContext(); + + // Resolve the SubgraphPropertyData row's systemId via the property data fetcher + const propRows = await this.propertyDataFetcher.fetchMany( + [subgraphSystemId], + session.sessionId, + ); + const prop = propRows.find( + p => p.subgraphPropertySystemId === propertySystemId, + ); + if (!prop) { + throw new Error( + `SubgraphPropertyData for property ${propertySystemId} not found on ` + + `subgraph ${subgraphSystemId}. Ensure the property is initialised at subgraph creation.`, + ); + } + await this.writer.writeDelta( + { + targetTable: ENTITY_NAMES.SubgraphPropertyData, + targetSystemId: prop.systemId, + aggregateId: subgraphSystemId, + delta: {payload: data}, + }, + session.sessionId, + groupId, + this.manager, + ); +} +``` + +### 4.4 PendingChangeWriter Specs + +**`rename`:** + +| Field | Value | +|---|---| +| `targetTable` | `Subgraph` | +| `targetSystemId` | `subgraphSystemId` | +| `aggregateId` | `subgraphSystemId` | +| `delta` | `{ name: "" }` | + +**`setPropertyData`:** + +| Field | Value | +|---|---| +| `targetTable` | `SubgraphPropertyData` | +| `targetSystemId` | `prop.systemId` (PK of the property data row) | +| `aggregateId` | `subgraphSystemId` | +| `delta` | `{ payload: }` | + +### 4.5 getSubgraphIdsInSameUsecases + +Pure SQL — no overlay needed. The `use_case_subgraphs` and `usecase_gkv_values` tables are not overlaid (they are not part of the staging model). + +```typescript +async getSubgraphIdsInSameUsecases( + subgraphSystemId: number, + fileSystemId: number, +): Promise { + // Step 1: find all usecases containing this subgraph + const usecaseRows = await this.manager + .getRepository(ENTITY_NAMES.UseCaseSubgraph) + .createQueryBuilder('ucs') + .select('ucs.usecaseSystemId') + .where('ucs.subgraphSystemId = :subgraphSystemId', {subgraphSystemId}) + .getRawMany<{usecaseSystemId: number}>(); + + if (usecaseRows.length === 0) return []; + + const allUsecaseIds = usecaseRows.map(r => r.usecaseSystemId); + + // Step 2: filter to non-zero-GKV usecases only + const gkvRows = await this.manager + .getRepository(ENTITY_NAMES.UsecaseGkvValues) + .createQueryBuilder('ugkv') + .select('DISTINCT ugkv.usecaseSystemId') + .where('ugkv.usecaseSystemId IN (:...ids)', {ids: allUsecaseIds}) + .getRawMany<{usecaseSystemId: number}>(); + + if (gkvRows.length === 0) return []; + + const nonZeroGkvUsecaseIds = gkvRows.map(r => r.usecaseSystemId); + + // Step 3: find all other subgraphs in those usecases + const linkedRows = await this.manager + .getRepository(ENTITY_NAMES.UseCaseSubgraph) + .createQueryBuilder('ucs') + .select('DISTINCT ucs.subgraphSystemId') + .where('ucs.usecaseSystemId IN (:...ids)', {ids: nonZeroGkvUsecaseIds}) + .andWhere('ucs.subgraphSystemId != :subgraphSystemId', {subgraphSystemId}) + .getRawMany<{subgraphSystemId: number}>(); + + return linkedRows.map(r => r.subgraphSystemId); +} +``` + +`ENTITY_NAMES.UseCaseSubgraph` and `ENTITY_NAMES.UsecaseGkvValues` must be added to `entity-table-names.ts` if not already present. + +--- + +## Section 5: Testing Strategy + +### Unit Tests + +#### serializeDefaultParameterData + +**File:** `packages/core/tests/unit/application/usecase-designer/shared/serialize-default-parameter-data.spec.ts` (new) + +| Scenario | Expected outcome | +|---|---| +| Single `ConfigElement` with `defaultValue` | serialized blob contains that value | +| Single `ConfigElement` with no `defaultValue` | serialized blob contains `0` | +| `Struct` with nested `ConfigElement` children | all children use default values | +| `ElementArray` with `arrayLength: 3` | three default items serialized | +| Malformed `elementsStructure` JSON | returns `{ ok: false }` | + +#### buildSubgraphWithDefaults (updated) + +**File:** `packages/core/tests/unit/application/usecase-designer/subgraph/build-subgraph-with-defaults.spec.ts` (new or extend) + +| Scenario | Expected outcome | +|---|---| +| Valid `elementsStructure` | property blob is non-null `Uint8Array` | +| Malformed `elementsStructure` | property blob falls back to `null` (no throw) | + +#### PatchSubgraphHandler + +**File:** `packages/core/tests/unit/application/usecase-designer/subgraph/patch/patch-subgraph.handler.spec.ts` (new) + +| Scenario | Expected outcome | +|---|---| +| Subgraph not found | throws `ResourceNotFoundException` → 404 | +| Name provided | `rename` called with correct args | +| Name undefined | `rename` NOT called | + +#### UpdateSubgraphPropertyHandler + +**File:** `packages/core/tests/unit/application/usecase-designer/subgraph/update-property/update-subgraph-property.handler.spec.ts` (new) + +| Scenario | Expected outcome | +|---|---| +| Subgraph not found | throws `ResourceNotFoundException` → 404 | +| Property definition not found | throws `ResourceNotFoundException` → 404 | +| `propertyId` === SCENARIO_ID | throws `InvalidOperationException` → 400 | +| `propertyId` === VSID_ID | throws `InvalidOperationException` → 400 | +| Serialization fails | throws `BadRequestException` → 400 | +| Valid property | `setPropertyData` called with serialized payload | + +#### UpdateSubgraphVsidHandler + +**File:** `packages/core/tests/unit/application/usecase-designer/subgraph/update-vsid/update-subgraph-vsid.handler.spec.ts` (new) + +| Scenario | Expected outcome | +|---|---| +| Subgraph not found | throws `ResourceNotFoundException` → 404 | +| VSID definition not found | throws `ResourceNotFoundException` → 404 | +| Current VSID === requested VSID | returns `{ groupId, affectedSubgraphSystemIds: [] }`; no writes | +| No linked subgraphs (zero-GKV only) | only target subgraph written; `affectedSubgraphSystemIds` has one entry | +| Linked subgraph is not Voice | skipped; not in `affectedSubgraphSystemIds` | +| Linked Voice subgraph with same VSID | not written but BFS still expands from it | +| BFS finds linked Voice subgraphs | all written atomically; all in `affectedSubgraphSystemIds` | +| Write throws | `rollback()` called; error re-thrown | +| VSID serialization fails | throws `BadRequestException` → 400 | + +### Integration Tests + +**File:** `packages/infrastructure/persistence/tests/integration/repositories/subgraph/subgraph-property.repository.spec.ts` (new) + +| Scenario | Expected outcome | +|---|---| +| `rename` — writes delta on Subgraph row | `edit_actions` row with `targetTable=Subgraph`, `delta={ name }` | +| `rename` — prior pending change exists | old row superseded; new merged row inserted | +| `setPropertyData` — writes delta on SubgraphPropertyData row | `edit_actions` row with `targetTable=SubgraphPropertyData`, `delta={ payload }` | +| `setPropertyData` — property row not on subgraph | throws | +| `getAggregate` — base row | returns subgraph with property array | +| `getAggregate` — pending CREATE overlay | includes staged property | +| `getAggregate` — pending DELETE overlay | excludes deleted property | +| `getAggregate` — not found | returns null | +| `getSubgraphIdsInSameUsecases` — no usecases | returns `[]` | +| `getSubgraphIdsInSameUsecases` — all usecases are zero-GKV | returns `[]` | +| `getSubgraphIdsInSameUsecases` — one non-zero-GKV usecase with two subgraphs | returns the other subgraph ID | +| `getSubgraphIdsInSameUsecases` — multiple usecases, deduped result | returns distinct subgraph IDs only | +| `getSubgraphIdsInSameUsecases` — never returns the input subgraphSystemId | self excluded | + +**File:** `packages/infrastructure/persistence/tests/integration/queries/subgraph-property-definition/db-subgraph-property-def.spec.ts` (new or existing — add cases) + +| Scenario | Expected outcome | +|---|---| +| `getSubgraphPropertyWithElements` — found | returns `SubgraphPropertyDefinitionWithElementsReadModel` with `elementsStructure` populated | +| `getSubgraphPropertyWithElements` — not found | returns `Result.fail` with `ENTITY_NOT_FOUND` | +| `getSubgraphPropertyWithElements` — session overlay creates definition | returns created row | + +### End-to-End Tests + +**File:** `packages/api/tests/e2e/subgraph/set-subgraph-property.e2e-spec.ts` (new) + +| Scenario | HTTP status | +|---|---| +| No active session | 403 | +| Session mode TUNING | 403 | +| Subgraph not found (name) | 404 | +| PATCH /name — success | 200 `SubgraphPropertiesResponseDto` | +| Subgraph not found (property) | 404 | +| Property definition not found | 404 | +| Reserved property (scenario) | 400 | +| Reserved property (VSID) | 400 | +| PATCH /properties/:propSystemId — success | 200 `PropertyResponseDto` | +| Subgraph not found (vsid) | 404 | +| Same VSID (no-op) | 200 empty `affectedSubgraphSystemIds` | +| PATCH /vsid — propagates to linked subgraphs | 200 `affectedSubgraphSystemIds` populated | + +--- + +## Open Questions + +| # | Question | +|---|---| +| OQ-1 | ~~Exact natural-key `propertyId` values~~ — **Resolved:** `SUB_GRAPH_PROP_ID_SCENARIO_ID = 0x08001010`, `SUB_GRAPH_PROP_ID_VSID = 0x080010CC`. | +| OQ-2 | ~~BFS algorithm~~ — **Resolved:** BFS uses `use_case_subgraphs` + `usecase_gkv_values`. For each subgraph in queue: find usecases containing it, skip zero-GKV usecases, find all other subgraphs in those usecases, filter to Voice only, skip if VSID already matches. Encapsulated in `getSubgraphIdsInSameUsecases`. | +| OQ-3 | ~~`getSgkvs` port extension~~ — **Resolved:** Not needed. BFS uses `getSubgraphIdsInSameUsecases` on `SubgraphRepository` instead. | +| OQ-4 | ~~`elementsStructure` for VSID serialization~~ — **Resolved:** Add `getSubgraphPropertyWithElements(propertySystemId, fileSystemId)` to `SubgraphPropertyDefQueryService` port (Section 3.8). Infra delegates to existing `fetcher.fetchAll` + filter in memory. | +| OQ-5 | ~~Voice scenario value~~ — **Resolved:** `SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL = 0x00000003`. Also defined: `AUDIO_PLAYBACK = 0x00000001`, `AUDIO_RECORDING = 0x00000002`. | diff --git a/docs/property-data/design/set-subgraph-scenario-design.md b/docs/property-data/design/set-subgraph-scenario-design.md new file mode 100644 index 000000000..e744529d1 --- /dev/null +++ b/docs/property-data/design/set-subgraph-scenario-design.md @@ -0,0 +1,990 @@ + + +# Set Subgraph Scenario — Low-Level Design (Draft) + +**Feature folder:** `docs/property-data/` +**Status:** DRAFT — Ready for review +**Date:** 2026-08-27 +**Related LLD:** `set-subgraph-property-design.md` (name, generic property, VSID) + +--- + +## Requirements + +Requirements source: [../set-subgraph-property-requirements.md](../set-subgraph-property-requirements.md) + +| ID | Requirement | +|---|---| +| FR-SG-SCENARIO-01 | `PATCH /subgraphs/:id/scenario` — elements format, value is uint32 scenario ID as string | +| FR-SG-SCENARIO-02 | Subgraph existence → 404 | +| FR-SG-SCENARIO-03 | No-op if scenario already matches → 200 with empty mutation log | +| FR-SG-SCENARIO-04 | Audio → Voice cascade (8 steps, atomic) | +| FR-SG-SCENARIO-05 | Voice → Audio cascade (5 steps, atomic) | +| FR-SG-SCENARIO-06 | Returns `{ groupId, propertiesAdded, propertiesRemoved, moduleCkvsAdded, moduleCkvsDeleted }` | +| FR-CCR-01 | Active session required → 403 | +| FR-CCR-02 | DESIGNER / DIFF_MERGE modes only → 403 | +| FR-CCR-03/04 | All writes staged; visible via overlay read immediately | +| FR-CCR-05 | groupId in response | + +--- + +## Section 0: Prerequisite — `serializeDefaultParameterData` in `serialize-elements.ts` + +**Context:** This design calls `serializeDefaultParameterData` in four places: +- `addProperty` (§4.1) — seeds a new `SubgraphPropertyData` blob with default values +- `wipeCalData` (§4.4) — restores zero-CKV payload rows to factory defaults +- `addVcpmCfgDefaultData` (§4.6) — seeds new `VcpmParameterPayload` blobs +- Voice → Audio step c — seeds the clock scale factor property blob + +That function does not yet exist in the codebase. The unmerged commit `a54340d` on `feature/use-case-designer` implements it. **This PR must port those changes before any of the above infra methods can be implemented.** + +Full implementation details and the `build-subgraph-with-defaults.ts` wiring are documented in **`set-subgraph-property-design.md` Section 0** — that section is the authoritative source. The summary here is: + +### 0.1 Files changed + +| File | Change | +|---|---| +| `packages/core/src/application/usecase-designer/shared/serialize-elements.ts` | Add `serializeDefaultParameterData` + `buildDefaultElement` helpers (ported from commit `a54340d`) | +| `packages/core/src/application/usecase-designer/subgraph/build-subgraph-with-defaults.ts` | Replace `null` placeholder with `serializeDefaultParameterData(propDef)` | + +See `set-subgraph-property-design.md §0.2–§0.3` for the full code. + +--- + +## Section 1: Architecture & Call Flow + +### 1.1 High-Level Workflow Diagram + +```mermaid +flowchart TD + A([Client PATCH /scenario]) --> B[SessionGuard] + B -->|No session| C([HTTP 403]) + B -->|OK| D[CommandBus: check allowedModes] + D -->|Not allowed| C + D -->|OK| E[getAggregate → 404 if null] + E -->|Not found| F([HTTP 404]) + E -->|Found| G[Read current scenario from overlay] + G -->|Same value| H([HTTP 200 empty ScenarioChangeDto]) + G -->|Different| I{Direction?} + I -->|Audio → Voice| J[8-step cascade] + I -->|Voice → Audio| K[5-step cascade] + J --> L[Write all changes atomically] + K --> L + L --> M([HTTP 200 UpdateScenarioResponseDto]) +``` + +### 1.2 File and Folder Organization + +Files annotated **(existing)** already exist; **(modified)** means changed; **(new)** means new file. + +#### Presentation Layer +``` +packages/api/src/presentation/rest/modules/subgraph/ +└── subgraph.controller.ts (modified — implement setSubgraphScenario stub) +``` + +#### Core Shared +``` +packages/core/src/application/usecase-designer/ +├── shared/ +│ └── serialize-elements.ts (modified — add serializeDefaultParameterData + buildDefaultElement; see §0) +└── subgraph/ + └── build-subgraph-with-defaults.ts (modified — replace null placeholder; see §0) +``` + +#### Core Layer +``` +packages/core/src/application/ +├── ports/persistence/query-services/vcpm-definition/ +│ ├── vcpm-definition-query-service.ts (new — VcpmDefinitionQueryService port) +│ └── vcpm-definition-read-model.ts (new — VcpmModuleDefinitionWithParamsReadModel) +├── ports/persistence/query-services/ +│ └── query-services.ts (modified — add vcpmDefinitionQueryService: VcpmDefinitionQueryService) +├── ports/persistence/repositories/subgraph/ +│ └── subgraph.repository.ts (modified — add addProperty, removeProperty) +├── ports/persistence/repositories/module/ +│ └── module.repository.ts (modified — add wipeCalData, getModulesBySubgraphId) +├── orchestration/cqrs/registries/ +│ └── command-handler-registry.ts (modified — inject queryServices into UpdateSubgraphScenarioHandler) +└── usecase-designer/subgraph/ + ├── file-operations/shared/constants/ + │ └── spf-ids.ts (modified — add SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR) + ├── dto/ + │ └── subgraph-write-result-types.ts (modified — add groupId to ScenarioChangeDtoSchema) + └── update-scenario/ + ├── update-subgraph-scenario.command.ts (modified — data: unknown[] → elements: ParameterElementSummaryDto[]) + └── update-subgraph-scenario.handler.ts (modified — implement full cascade logic) +``` + +#### Infrastructure Layer +``` +packages/infrastructure/persistence/src/persistence-typeorm-sqllite/ +├── queries/vcpm-definition/ +│ └── db-vcpm-definition-query-service.ts (new — implements VcpmDefinitionQueryService) +├── repositories/subgraph/ +│ └── subgraph.repository.ts (modified — implement addProperty, removeProperty) +└── repositories/module/ + └── module.repository.ts (modified — implement wipeCalData, getModulesBySubgraphId) +``` + +### 1.3 Layer Responsibilities + +``` +Presentation (API) + PATCH /scenario: + → @UseGuards(SessionGuard) + → new UpdateSubgraphScenarioCommand(subgraphSystemId, dto.elements) + → CommandBus.execute(command, session) — returns ScenarioChangeDto + → toApiResult(Result.ok(result)) → UpdateScenarioResponseDto → 200 + +Core (Application) + UpdateSubgraphScenarioHandler: + fileSystemId = uow.getWriteContext().session.fileSystemId + + Read phase (no transaction): + 1. getAggregate(subgraphSystemId, fileSystemId) → 404 if null + 2. Resolve scenario property definition → get systemId + 3. Read current scenario value from subgraph properties + 4. No-op if current === requested → return empty ScenarioChangeDto + 5. If Audio → Voice: getOptimalVsid(subgraphSystemId, fileSystemId) → 422 if conflict + 6. Load all subgraph property definitions (for IsVoice filter + clock scale factor) + + Write phase (transactional — all steps share same groupId): + uow.startTransaction() + try: + Audio → Voice (steps in order): + a. addProperty for each IsVoice=true definition not already present + b. removeProperty for SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR if present + c. setPropertyData for VSID property using BinaryDataWriter.writeUInt32(optimalVsid).align(8) + d. wipeCalData for each module in subgraph + e. for each VcpmModuleDefinition (via VcpmDefinitionQueryService): + create VcpmInstance row + zero-CKV VcpmParameterPayload per parameter + using serializeDefaultParameterData(paramDef) — from serialize-elements.ts (PR a54340d) + f. setPropertyData for scenario property (final step) + + Voice → Audio (steps in order): + a. wipeCalData for each module in subgraph + b. removeProperty for each IsVoice=true property + c. addProperty for SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR with serializeDefaultParameterData(clockScaleDef) + d. SubgraphRepository.removeAllVcpmCfgData (deletes VcpmInstance + VcpmCkv + VcpmParameterPayload) + e. setPropertyData for scenario property (final step) + uow.commit() + catch: + if uow.isInTransaction() → uow.rollback(); throw + + Return ScenarioChangeDto { groupId, propertiesAdded, propertiesRemoved, + moduleCkvsAdded, moduleCkvsDeleted } + +Infrastructure (Persistence) + TypeOrmSubgraphRepository.addProperty: + → writeCreate on SubgraphPropertyData row + + TypeOrmSubgraphRepository.removeProperty: + → writeDelete on SubgraphPropertyData row + + TypeOrmModuleRepository.getModulesBySubgraphId: + → loadBaselineNodeIdsForSubgraph(subgraphSystemId, fileSystemId) → Set + → applySessionOverlayToNodesForSubgraph(subgraphSystemId, nodeIds, sessionId) + → fetchOverLayedSpfModules([...nodeIds], fileSystemId, sessionId) + → returns SpfModuleBase[] (overlay-aware) + + TypeOrmModuleRepository.wipeCalData: + → for each non-zero CKV: writeDelete on CkvParameterPayload rows + Ckv row + → for each TKV: writeDelete on TkvParameterPayload rows + Tkv row + → for each tagged module entry: writeDelete on ModuleTagIdMap row + → for each existing zero-CKV payload row: writeDelta to restore default value +``` + +--- + +## Section 2: Presentation Layer + +**File:** `packages/api/src/presentation/rest/modules/subgraph/subgraph.controller.ts` (modified) + +The existing `setSubgraphScenario` stub already calls `UpdateSubgraphScenarioCommand` and maps `ScenarioChangeDto` to `UpdateScenarioResponseDto`. The command constructor is updated to accept `elements`: + +```typescript +@Patch('/:subgraphSystemId/scenario') +@UseGuards(SessionGuard) +async setSubgraphScenario( + @Param('subgraphSystemId', ParseIntPipe) subgraphSystemId: number, + @Body() dto: UpdatePropertyRequestDto, + @ArcSession() session: ActiveSession, +): Promise> { + const result = await this.commandBus.execute( + new UpdateSubgraphScenarioCommand(subgraphSystemId, dto.elements), + session, + ); + return toApiResult(Result.ok(result)); +} +``` + +**Note:** `ScenarioChangeDtoSchema` needs `groupId` added (see Section 3.1). + +--- + +## Section 3: Core Layer + +### 3.1 ScenarioChangeDtoSchema — add groupId + +**File:** `packages/core/src/application/usecase-designer/subgraph/dto/subgraph-write-result-types.ts` (modified) + +```typescript +export const ScenarioChangeDtoSchema = z.object({ + groupId: z.string(), + propertiesAdded: z.array(PropertyChangeDtoSchema), + propertiesRemoved: z.array(PropertyChangeDtoSchema), + moduleCkvsAdded: z.array(CkvRefDtoSchema), + moduleCkvsDeleted: z.array(CkvRefDtoSchema), +}); +``` + +### 3.2 UpdateSubgraphScenarioCommand (modified) + +**File:** `packages/core/src/application/usecase-designer/subgraph/update-scenario/update-subgraph-scenario.command.ts` (modified) + +`data: unknown[]` → `elements: ParameterElementSummaryDto[]`: + +```typescript +export class UpdateSubgraphScenarioCommand extends BaseCommand { + static override readonly requiresSession = true; + static override readonly allowedModes: readonly SessionMode[] = [ + SESSION_MODE.Designer, + SESSION_MODE.DiffMerge, + ]; + + constructor( + public readonly subgraphSystemId: number, + public readonly elements: ParameterElementSummaryDto[], + ) { + super(); + } +} +``` + +### 3.3 Subgraph Property ID additions + +**File:** `packages/core/src/domain/entities/definitions/spf-ids.ts` (modified) + +Add: +```typescript +export const SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR = 0x08001374; +``` + +### 3.4 UpdateSubgraphScenarioHandler + +**File:** `packages/core/src/application/usecase-designer/subgraph/update-scenario/update-subgraph-scenario.handler.ts` (modified) + +```typescript +export class UpdateSubgraphScenarioHandler implements CommandHandler< + UpdateSubgraphScenarioCommand, + ScenarioChangeDto +> { + constructor( + private readonly uow: UnitOfWork, + private readonly queryServices: QueryServices, + ) {} + + async handle(command: UpdateSubgraphScenarioCommand): Promise { + const {session, groupId} = this.uow.getWriteContext(); + const fileSystemId = session.fileSystemId; + + // ── Read phase ──────────────────────────────────────────────────────────── + + // Step 1: load subgraph with properties + const subgraph = await this.uow.getSubgraphRepository() + .getAggregate(command.subgraphSystemId, fileSystemId); + if (!subgraph) { + throw new ResourceNotFoundException( + `Subgraph ${command.subgraphSystemId} not found`, + ); + } + + // Step 2: resolve scenario property definition + const scenarioDefResult = await this.queryServices.subgraphPropertyDefQueryService + .getAllSubgraphPropertyDefinitionsSummary(fileSystemId, SUB_GRAPH_PROP_ID_SCENARIO_ID); + if (scenarioDefResult.kind === RESULT_KIND.Fail || scenarioDefResult.data.length === 0) { + throw new ResourceNotFoundException('Scenario property definition not found'); + } + const scenarioDef = scenarioDefResult.data[0]; + + // Step 3: read current scenario + const scenarioProp = subgraph.properties.find( + p => p.propertySystemId === scenarioDef.systemId, + ); + const currentScenario = scenarioProp?.payload + ? new BinaryDataReader(scenarioProp.payload as Uint8Array).readUInt32() + : undefined; + + // Step 4: extract requested scenario from elements + const requestedScenario = Number(command.elements[0]?.value); + + // Step 5: no-op if same + if (currentScenario === requestedScenario) { + return { + groupId, + propertiesAdded: [], + propertiesRemoved: [], + moduleCkvsAdded: [], + moduleCkvsDeleted: [], + }; + } + + // Step 6: determine direction + const isAudioToVoice = + currentScenario !== SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL && + requestedScenario === SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL; + const isVoiceToAudio = + currentScenario === SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL && + requestedScenario !== SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL; + + // Step 7: load all property definitions (needed for IsVoice filter + clock scale factor) + const allDefsResult = await this.queryServices.subgraphPropertyDefQueryService + .getSubgraphPropertiesWithElements(fileSystemId); + if (allDefsResult.kind === RESULT_KIND.Fail) { + throw new Error('Failed to load subgraph property definitions'); + } + const allDefs = allDefsResult.data; + const voiceDefs = allDefs.filter(d => d.isVoice); + const clockScaleDef = allDefs.find(d => d.propertyId === SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR); + + // Step 8 (Audio → Voice only): find optimal VSID — throws 422 on conflict + let optimalVsid: number | undefined; + if (isAudioToVoice) { + optimalVsid = await this.getOptimalVsid(command.subgraphSystemId, fileSystemId, allDefs); + } + + // Step 9: get all non-deleted modules in this subgraph + const modules = await this.uow.getModuleRepository() + .getModulesBySubgraphId(command.subgraphSystemId, fileSystemId); + + // ── Serialize scenario payload (before transaction) ─────────────────────── + const scenarioDefWithElements = await this.queryServices.subgraphPropertyDefQueryService + .getSubgraphPropertyWithElements(scenarioDef.systemId, fileSystemId); + if (scenarioDefWithElements.kind === RESULT_KIND.Fail) { + throw new ResourceNotFoundException('Scenario property definition (with elements) not found'); + } + const serializedScenario = serializeParameterData( + scenarioDefWithElements.data, command.elements, + ); + if (!serializedScenario.ok) { + throw new BadRequestException(serializedScenario.error); + } + + // ── Write phase (transactional) ─────────────────────────────────────────── + + const propertiesAdded: z.infer[] = []; + const propertiesRemoved: z.infer[] = []; + const moduleCkvsAdded: z.infer[] = []; + const moduleCkvsDeleted: z.infer[] = []; + + await this.uow.startTransaction(); + try { + if (isAudioToVoice) { + // a. Add voice-specific SPF properties (IsVoice=true, not already present) + const existingPropIds = new Set(subgraph.properties.map(p => p.propertySystemId)); + for (const def of voiceDefs) { + if (existingPropIds.has(def.systemId)) continue; + const newSystemId = await this.uow.getSubgraphRepository() + .addProperty(command.subgraphSystemId, def.systemId, def); + propertiesAdded.push({systemId: String(newSystemId), propertyId: def.propertyId, propertyName: def.name}); + } + + // b. Remove clock scale factor if present + if (clockScaleDef) { + const clockProp = subgraph.properties.find(p => p.propertySystemId === clockScaleDef.systemId); + if (clockProp) { + await this.uow.getSubgraphRepository() + .removeProperty(command.subgraphSystemId, clockProp.systemId); + propertiesRemoved.push({systemId: String(clockProp.systemId), propertyId: clockScaleDef.propertyId, propertyName: clockScaleDef.name}); + } + } + + // c. Set VSID using BinaryDataWriter directly (OQ-4) + const vsidDefsResult = await this.queryServices.subgraphPropertyDefQueryService + .getAllSubgraphPropertyDefinitionsSummary(fileSystemId, SUB_GRAPH_PROP_ID_VSID); + const vsidDef = vsidDefsResult.data?.[0]; + if (vsidDef && optimalVsid !== undefined) { + const writer = new BinaryDataWriter(); + writer.writeUInt32(optimalVsid); + writer.align(8); + await this.uow.getSubgraphRepository() + .setPropertyData(command.subgraphSystemId, vsidDef.systemId, writer.toUint8Array()); + } + + // d. Wipe all module CKV/TKV cal data + for (const mod of modules) { + const wiped = await this.uow.getModuleRepository() + .wipeCalData(mod.systemId, fileSystemId); + moduleCkvsDeleted.push(...wiped.ckvsDeleted.map(c => ({ + moduleSystemId: String(mod.systemId), ckvSystemId: String(c), + }))); + moduleCkvsAdded.push(...wiped.zeroCkvsAdded.map(c => ({ + moduleSystemId: String(mod.systemId), ckvSystemId: String(c), + }))); + } + + // e. Add default VCPM cfg data for this subgraph + const vcpmDefs = await this.queryServices.vcpmDefinitionQueryService + .getAllVcpmModuleDefinitions(fileSystemId); + await this.uow.getSubgraphRepository() + .addVcpmCfgDefaultData(command.subgraphSystemId, vcpmDefs); + + } else if (isVoiceToAudio) { + // a. Wipe all module CKV/TKV cal data + for (const mod of modules) { + const wiped = await this.uow.getModuleRepository() + .wipeCalData(mod.systemId, fileSystemId); + moduleCkvsDeleted.push(...wiped.ckvsDeleted.map(c => ({ + moduleSystemId: String(mod.systemId), ckvSystemId: String(c), + }))); + moduleCkvsAdded.push(...wiped.zeroCkvsAdded.map(c => ({ + moduleSystemId: String(mod.systemId), ckvSystemId: String(c), + }))); + } + + // b. Remove voice-specific SPF properties + for (const def of voiceDefs) { + const voiceProp = subgraph.properties.find(p => p.propertySystemId === def.systemId); + if (!voiceProp) continue; + await this.uow.getSubgraphRepository() + .removeProperty(command.subgraphSystemId, voiceProp.systemId); + propertiesRemoved.push({systemId: String(voiceProp.systemId), propertyId: def.propertyId, propertyName: def.name}); + } + + // c. Add clock scale factor with default payload from elementsStructure defaultValue fields + if (clockScaleDef) { + const newSystemId = await this.uow.getSubgraphRepository() + .addProperty(command.subgraphSystemId, clockScaleDef.systemId, clockScaleDef); + propertiesAdded.push({systemId: String(newSystemId), propertyId: clockScaleDef.propertyId, propertyName: clockScaleDef.name}); + } + + // d. Remove all VCPM cfg data (VcpmInstance + children) + await this.uow.getSubgraphRepository() + .removeAllVcpmCfgData(command.subgraphSystemId); + } + + // Final step (both directions): write scenario property + await this.uow.getSubgraphRepository() + .setPropertyData(command.subgraphSystemId, scenarioDef.systemId, serializedScenario.value); + + await this.uow.commit(); + } catch (error) { + if (this.uow.isInTransaction()) await this.uow.rollback(); + throw error; + } + + return {groupId, propertiesAdded, propertiesRemoved, moduleCkvsAdded, moduleCkvsDeleted}; + } + + private async getOptimalVsid( + subgraphSystemId: number, + fileSystemId: number, + allDefs: SubgraphPropertyDefinitionWithElementsReadModel[], + ): Promise { + const vsidDef = allDefs.find(d => d.propertyId === SUB_GRAPH_PROP_ID_VSID); + if (!vsidDef) throw new ResourceNotFoundException('VSID property definition not found'); + + const scenarioDef = allDefs.find(d => d.propertyId === SUB_GRAPH_PROP_ID_SCENARIO_ID); + + // BFS — same logic as UpdateSubgraphVsidHandler but read-only + const processedIds = new Set([subgraphSystemId]); + const queue: number[] = [subgraphSystemId]; + const foundVsids = new Set(); + + while (queue.length > 0) { + const currentId = queue.shift()!; + const linkedIds = await this.uow.getSubgraphRepository() + .getSubgraphIdsInSameUsecases(currentId, fileSystemId); + + for (const linkedId of linkedIds) { + if (processedIds.has(linkedId)) continue; + processedIds.add(linkedId); + + const linked = await this.uow.getSubgraphRepository() + .getAggregate(linkedId, fileSystemId); + if (!linked) continue; + + // Filter to Voice subgraphs only + if (scenarioDef) { + const scenarioProp = linked.properties.find(p => p.propertySystemId === scenarioDef.systemId); + const scenarioVal = scenarioProp?.payload + ? new BinaryDataReader(scenarioProp.payload as Uint8Array).readUInt32() + : undefined; + if (scenarioVal !== SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL) continue; + } + + const vsidProp = linked.properties.find(p => p.propertySystemId === vsidDef.systemId); + if (vsidProp?.payload) { + foundVsids.add(new BinaryDataReader(vsidProp.payload as Uint8Array).readUInt32()); + } + queue.push(linkedId); + } + } + + if (foundVsids.size === 0) { + // No voice subgraphs found — use default from elementsStructure + const schema = convertParamDefinition(vsidDef.elementsStructure); + const firstConfig = schema.find(e => e.elementType === PARAMETER_ELEMENT_TYPE.ConfigElement) as ConfigElement | undefined; + return Number(firstConfig?.defaultValue ?? '0'); + } + + if (foundVsids.size === 1) { + return [...foundVsids][0]; + } + + // Multiple conflicting VSIDs → 422 + throw new DomainRuleViolationException( + `Conflicting VSIDs found across linked usecases: ${[...foundVsids].join(', ')}`, + ); + } +} +``` + +**Registry entry:** +```typescript +this.commandHandlerFactories.set(UpdateSubgraphScenarioCommand, { + create: deps => new UpdateSubgraphScenarioHandler(deps.uow, deps.queryServices), +}); +``` + +### 3.5 SubgraphRepository Port Extensions + +**File:** `packages/core/src/application/ports/persistence/repositories/subgraph/subgraph.repository.ts` (modified) + +Two new methods needed for adding and removing property rows (distinct from `setPropertyData` which updates an existing row): + +```typescript +export interface SubgraphRepository { + // ... existing methods from set-subgraph-property-design.md ... + + // Stages a CREATE for a new SubgraphPropertyData row with default payload. + // Returns the new row's systemId (needed for mutation log). + // definition is used to generate the default binary payload via serializeDefaultParameterData. + addProperty( + subgraphSystemId: number, + propertyDefinitionSystemId: number, + definition: SubgraphPropertyDefinitionWithElementsReadModel, + ): Promise; + + // Stages a DELETE on an existing SubgraphPropertyData row. + // propDataSystemId is the PK of the SubgraphPropertyData row (not the definition systemId). + removeProperty( + subgraphSystemId: number, + propDataSystemId: number, + ): Promise; + + // Stages DELETE for all VcpmInstance rows (and their VcpmCkv + VcpmParameterPayload + + // VcpmCkvValues children) for the given subgraph. aggregateId = subgraphSystemId. + // Used by Voice → Audio cascade step d. + removeAllVcpmCfgData(subgraphSystemId: number): Promise; + + // Stages CREATE for VcpmInstance + zero-CKV VcpmParameterPayload rows for each + // VCPM module definition. Default payload derived from each parameter's elementsStructure + // via serializeDefaultParameterData(param). Used by Audio → Voice cascade step e. + addVcpmCfgDefaultData( + subgraphSystemId: number, + vcpmDefs: VcpmModuleDefinitionWithParamsReadModel[], + ): Promise; +} +``` + +### 3.6 ModuleRepository Port Extensions + +**File:** `packages/core/src/application/ports/persistence/repositories/module/module.repository.ts` (modified) + +```typescript +export interface WipeCalDataResult { + ckvsDeleted: number[]; // systemIds of CKV rows staged for DELETE + zeroCkvsAdded: number[]; // systemIds of zero-CKV rows staged for CREATE +} + +export interface ModuleRepository { + // ... existing methods ... + + // Returns all non-deleted SpfModule systemIds belonging to a subgraph. + // Overlay-aware: excludes pending DELETE, includes pending CREATE. + getModulesBySubgraphId( + subgraphSystemId: number, + fileSystemId: number, + ): Promise; + + // Wipes all CKV/TKV cal data for a module: + // - Stages DELETE for all non-zero CKV rows and their parameter payloads + // - Stages DELETE for all TKV rows and their parameter payloads + // - Stages DELETE for all tagged module entries + // - Stages CREATE for zero-CKV default payload rows for each calibration parameter + // Returns mutation log for ScenarioChangeDto. + wipeCalData( + moduleSystemId: number, + fileSystemId: number, + ): Promise; +} +``` + +--- + +## Section 4: Infrastructure Layer + +### 4.1 TypeOrmSubgraphRepository — addProperty + +```typescript +async addProperty( + subgraphSystemId: number, + propertyDefinitionSystemId: number, + definition: SubgraphPropertyDefinitionWithElementsReadModel, +): Promise { + const {session, groupId} = this.uow.getWriteContext(); + const newSystemId = await this.idGeneration.generateId(ENTITY_NAMES.SubgraphPropertyData); + // Default payload derived from each ConfigElement's defaultValue via serializeDefaultParameterData. + const defaultPayload = serializeDefaultParameterData(definition); // definition: ParameterDefinitionBase + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.SubgraphPropertyData, + targetSystemId: newSystemId, + aggregateId: subgraphSystemId, + payload: { + subgraphSystemId, + subgraphPropertySystemId: propertyDefinitionSystemId, + payload: defaultPayload, + }, + }, + session.sessionId, + groupId, + this.manager, + ); + return newSystemId; +} +``` + +**Note:** `addProperty` requires `IdGenerationPort` — the repository constructor must gain this dependency, same pattern as `CreateModuleHandler`. + +### 4.2 TypeOrmSubgraphRepository — removeProperty + +```typescript +async removeProperty( + subgraphSystemId: number, + propDataSystemId: number, +): Promise { + const {session, groupId} = this.uow.getWriteContext(); + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.SubgraphPropertyData, + targetSystemId: propDataSystemId, + aggregateId: subgraphSystemId, + }, + session.sessionId, + groupId, + this.manager, + ); +} +``` + +### 4.3 TypeOrmModuleRepository — getModulesBySubgraphId + +Uses the two-step pattern on `ModuleNodeOverlayFetcher` — `loadBaselineNodeIdsForSubgraph` +then `applySessionOverlayToNodesForSubgraph` then `fetchOverLayedSpfModules`: + +```typescript +async getModulesBySubgraphId( + subgraphSystemId: number, + fileSystemId: number, +): Promise { + const {session} = this.uow.getWriteContext(); + const sessionId = session.sessionId; + + // Step 1: get baseline module node IDs for this subgraph + const nodeIds = await this.moduleNodeFetcher.loadBaselineNodeIdsForSubgraph( + subgraphSystemId, + fileSystemId, + ); + + // Step 2: apply session overlay (adds staged CREATEs, removes staged DELETEs) + await this.moduleNodeFetcher.applySessionOverlayToNodesForSubgraph( + subgraphSystemId, + nodeIds, + sessionId, + ); + + if (nodeIds.size === 0) return []; + + // Step 3: fetch full overlay-aware SpfModule rows for the resolved IDs + const rows = await this.moduleNodeFetcher.fetchOverLayedSpfModules( + [...nodeIds], + fileSystemId, + sessionId, + ); + + return rows.map(r => ({ + systemId: r.systemId, + definitionSystemId: r.definitionSystemId, + subgraphSystemId: r.subgraphSystemId, + containerSystemId: r.containerSystemId, + })); +} +``` + +### 4.4 TypeOrmModuleRepository — wipeCalData + +This is the most complex infra method. It mirrors `RemoveAllGeckoCalTagData` + `AddZeroCkvData` from the C# reference. + +**Steps:** +1. Load all CKVs for the module (overlay-aware) via `CkvOverlayFetcher.fetchForModule` +2. For each **non-zero** CKV (those whose `values` array is non-empty): stage DELETE on all its `CkvParameterPayload` rows, then DELETE the CKV row itself +3. Load all `ModuleTagIdMap` rows for the module via `TkvOverlayFetcher.fetchForModule` — for each: DELETE its `TkvParameterPayload` rows, `Tkv` rows, then the `ModuleTagIdMap` row +4. Find the zero-CKV (the one whose `values` array is empty) — for each of its existing `CkvParameterPayload` rows: fetch its definition and write delta to restore default value + +```typescript +async wipeCalData( + moduleSystemId: number, + fileSystemId: number, +): Promise { + const {session, groupId} = this.uow.getWriteContext(); + const ckvsDeleted: number[] = []; + const zeroCkvsAdded: number[] = []; + + // Step 1+2: load CKVs, delete non-zero CKVs and their payloads + const ckvs = await this.ckvOverlayFetcher.fetchForModule(moduleSystemId, session.sessionId); + for (const ckv of ckvs) { + if (ckv.values.length === 0) continue; // skip zero-CKV (no key-value entries) + const payloads = await this.ckvOverlayFetcher.fetchCkvPayloads( + ckv.systemId, moduleSystemId, session.sessionId, + ); + for (const payload of payloads) { + await this.writer.writeDelete( + {targetTable: ENTITY_NAMES.CkvParameterPayload, targetSystemId: payload.systemId, aggregateId: moduleSystemId}, + session.sessionId, groupId, this.manager, + ); + } + await this.writer.writeDelete( + {targetTable: ENTITY_NAMES.Ckv, targetSystemId: ckv.systemId, aggregateId: moduleSystemId}, + session.sessionId, groupId, this.manager, + ); + ckvsDeleted.push(ckv.systemId); + } + + // Step 3+4: load ModuleTagIdMap + TKVs, delete TkvParameterPayload, Tkv, ModuleTagIdMap + // CkvValues and TkvValues are composite-PK join tables — cascade DELETE automatically. + const tagMaps = await this.tkvOverlayFetcher.fetchForModule( + moduleSystemId, session.sessionId, CONFIGURATION_INCLUDES.FullDetails, + ); + for (const tagMap of tagMaps) { + for (const tkv of tagMap.tkvs) { + const tkvPayloads = await this.tkvOverlayFetcher.fetchTkvPayloads( + tkv.systemId, session.sessionId, + ); + for (const payload of tkvPayloads) { + await this.writer.writeDelete( + {targetTable: ENTITY_NAMES.TkvParameterPayload, targetSystemId: payload.systemId, aggregateId: tagMap.systemId}, + session.sessionId, groupId, this.manager, + ); + } + await this.writer.writeDelete( + {targetTable: ENTITY_NAMES.Tkv, targetSystemId: tkv.systemId, aggregateId: tagMap.systemId}, + session.sessionId, groupId, this.manager, + ); + } + await this.writer.writeDelete( + {targetTable: ENTITY_NAMES.ModuleTagIdMap, targetSystemId: tagMap.systemId, aggregateId: moduleSystemId}, + session.sessionId, groupId, this.manager, + ); + } + + // Step 5: restore zero-CKV default payloads. + // When switching Audio → Voice, the zero-CKV's user-edited payloads must be + // reset to factory defaults. Existing payload rows are already under the zero-CKV + // (created at module creation) — we just overwrite each one with its default value. + const zeroCkv = ckvs.find(c => c.values.length === 0); + if (zeroCkv) { + const module = await this.moduleNodeFetcher.fetchOne(moduleSystemId, fileSystemId, session.sessionId); + if (module) { + const existingPayloads = await this.ckvOverlayFetcher.fetchCkvPayloads( + zeroCkv.systemId, moduleSystemId, session.sessionId, + ); + for (const payload of existingPayloads) { + const defs = await this.uow.getModuleDefinitionRepository() + .getParameterDefinitions(module.definitionSystemId, [payload.parameterSystemId]); + if (defs.length === 0) continue; + const defaultPayload = serializeDefaultParameterData(defs[0]); + await this.writer.writeDelta( + {targetTable: ENTITY_NAMES.CkvParameterPayload, targetSystemId: payload.systemId, + aggregateId: moduleSystemId, delta: {payload: defaultPayload}}, + session.sessionId, groupId, this.manager, + ); + zeroCkvsAdded.push(zeroCkv.systemId); + } + } + } + + return {ckvsDeleted, zeroCkvsAdded}; +} +``` + +**Constructor note:** `TypeOrmModuleRepository` must be extended with `TkvOverlayFetcher` for `wipeCalData`. Add it alongside the existing `CkvOverlayFetcher` in the constructor: +```typescript +this.tkvOverlayFetcher = new TkvOverlayFetcher(manager, editActionsQs); +``` + +--- + +### 4.5 TypeOrmSubgraphRepository — removeAllVcpmCfgData + +```typescript +async removeAllVcpmCfgData(subgraphSystemId: number): Promise { + const {session, groupId} = this.uow.getWriteContext(); + + // Load all VcpmInstance rows for this subgraph + const instances = await this.manager + .getRepository(ENTITY_NAMES.VcpmInstance) + .createQueryBuilder('vi') + .leftJoinAndSelect('vi.vcpmCkvs', 'ckv') + .leftJoinAndSelect('ckv.vcpmParameterPayloads', 'payload') + .where('vi.subgraphSystemId = :subgraphSystemId', {subgraphSystemId}) + .getMany(); + + for (const instance of instances) { + for (const ckv of instance.vcpmCkvs ?? []) { + for (const payload of ckv.vcpmParameterPayloads ?? []) { + await this.writer.writeDelete( + {targetTable: ENTITY_NAMES.VcpmParameterPayload, targetSystemId: payload.systemId, aggregateId: subgraphSystemId}, + session.sessionId, groupId, this.manager, + ); + } + await this.writer.writeDelete( + {targetTable: ENTITY_NAMES.VcpmCkv, targetSystemId: ckv.systemId, aggregateId: subgraphSystemId}, + session.sessionId, groupId, this.manager, + ); + } + await this.writer.writeDelete( + {targetTable: ENTITY_NAMES.VcpmInstance, targetSystemId: instance.systemId, aggregateId: subgraphSystemId}, + session.sessionId, groupId, this.manager, + ); + } +} +``` + +### 4.6 TypeOrmSubgraphRepository — addVcpmCfgDefaultData + +```typescript +async addVcpmCfgDefaultData( + subgraphSystemId: number, + vcpmDefs: VcpmModuleDefinitionWithParamsReadModel[], +): Promise { + const {session, groupId} = this.uow.getWriteContext(); + + for (const def of vcpmDefs) { + // Create VcpmInstance row linking this subgraph to the VCPM module definition + const instanceSystemId = await this.idGeneration.generateId(ENTITY_NAMES.VcpmInstance); + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.VcpmInstance, + targetSystemId: instanceSystemId, + aggregateId: subgraphSystemId, + payload: {subgraphSystemId, vcpmDefinitionId: def.systemId}, + }, + session.sessionId, groupId, this.manager, + ); + + // Create zero-CKV VcpmCkv row (no VcpmCkvValues — zero-CKV has empty key set) + const ckvSystemId = await this.idGeneration.generateId(ENTITY_NAMES.VcpmCkv); + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.VcpmCkv, + targetSystemId: ckvSystemId, + aggregateId: subgraphSystemId, + payload: {vcpmInstanceSystemId: instanceSystemId}, + }, + session.sessionId, groupId, this.manager, + ); + + // Create VcpmParameterPayload for each parameter using default values + for (const param of def.parameters) { + const defaultPayload = serializeDefaultParameterData(param); + const payloadSystemId = await this.idGeneration.generateId(ENTITY_NAMES.VcpmParameterPayload); + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.VcpmParameterPayload, + targetSystemId: payloadSystemId, + aggregateId: subgraphSystemId, + payload: {vcpmCkvSystemId: ckvSystemId, vcpmParameterSystemId: param.systemId, payload: defaultPayload}, + }, + session.sessionId, groupId, this.manager, + ); + } + } +} +``` + +--- + +## Section 5: Testing Strategy + +### Unit Tests + +#### serializeDefaultParameterData + buildSubgraphWithDefaults + +> Covered by `set-subgraph-property-design.md §5` — tests live in: +> - `packages/core/tests/unit/application/usecase-designer/shared/serialize-default-parameter-data.spec.ts` +> - `packages/core/tests/unit/application/usecase-designer/subgraph/build-subgraph-with-defaults.spec.ts` + +#### UpdateSubgraphScenarioHandler + +**File:** `packages/core/tests/unit/application/usecase-designer/subgraph/update-scenario/update-subgraph-scenario.handler.spec.ts` (new) + +| Scenario | Expected outcome | +|---|---| +| Subgraph not found | throws `ResourceNotFoundException` → 404 | +| Scenario definition not found | throws `ResourceNotFoundException` → 404 | +| Current === requested (no-op) | returns empty `ScenarioChangeDto`; no writes | +| Audio → Voice: VSID conflict | throws `DomainRuleViolationException` → 422 | +| Audio → Voice: success | voice props added, clock scale removed, VSID set, CKVs wiped, VCPM cfg added, scenario written | +| Voice → Audio: success | CKVs wiped, voice props removed, clock scale added, VCPM cfg removed, scenario written | +| Write throws | `rollback()` called; error re-thrown | +| Serialization of scenario payload fails | throws `BadRequestException` → 400 | + +### Integration Tests + +**File:** `packages/infrastructure/persistence/tests/integration/repositories/subgraph/subgraph-scenario.repository.spec.ts` (new) + +| Scenario | Expected outcome | +|---|---| +| `addProperty` — writes CREATE on SubgraphPropertyData | `edit_actions` row with correct payload | +| `removeProperty` — writes DELETE on SubgraphPropertyData | `edit_actions` DELETE row | +| `getModulesBySubgraphId` — base rows | returns correct SpfModuleBase[] | +| `getModulesBySubgraphId` — pending DELETE overlay | excludes deleted module | +| `getModulesBySubgraphId` — pending CREATE overlay | includes staged module | +| `wipeCalData` — non-zero CKVs deleted | DELETE edit_actions for each CKV + payloads | +| `wipeCalData` — zero-CKV skipped | zero-CKV row NOT deleted | +| `wipeCalData` — zero-CKV defaults created | CREATE edit_actions for default payloads | + +### End-to-End Tests + +**File:** `packages/api/tests/e2e/subgraph/set-subgraph-scenario.e2e-spec.ts` (new) + +| Scenario | HTTP status | +|---|---| +| No active session | 403 | +| Session mode TUNING | 403 | +| Subgraph not found | 404 | +| Same scenario (no-op) | 200 empty mutation log | +| Audio → Voice: VSID conflict | 422 | +| Audio → Voice: success | 200 with populated `propertiesAdded`, `moduleCkvsDeleted`, `moduleCkvsAdded` | +| Voice → Audio: success | 200 with populated `propertiesRemoved`, `moduleCkvsDeleted`, `moduleCkvsAdded` | + +--- + +## Open Questions + +| # | Question | +|---|---| +| OQ-1 | ~~VCPM cfg definition source~~ — **Resolved:** All rows in `vcpm_module_definitions` for a given `fileSystemId` are VCPM cfg definitions — no filtering needed. Add a new `VcpmDefinitionQueryService` port with one method: `getAllVcpmModuleDefinitions(fileSystemId): Promise`. The result includes parameter definitions. Read model: `{ systemId, moduleDefinitionId, parameters: { systemId, paramId, elementsStructure }[] }`. Infra: simple SQL join of `vcpm_module_definitions` + `vcpm_module_parameter_definitions` filtered by `fileSystemId`. Audio→Voice step e uses this to create one `VcpmInstance` row per definition and one zero-CKV `VcpmParameterPayload` per parameter with default payload derived from `elementsStructure`. | +| OQ-2 | ~~Remove all VCPM cfg data~~ — **Resolved:** VCPM data is owned by the subgraph aggregate (`aggregateId = subgraphSystemId`). Add `removeAllVcpmCfgData(subgraphSystemId: number): Promise` to `SubgraphRepository`. Infra: query all `VcpmInstance` WHERE `subgraphSystemId = X`; for each instance → for each `VcpmCkv` → stage DELETE on `VcpmParameterPayload` rows, `VcpmCkvValues` rows, the `VcpmCkv` row; then stage DELETE on the `VcpmInstance` row. Distinct from the existing `delete-vcpm-ckv` handler which deletes a single CKV entry. | +| OQ-3 | ~~`SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR` property ID~~ — **Resolved:** `SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR = 0x08001374`. | +| OQ-4 | ~~VSID payload construction inside cascade~~ — **Resolved:** Use `BinaryDataWriter` directly. `const writer = new BinaryDataWriter(); writer.writeUInt32(optimalVsid); writer.align(8); const payload = writer.toUint8Array();`. No need to go through `serializeParameterData` since the value is a computed `number`, not user-supplied `elements`. | +| OQ-5 | ~~Default payload for `addProperty`~~ — **Resolved:** Use `serializeDefaultParameterData(definition)` from `packages/core/src/application/usecase-designer/shared/serialize-elements.ts` (added in PR a54340d). It builds default `ElementData[]` from each `ConfigElement.defaultValue ?? '0'` then calls `serializeParameterData` internally. The `definition` is a `ParameterDefinitionBase` object (already available at all call sites). No new file required. | +| OQ-6 | ~~`getOptimalVsid` implementation~~ — **Resolved:** Private method on `UpdateSubgraphScenarioHandler`. Uses `getSubgraphIdsInSameUsecases` (already designed) for BFS, `getAggregate` to read each linked subgraph's scenario + VSID, filters to Voice only. If no voice subgraphs found: parse `vsidDef.elementsStructure` via `convertParamDefinition`, read `defaultValue` from the first `ConfigElement`, return `Number(defaultValue)`. If one distinct VSID found: use it. If multiple distinct VSIDs found: throw `DomainRuleViolationException` → 422. No shared service needed — `UpdateSubgraphVsidHandler` does not call this. | +| OQ-7 | ~~TKV and tagged module wipe~~ — **Resolved:** Full hierarchy per module: `ModuleTagIdMap` (tagged entries, `aggregateId=spfModuleSystemId`) → `Tkv` (`aggregateId=moduleTagIdMapSystemId`) → `TkvParameterPayload`. `TkvValues` and `CkvValues` are composite-PK join tables — they cascade DELETE automatically when their parent is deleted, no explicit write needed. Fetchers already exist: `CkvOverlayFetcher` for CKV reads, `TkvOverlayFetcher` for `ModuleTagIdMap` + TKV reads. `wipeCalData` infra steps: (1) fetch all CKVs via `CkvOverlayFetcher`, skip zero-CKV, DELETE payloads + CKV rows; (2) fetch all `ModuleTagIdMap` via `TkvOverlayFetcher`, DELETE `TkvParameterPayload` + `Tkv` + `ModuleTagIdMap` rows. All writes use `aggregateId = spfModuleSystemId` for CKV level and `aggregateId = moduleTagIdMapSystemId` for TKV level. | +| OQ-8 | ~~Zero-CKV default payload source for `wipeCalData`~~ — **Resolved:** The zero-CKV row (empty `CkvValues`) always exists — `wipeCalData` only deletes non-zero CKVs, so the zero-CKV survives. Step 5 resets each existing `CkvParameterPayload` row under the zero-CKV back to its factory default. No tool-policy filter needed — every payload row that exists under the zero-CKV was created at module creation and must be reset. Steps: (1) `ckvOverlayFetcher.fetchCkvPayloads(zeroCkv.systemId, moduleSystemId, sessionId)` → existing rows; (2) for each: `getModuleDefinitionRepository().getParameterDefinitions(moduleDefSystemId, [payload.parameterSystemId])` to get the definition; (3) `serializeDefaultParameterData(def)` → `writeDelta` on the existing payload row. | diff --git a/docs/property-data/design/subgraph-write-review-resolution-lld.md b/docs/property-data/design/subgraph-write-review-resolution-lld.md new file mode 100644 index 000000000..a0141f7a8 --- /dev/null +++ b/docs/property-data/design/subgraph-write-review-resolution-lld.md @@ -0,0 +1,465 @@ +# Subgraph Write Review Resolution — Low-Level Design + +**Status:** Draft for review +**Date:** 2026-09-02 +**Scope:** Reconcile the subgraph property/scenario write design with the rebased repository architecture. + +## 1. Context + +The original property-data design was written before the repository layer was rebased. The rebased implementation now has a routing-focused `SubgraphRepository` with these existing responsibilities: + +- `getSgkvs` +- `findByIds` +- `findIsMdfInScope` +- `findChangedInSession` +- scalar-only domain aggregate hydration through `hydrate(SubgraphBase)` + +The review resolution must preserve those APIs while addressing three concerns: + +1. Command handlers should not receive `QueryServices`. +2. Property reads must use the rebased overlay fetcher design. +3. Zero-CKV identification and default-payload generation belong in core; persistence applies the resulting write plan. + +Controller-specific review comments remain out of scope for this LLD. + +## 2. Requirements + +### Functional requirements + +| ID | Requirement | +|---|---| +| FR-01 | Subgraph command handlers receive only `UnitOfWork` and other non-query dependencies; they do not receive `QueryServices`. | +| FR-02 | Property reads used by command handlers include the active edit-session overlay, including session-created, updated, and deleted rows. | +| FR-03 | Existing routing repository methods and scalar-only `Subgraph` hydration remain unchanged. | +| FR-04 | Core identifies zero CKVs and generates factory-default payload bytes. | +| FR-05 | Persistence stages CKV/TKV deletes and zero-CKV payload updates supplied by core. It does not decide which CKV is zero or serialize defaults. | +| FR-06 | Existing zero-CKV rows retain their system IDs during calibration reset. Only non-zero CKVs are deleted; zero-CKV payloads are reset. | +| FR-07 | VCPM default-data creation receives already-serialized payloads from core; persistence only stages the VCPM hierarchy rows. | + +### Invariants + +**I1 — Overlay visibility:** A command in an active session must see its own pending changes. + +**I2 — Aggregate hydration stability:** Existing routing methods must continue returning scalar `Subgraph` aggregates without implicitly loading property data. + +**I3 — Transaction ownership:** The command handler owns transaction boundaries; repositories only stage writes using the current `UnitOfWork` context. + +**I4 — Zero-CKV identity:** Audio → Voice reset preserves the existing zero-CKV system ID. + +## 3. Target architecture + +**Current:** Command handlers use `QueryServices` for effective property and VCPM-definition reads. `TypeOrmModuleRepository.wipeCalData()` currently owns CKV classification, default-payload serialization, and the corresponding persistence writes. The scenario handler also references a VCPM default-data repository operation that is not present in the rebased repository port or adapter. + +**After change:** Command handlers use read/write ports exposed by the same `UnitOfWork`; core owns business decisions and serialization, and persistence applies explicit write plans. + +```text +Command handler + └─ UnitOfWork + ├─ SubgraphRepository + │ ├─ effective subgraph/property reads + │ ├─ property writes + │ └─ relationship traversal + ├─ SubgraphPropertyDefinitionRepository + │ └─ effective property-definition reads + ├─ ModuleRepository + │ ├─ effective calibration-state reads + │ └─ apply calibration reset plan + └─ VcpmDefinitionRepository + └─ effective VCPM definition reads + +Core domain/application services + ├─ serialize property/VSID/default payloads + ├─ identify zero CKV and build reset plans + └─ decide scenario/Voice/VCPM business outcomes + +Persistence adapters + ├─ SubgraphOverlayFetcher + SubgraphPropertyDataFetcher + ├─ CkvOverlayFetcher / TkvOverlayFetcher + └─ PendingChangeWriter +``` + +The controller continues to validate HTTP input and dispatch commands. It does not perform persistence reads. + +## 4. Core port changes + +### 4.1 Preserve rebased subgraph methods + +The following methods remain unchanged: + +**Current:** These routing and scalar-read methods already exist in the rebased repository: + +```ts +getSgkvs(...) +findByIds(...) +findIsMdfInScope(...) +findChangedInSession(...) +``` + +**After change:** Keep the same methods and behavior. No property data is loaded implicitly. + +The existing `hydrate(SubgraphBase)` remains the mapper for these methods. + +### 4.2 Add property-aware subgraph reads + +Add dedicated property read-model methods rather than changing `findByIds()` to load properties: + +**Current:** Effective property payloads are currently read through `QueryServices.subgraphQueryService.findPropertyPayloads()`, and property-definition metadata is currently read through `QueryServices.subgraphPropertyDefQueryService`. Separately, `findByIds()` returns scalar-only `Subgraph` aggregates and does not load property data. + +**After change:** Add separate property-aware methods to the UoW-bound subgraph repository for effective property payloads, while preserving the existing scalar-only contract. Keep property-definition metadata on a dedicated property-definition read port: + +```ts +findByIdWithProperties( + subgraphSystemId: number, + fileSystemId: number, +): Promise; + +findByIdsWithProperties( + subgraphSystemIds: readonly number[], + fileSystemId: number, +): Promise>; +``` + +`SubgraphWithProperties` remains a query/read model under: + +`packages/core/src/application/ports/persistence/query-services/subgraph-property-definition/subgraph-property-definition-with-elements-read-model.ts` + +It is not a replacement for the domain `Subgraph` aggregate. + +### 4.3 Add write-side property operations + +The subgraph repository port adds: + +**Current:** The effective property information needed by command handlers is currently read through `QueryServices`: + +- `QueryServices.subgraphQueryService.findPropertyPayloads()` reads effective property payloads. +- `QueryServices.subgraphPropertyDefQueryService.getSubgraphPropertyWithElements()` reads one effective property definition. +- `QueryServices.subgraphPropertyDefQueryService.getSubgraphPropertiesWithElements()` reads effective property definitions with element metadata. + +The write operations themselves are not part of `QueryServices`, because `QueryServices` is read-only. In the rebased `SubgraphRepository` port, `rename()`, `setPropertyData()`, and same-usecase relationship traversal are not currently declared. + +**After change:** Keep the effective-read responsibility behind UoW-bound ports used by command handlers, and add the write-side methods below to the subgraph repository port. `setPropertyData()` accepts already serialized payload bytes: + +```ts +rename(subgraphSystemId: number, name: string): Promise; + +setPropertyData( + subgraphSystemId: number, + propertySystemId: number, + payload: Uint8Array, +): Promise; + +getSubgraphIdsInSameUsecases( + subgraphSystemId: number, + fileSystemId: number, +): Promise; +``` + +`setPropertyData` receives final bytes. It does not receive a property definition and does not serialize data. + +### 4.4 Replace command-handler query-service dependencies + +The required reads currently supplied by `QueryServices` need UoW-bound repository ports: + +- `SubgraphRepository` supplies effective subgraph and property-data reads needed by subgraph commands. +- A dedicated `SubgraphPropertyDefinitionRepository` supplies effective property-definition metadata, including element structures needed for serialization. +- A dedicated `VcpmDefinitionRepository` port supplies effective VCPM definitions. VCPM definitions should not be placed on `SubgraphRepository` merely to avoid adding a port. +- `ModuleRepository` supplies effective CKV reset input and applies CKV/TKV reset operations. + +The `UnitOfWork` exposes these repositories, and the command registry constructs handlers with `deps.uow` only. + +### 4.5 Add the core CKV reset-plan function + +**Current:** No core CKV reset function exists. `TypeOrmModuleRepository.wipeCalData()` currently reads CKVs and payloads, identifies the zero CKV, serializes default payloads, and stages the writes. + +**After change:** Add a stateless pure function at: + +`packages/core/src/application/usecase-designer/subgraph/update-scenario/create-ckv-reset-plan.ts` + +The function reuses the existing core read models from: + +`packages/core/src/application/ports/persistence/query-services/spf-module/tuning/tuning-config-read-model.ts` + +- `CkvReadModel` identifies the CKV and its key-value pairs. +- `CkvParamReadModel` identifies each payload row and includes its parameter definition and `elementsStructure`. + +The combined input groups one CKV with its parameter payloads: + +**Current:** The existing repository fetcher models are split: CKV rows come from `fetchForModule()`, and payload rows come from `fetchCkvPayloads()`. + +**After change:** The repository maps those existing models into the combined input, and the core function creates the plan: + +```ts +import type { + CkvReadModel, + CkvParamReadModel, +} from '../../../ports/persistence/query-services/spf-module/tuning/tuning-config-read-model.js'; +import {serializeDefaultParameterData} from '../../shared/serialize-elements.js'; + +export interface CkvResetCkvInput { + ckv: CkvReadModel; + payloads: readonly CkvParamReadModel[]; +} + +export type CkvResetInput = readonly CkvResetCkvInput[]; + +export interface CkvResetPlan { + zeroCkvSystemId: number; + nonZeroCkvSystemIds: number[]; + nonZeroCkvPayloadSystemIds: number[]; + zeroCkvPayloadUpdates: Array<{ + payloadSystemId: number; + payload: Uint8Array; + }>; +} + +export function createCkvResetPlan( + input: CkvResetInput, +): CkvResetPlan { + const zeroCkvs = input.filter( + item => item.ckv.keyValuePairs.length === 0, + ); + + if (zeroCkvs.length !== 1) { + throw new Error( + `Expected exactly one zero CKV, found ${zeroCkvs.length}`, + ); + } + + const zeroCkv = zeroCkvs[0]!; + const nonZeroCkvs = input.filter( + item => item.ckv.keyValuePairs.length > 0, + ); + + const nonZeroCkvSystemIds = nonZeroCkvs.map( + item => item.ckv.systemId, + ); + + const nonZeroCkvPayloadSystemIds = nonZeroCkvs.flatMap( + item => item.payloads.map(payload => payload.systemId), + ); + + const zeroCkvPayloadUpdates = zeroCkv.payloads.map(payload => { + const definition = payload.definition; + + if (!definition.elementsStructure) { + throw new Error( + `Missing elementsStructure for parameter ${definition.systemId}`, + ); + } + + const serialized = serializeDefaultParameterData({ + systemId: definition.systemId, + elementsStructure: definition.elementsStructure, + }); + + if (!serialized.ok) { + throw new Error( + `Failed to serialize default payload for parameter ${definition.systemId}`, + ); + } + + return { + payloadSystemId: payload.systemId, + payload: serialized.value, + }; + }); + + return { + zeroCkvSystemId: zeroCkv.ckv.systemId, + nonZeroCkvSystemIds, + nonZeroCkvPayloadSystemIds, + zeroCkvPayloadUpdates, + }; +} +``` + +The function does not access a database, `QueryServices`, TypeORM, or a persistence fetcher. It only converts effective CKV input into a reset plan. + +### 4.6 Call the pure function from the scenario handler + +**Current:** `UpdateSubgraphScenarioHandler.wipeModuleCalData()` calls `ModuleRepository.wipeCalData()`, which performs both business decisions and persistence writes. + +**After change:** The handler obtains effective reset input through the UoW-bound module repository, calls the pure core function, and passes the resulting plan back to persistence: + +```ts +import {createCkvResetPlan} from './create-ckv-reset-plan.js'; + +const moduleRepository = this.uow.getModuleRepository(); + +const resetInput = await moduleRepository.getCkvResetInput( + mod.systemId, + fileSystemId, +); + +const resetPlan = createCkvResetPlan(resetInput); + +await moduleRepository.wipeAllCkvData( + mod.systemId, + resetPlan, +); + +await moduleRepository.wipeAllTkvData(mod.systemId); +``` + +`MutationLog` remains a separate response accumulator for the scenario result. It is not used to make CKV reset decisions and is not passed to persistence as the reset plan. The current `moduleCkvsAdded` response name should be reviewed later because the existing zero CKV is preserved and reset, not added. + +## 5. Persistence adapter changes + +### 5.1 Property fetcher wiring + +`SubgraphOverlayFetcher` already supports `SubgraphPropertyDataFetcher`. The TypeORM repository must pass the existing fetcher instance when constructing it: + +**Current:** The repository constructs `SubgraphOverlayFetcher` without the property-data fetcher, so this repository instance cannot retrieve effective property rows through the overlay fetcher. + +**After change:** Construct the existing `SubgraphPropertyDataFetcher` and inject it into `SubgraphOverlayFetcher`: + +```ts +const propertyDataFetcher = new SubgraphPropertyDataFetcher( + manager, + editActionsQueryService, +); + +this.subgraphFetcher = new SubgraphOverlayFetcher( + manager, + editActionsQueryService, + propertyDataFetcher, + this.sgkvFetcher, +); +``` + +No new property-fetching abstraction is required. + +### 5.2 Property-aware reads + +`findByIdWithProperties()` delegates to `subgraphFetcher.fetchOne()` and maps the effective `properties` array to `SubgraphWithProperties`. + +`findByIdsWithProperties()` must assemble scalar rows and effective property rows without changing the existing scalar-only `fetchMany()` contract. It may use a dedicated batch fetcher method or fetch and group property rows separately. + +### 5.3 CKV reset input and plan application + +**Current:** `TypeOrmModuleRepository.wipeCalData()` directly calls `CkvOverlayFetcher`, fetches parameter definitions, classifies CKVs, serializes defaults, and stages CKV/TKV writes. + +**After change:** Add the following UoW-bound module repository operations: + +```ts +getCkvResetInput( + moduleSystemId: number, + fileSystemId: number, +): Promise; + +wipeAllCkvData( + moduleSystemId: number, + resetPlan: CkvResetPlan, +): Promise; + +wipeAllTkvData(moduleSystemId: number): Promise; +``` + +`getCkvResetInput()` uses the existing overlay fetchers and maps the effective CKV rows plus full parameter payload read models into `CkvResetInput`. `wipeAllCkvData()` applies the plan by deleting non-zero CKV payloads and rows, then updating the existing zero-CKV payload rows. It does not classify CKVs or serialize defaults. `wipeAllTkvData()` handles TKV/tag data separately. + +### 5.4 Property writes + +`setPropertyData()`: + +1. Reads the effective subgraph through `fetchOne()`. +2. Resolves the property-data row by `propertySystemId`. +3. Uses the resolved row `systemId` as the `edit_actions.targetSystemId`. +4. Stages `{ payload }` through `PendingChangeWriter`. + +It must not query only base tables because the property row may have been created or changed earlier in the same session. + +### 5.5 Name and relationship writes/reads + +- `rename()` stages a `Subgraph` name delta. +- `getSubgraphIdsInSameUsecases()` retains the existing relationship query behavior and excludes zero-GKV usecases and the source subgraph. +- Existing SGKV/routing methods are retained without behavior changes. + +## 6. Zero-CKV design + +### 6.1 Current problem + +`ModuleRepository.wipeCalData()` currently combines: + +- effective CKV/TKV reads; +- zero/non-zero CKV classification; +- parameter-definition lookup; +- default-payload serialization; +- delete and delta writes. + +The current scenario handler requests VCPM definitions through `QueryServices.vcpmDefinitionQueryService` and then calls `addVcpmCfgDefaultData()` on `SubgraphRepository`. However, the rebased `SubgraphRepository` port and `TypeOrmSubgraphRepository` adapter do not currently declare or implement `addVcpmCfgDefaultData()`. The VCPM default-data persistence path therefore still needs to be introduced in the target design. + +### 6.2 Core reset plan + +The pure `createCkvResetPlan()` function defined in §4.5 builds the explicit reset plan. The plan contains the identity and payload data persistence needs: + +**Current:** Persistence currently classifies CKVs and serializes zero-CKV defaults while performing the delete/update writes. + +**After change:** Core classifies CKVs and creates the serialized reset payloads; persistence receives and applies the plan defined in §4.5. + +The core function: + +1. Loads effective calibration state through a port. +2. Classifies the zero CKV using the domain rule that it has no key values. +3. Selects non-zero CKVs for deletion. +4. Serializes factory defaults for each existing zero-CKV payload. +5. Returns the reset plan. + +### 6.3 Persistence application + +`ModuleRepository.wipeAllCkvData(moduleSystemId, resetPlan)` applies the CKV plan by: + +- deleting non-zero CKV payloads and CKV rows in FK order; +- updating existing zero-CKV payload rows with the supplied bytes. + +`ModuleRepository.wipeAllTkvData(moduleSystemId)` separately deletes TKV/tagged calibration data. + +Persistence does not classify CKVs or call `serializeDefaultParameterData()`. + +### 6.4 VCPM default data + +Core builds VCPM default entries with serialized payloads: + +**Current:** The scenario handler gets VCPM definitions from `QueryServices.vcpmDefinitionQueryService` and passes them to `SubgraphRepository.addVcpmCfgDefaultData()`. In the rebased repository code, that subgraph-repository method is not yet part of the port or adapter, so there is no completed current persistence implementation for this path. + +**After change:** Core supplies serialized payloads in the default-data plan, and a UoW-bound persistence repository only stages the hierarchy rows: + +```ts +interface VcpmDefaultData { + definitionSystemId: number; + parameters: Array<{ + parameterSystemId: number; + payload: Uint8Array; + }>; +} +``` + +The persistence repository receives these entries and stages: + +1. `VcpmInstance` CREATE; +2. zero-CKV `VcpmCkv` CREATE; +3. `VcpmParameterPayload` CREATE rows. + +It does not derive payload bytes from parameter definitions. + +## 7. Transaction and error handling + +- Scenario transitions start one transaction before the cascade. +- All repository writes use the same `UnitOfWork` context. +- Any failure rolls back the transaction. +- A missing effective row is reported as a domain/application error by the handler or service; persistence does not expose TypeORM details. + +## 8. Review-comment mapping + +| Review/design concern | Resolution in this LLD | +|---|---| +| Remove `QueryServices` from command handlers | Add required effective reads to UoW-bound repository ports. | +| Property data must use the rebased subgraph fetcher | Wire `SubgraphPropertyDataFetcher` into `SubgraphOverlayFetcher` and use overlay-aware property reads. | +| Zero-CKV logic belongs in core | The pure `createCkvResetPlan()` function classifies zero CKVs and serializes defaults; persistence applies the explicit plan. | + +## 9. Out of scope + +- Controller-specific endpoint mapping comments. +- `Update` versus `Put`/`Patch` command naming. +- Database schema or migration changes. +- Replacing the rebased routing repository APIs. +- Test execution in this review session. diff --git a/docs/property-data/get-subgraph-properties-design.md b/docs/property-data/get-subgraph-properties-design.md index 0a2947a9b..134ce2770 100644 --- a/docs/property-data/get-subgraph-properties-design.md +++ b/docs/property-data/get-subgraph-properties-design.md @@ -121,7 +121,7 @@ Client → Result.fail → throws → Result.ok(null) → ResourceNotFoundException → 404 → Result.ok(PropertyPayloadReadModel[]) → subgraph exists + property payloads - Step 4: getAllDetailedSubgraphPropertyDefinitionsWithElements(fileSystemId) + Step 4: getSubgraphPropertiesWithElements(fileSystemId) → SubgraphPropertyDefinitionWithElementsReadModel[] Step 5: defMap = Map buildPropertyModels(payloads, defMap) @@ -221,7 +221,7 @@ export class GetSubgraphPropertiesHandler // Step 4: fetch definitions with elementsStructure const definitionsResult = await this.queryServices.subgraphPropertyDefQueryService - .getAllDetailedSubgraphPropertyDefinitionsWithElements(fileSystemId); + .getSubgraphPropertiesWithElements(fileSystemId); if (definitionsResult.kind === RESULT_KIND.Fail) { throw new Error(definitionsResult.issues[0]?.message ?? 'Failed to load subgraph property definitions'); } diff --git a/docs/property-data/requirements/update-subgraph-container-id-requirements.md b/docs/property-data/requirements/update-subgraph-container-id-requirements.md new file mode 100644 index 000000000..04bfa1581 --- /dev/null +++ b/docs/property-data/requirements/update-subgraph-container-id-requirements.md @@ -0,0 +1,182 @@ + + +# Replace Subgraph Container ID: Requirements + +**Date:** 2026-09-12 +**Status:** Draft + +--- + +## 1. Context + +### 1.1 Problem statement + +Clients need to replace one container natural ID with another for the modules +in a subgraph. A client cannot supply a container `systemId` for a container +that does not yet exist because that ID is assigned internally by the system. + +### 1.2 What this builds on + +This requirement covers only the subgraph container-ID endpoint described in +the existing property-data API design. That design document is not modified by +this requirement. Where the two documents differ for this endpoint, this +document is the requirements source for the endpoint. + +### 1.3 Key decisions already made + +- The endpoint uses `PUT` because it fully replaces the specified container ID + assignment. +- The client supplies natural IDs, never persistence `systemId` values. +- A successful request returns `204 No Content`. + +--- + +## 2. Definitions + +| Term | Definition | +|---|---| +| Container natural ID | The integer `containerId` from the ACDB domain model. It is meaningful to clients and is distinct from `systemId`. | +| Container system ID | The internally assigned persistence identifier. The server resolves or assigns it; clients do not submit it to this endpoint. | +| Source container | The container identified by `oldContainerNaturalId`. | +| Target container | The existing or newly created container identified by `newContainerNaturalId`. | +| Active file scope | The file/database scope selected by the authenticated active session. | + +--- + +## 3. Functional Requirements + +### 3.1 API contract + +#### FR-SCI-01: Replace a subgraph container ID + +The system SHALL expose: + +```http +PUT /arc-api/v1/projects/:projectId/subgraphs/:subgraphSystemId/container-id +``` + +The endpoint SHALL require an authenticated active session. + +#### FR-SCI-02: Request body uses natural IDs + +The request body SHALL contain the integer ACDB natural IDs of both the source +and target containers: + +```json +{ + "oldContainerNaturalId": 100, + "newContainerNaturalId": 200 +} +``` + +The endpoint SHALL NOT accept a target `systemId` as client input. + +### 3.2 Source and target resolution + +#### FR-SCI-03: Source container lookup + +The system SHALL locate the source container by `oldContainerNaturalId` within +the requested subgraph and active file scope. If either the subgraph or source +container does not exist in that scope, the endpoint SHALL return `404 Not +Found` and SHALL make no changes. + +#### FR-SCI-04: Existing target container compatibility + +When a target container with `newContainerNaturalId` already exists in the +active file scope, the system SHALL update the container ID of every module in +the requested subgraph that currently belongs to the source container only when +the target container's capability ID and processor ID both match those of the +source container. + +If either value does not match, the endpoint SHALL return `422 Unprocessable +Entity` and SHALL make no changes. + +#### FR-SCI-05: Create a missing target container + +When no target container with `newContainerNaturalId` exists in the active file +scope, the system SHALL add a target container with that natural ID to the +system, resolve or assign its internal `systemId`, and update the container ID +of every module in the requested subgraph that currently belongs to the source +container. + +### 3.3 Result semantics + +#### FR-SCI-06: Replace all source-container module assignments + +On success, the system SHALL replace the source container assignment for all +modules in the requested subgraph that belong to the source container. Modules +associated with other containers in the same subgraph SHALL remain unchanged. + +#### FR-SCI-07: Idempotent identical-ID request + +When `oldContainerNaturalId` and `newContainerNaturalId` are identical, the +system SHALL perform no database mutation and return `204 No Content`. + +#### FR-SCI-08: Success response + +After a successful replacement or the no-op case, the endpoint SHALL return +`204 No Content`. + +### 3.4 Validation and atomicity + +#### FR-SCI-09: Request validation + +The system SHALL reject a request missing either natural ID or containing a +non-integer ID with `400 Bad Request`. + +#### FR-SCI-10: Atomic update + +The target lookup or creation and every affected module assignment update SHALL +complete atomically. On failure, no affected module assignment or target +container creation SHALL persist. + +#### FR-SCI-11: Session enforcement + +When no valid active session is present, the endpoint SHALL return `401 +Unauthorized`. + +--- + +## 4. Invariants + +**I1 — Client-facing identity:** A client request for this endpoint never +depends on a persistence `systemId` for either container. + +**I2 — Scoped replacement:** A successful request changes only modules in the +specified subgraph that are assigned to the source container. + +**I3 — Compatible reuse:** An existing target container can be used only when +both its capability ID and processor ID match the source container. + +**I4 — All-or-nothing:** A failed request leaves source assignments and target +container persistence unchanged. + +--- + +## 5. Non-Functional Requirements + +**NFR-SCI-01:** Standard transactional database performance is sufficient; +there is no additional latency or scale target for this endpoint. + +--- + +## 6. Out of Scope + +- Updating the existing `docs/property-data/design/property-write-api-design.md`. +- Moving individual modules independently of their source container. +- Replacing container assignments for modules belonging to other containers in + the same subgraph. +- Accepting or exposing internally assigned container `systemId` values in the + request body. +- Defining read APIs or changes to other property endpoints. + +--- + +## 7. Open Questions + +**OQ-SCI-01: Empty source container lifecycle.** After every affected module +has moved to the target container, should an empty source container be retained +or deleted? This requirement intentionally leaves that decision open. diff --git a/docs/property-data/set-subgraph-property-requirements.md b/docs/property-data/set-subgraph-property-requirements.md new file mode 100644 index 000000000..2418fec4a --- /dev/null +++ b/docs/property-data/set-subgraph-property-requirements.md @@ -0,0 +1,345 @@ + + +# Requirements: Subgraph Property Write API + +**Feature folder:** `docs/property-data/` +**Status:** DRAFT +**Date:** 2026-08-24 +**Reference:** `docs/property-data/design/property-write-api-design.md` + +--- + +## Context + +Write endpoints for managing subgraph properties in the AudioReach usecase designer. Subgraph properties fall into two categories: + +- **Simple properties** — direct writes with no cascade (name, generic SPF/driver properties including sgtype). +- **Cascading properties** — writes that trigger side effects across the subgraph or across linked subgraphs (scenario, VSID). + +All writes are staged and not applied to the canonical data until the caller invokes commit. + +--- + +## Definitions + +| Term | Meaning | +|---|---| +| SubgraphPropertyData | One row per property per subgraph. | +| PropertyDefinition | Defines a property — its name, valid values, and element structure. | +| SPF property | A gecko/SPF-layer subgraph config property (PROPERTY_TYPE.Spf). | +| Driver property | A GSL/driver-layer subgraph property (PROPERTY_TYPE.Driver). | +| Scenario | SPF property `SUB_GRAPH_PROP_ID_SCENARIO_ID` — determines if the subgraph is Audio or Voice. | +| VSID | SPF property `SUB_GRAPH_PROP_ID_VSID` — Voice Session ID; shared across voice subgraphs linked by the same GKV. | +| Subgraph Type | Driver property `SUBGRAPH_TYPE_DRIVER_PROP_ID` — determines if the subgraph is Stream, Device, or None. | +| Staged write | A pending change visible via overlay read but not yet committed to the canonical table. | + +--- + +## FR-SG-NAME — PATCH /subgraphs/:id/name + +### FR-SG-NAME-01 — Endpoint definition + +`PATCH /arc-api/v1/projects/:projectId/subgraphs/:subgraphSystemId/name` + +Request body: + +```json +{ "name": "string" } +``` + +An empty string is a valid value and clears the name. + +### FR-SG-NAME-02 — Subgraph existence + +`subgraphSystemId` must refer to a non-deleted subgraph in the session's file. → `404` if not found. + +### FR-SG-NAME-03 — Staged write + +The name update must be staged. Visible immediately via overlay read before commit. + +### FR-SG-NAME-04 — Response + +Handler returns `{ groupId: string }`. Controller re-queries via `GetSubgraphPropertiesQuery` +and returns `SubgraphPropertiesResponseDto`. → `200`. + +--- + +## FR-SG-PROP — PATCH /subgraphs/:id/properties/:propSystemId + +### FR-SG-PROP-01 — Endpoint definition + +`PATCH /arc-api/v1/projects/:projectId/subgraphs/:subgraphSystemId/properties/:propertySystemId` + +Request body: + +```json +{ + "elements": ParameterElementSummaryDto[] +} +``` + +Example for a simple SPF property: + +```json +{ + "elements": [ + { + "type": "ConfigElement", + "name": "direction", + "value": "1" + } + ] +} +``` + +### FR-SG-PROP-02 — Subgraph existence + +`subgraphSystemId` must refer to a non-deleted subgraph in the session's file. → `404` if not found. + +### FR-SG-PROP-03 — Property definition existence + +`propertySystemId` must refer to a property definition in the session's file. → `404` if not found. + +### FR-SG-PROP-04 — Reserved property guard + +If `propertySystemId` maps to any of the following reserved properties, the request must be +rejected → `400` with a message naming the correct dedicated endpoint: + +| Property | Dedicated endpoint | +|---|---| +| Scenario (`SUB_GRAPH_PROP_ID_SCENARIO_ID`) | `PATCH /subgraphs/:id/scenario` | +| VSID (`SUB_GRAPH_PROP_ID_VSID`) | `PATCH /subgraphs/:id/vsid` | + +Guard is enforced in the command handler, not the controller. + +### FR-SG-PROP-05 — Staged write + +The property update must be staged. Visible immediately via overlay read before commit. + +### FR-SG-PROP-06 — Response + +Handler returns `{ groupId: string }`. Controller re-queries via `GetSubgraphPropertiesQuery` +and returns `PropertyResponseDto` for the single updated property. → `200`. + +--- + +## FR-SG-SCENARIO — PATCH /subgraphs/:id/scenario + +### FR-SG-SCENARIO-01 — Endpoint definition + +`PATCH /arc-api/v1/projects/:projectId/subgraphs/:subgraphSystemId/scenario` + +Request body uses the same elements format as the generic property endpoint: + +```json +{ + "elements": [ + { + "type": "ConfigElement", + "name": "scenario_id", + "value": "3" + } + ] +} +``` + +Where `value` is the uint32 scenario ID as a string. The handler resolves the scenario +type (Audio vs Voice) internally from the value and determines whether a cascade is needed. + +### FR-SG-SCENARIO-02 — Subgraph existence + +`subgraphSystemId` must refer to a non-deleted subgraph in the session's file. → `404` if not found. + +### FR-SG-SCENARIO-03 — No-op on same scenario + +If the subgraph's current scenario already matches the requested value, the handler +returns immediately with no writes. → `200` with an empty mutation log. + +### FR-SG-SCENARIO-04 — Audio → Voice cascade + +When the current scenario is Audio and the requested scenario is Voice, the following steps +are applied in order. All steps are atomic — either all succeed or none are applied: + +1. **Add VCPM module definitions** — ensure VCPM module definitions are present in the session. +2. **Find optimal VSID** — BFS across all GKVs linked to this subgraph to find a consistent + VSID from other voice subgraphs in the same usecases. If none found, use the property + definition's default. If conflicting VSIDs are found across usecases, the request is + rejected → `422` with a message describing the conflict. +3. **Add voice-specific SPF properties** — for each property definition where `IsVoice = true`, + add the property with its default payload. +4. **Remove audio-specific SPF properties** — remove the clock scale factor property + (`SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR`) if present. +5. **Set VSID** — write the optimal VSID found in step 2. +6. **Wipe all module CKV/TKV cal data** — for every non-deleted module in the subgraph: + - Remove all configured (non-zero) CKV cal data. + - Remove all TKV cal data. + - Remove all tagged module entries. + - Restore zero-CKV default cal data for each calibration parameter. +7. **Add default VCPM cfg data** — for each VCPM cfg parameter definition, add a zero-CKV + entry with its default payload. +8. **Update scenario property** — write the new scenario ID. + +### FR-SG-SCENARIO-05 — Voice → Audio cascade + +When the current scenario is Voice and the requested scenario is Audio, the following steps +are applied in order. All steps are atomic: + +1. **Wipe all module CKV/TKV cal data** — same as FR-SG-SCENARIO-04 step 6. +2. **Remove voice-specific SPF properties** — remove all properties where `IsVoice = true`. +3. **Add audio-specific SPF properties** — add the clock scale factor property + (`SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR`) with its default payload. +4. **Remove all VCPM cfg data** — remove all VCPM CKV entries and their parameter payloads. +5. **Update scenario property** — write the new scenario ID. + +### FR-SG-SCENARIO-06 — Response + +Handler returns `{ groupId: string }` plus a structured mutation log. Controller maps +directly to `UpdateScenarioResponseDto` — no re-query needed. + +```json +{ + "groupId": "string", + "propertiesAdded": [{ "systemId": "string", "propertyId": 0, "propertyName": "string" }], + "propertiesRemoved": [{ "systemId": "string", "propertyId": 0, "propertyName": "string" }], + "moduleCkvsAdded": [{ "moduleSystemId": "string", "ckvSystemId": "string" }], + "moduleCkvsDeleted": [{ "moduleSystemId": "string", "ckvSystemId": "string" }] +} +``` + +→ `200`. + +--- + +## FR-SG-VSID — PATCH /subgraphs/:id/vsid + +### FR-SG-VSID-01 — Endpoint definition + +`PATCH /arc-api/v1/projects/:projectId/subgraphs/:subgraphSystemId/vsid` + +Request body uses the same elements format as the generic property endpoint: + +```json +{ + "elements": [ + { + "type": "ConfigElement", + "name": "vsid", + "value": "196609" + } + ] +} +``` + +Where `value` is the uint32 VSID as a string. + +### FR-SG-VSID-02 — Subgraph existence + +`subgraphSystemId` must refer to a non-deleted subgraph in the session's file. → `404` if not found. + +### FR-SG-VSID-03 — No-op on same value + +If the subgraph's current VSID already matches the incoming value, the handler returns +immediately with no writes. → `200` with an empty `affectedSubgraphSystemIds` list. + +### FR-SG-VSID-04 — BFS propagation + +VSID always propagates. The handler performs a BFS across all GKVs linked to the subgraph +to find all other voice subgraphs in the same usecases. The new VSID is written to the +target subgraph and all BFS-discovered subgraphs as one atomic operation. Zero-GKV +usecases are skipped. + +### FR-SG-VSID-05 — Staged write + +All VSID writes are staged as one atomic operation sharing the same `groupId`. Visible +immediately via overlay read before commit. + +### FR-SG-VSID-06 — Response + +Handler returns `{ groupId: string }` plus the list of all affected subgraph system IDs. +Controller maps directly to `UpdateVsidResponseDto` — no re-query needed. + +```json +{ + "groupId": "string", + "affectedSubgraphSystemIds": ["string"] +} +``` + +→ `200`. + +--- + +## Cross-Cutting Requirements + +### FR-CCR-01 — Edit session required + +All write endpoints require an active edit session for the project. → `403` if no session +is open. + +### FR-CCR-02 — Session mode + +All write endpoints are allowed in `DESIGNER` and `DIFF_MERGE` session modes only. → `403` +if the session mode is `TUNING` or `DISCOVERY_WIZARD`. + +### FR-CCR-03 — Staging model + +All writes are staged. Changes are not committed to the canonical tables until the caller +invokes `PATCH /projects/:projectId/commit`. + +### FR-CCR-04 — Session overlay for reads + +Staged (uncommitted) writes must be reflected in responses immediately after the write. + +### FR-CCR-05 — groupId in all responses + +Every write endpoint returns a `groupId` in its response. The `groupId` is the atomic +handle for the API call — all `edit_actions` rows produced within the call share the same +`groupId`. The client uses this for undo/redo and stage/unstage operations. + +### FR-CCR-06 — Reserved property guard + +`PATCH /subgraphs/:id/properties/:propSystemId` must reject requests where +`propertySystemId` maps to a reserved property (scenario, VSID). Guard is enforced in +the command handler, not the controller. → `400` with a message naming the correct +dedicated endpoint. + +--- + +## Invariants + +| # | Invariant | +|---|---| +| I1 | A property written multiple times in the same session results in only one effective value — the latest write wins. | +| I2 | Scenario cascade and the triggering scenario property write are atomic — either all steps succeed or none are applied. | +| I3 | VSID propagation and all BFS-discovered subgraph writes are atomic — either all subgraphs are updated or none are. | +| I4 | The generic property endpoint cannot write reserved property IDs — scenario and VSID always go through their dedicated endpoints. | + +--- + +## Error Codes Summary + +| Scenario | HTTP Code | +|---|---| +| Subgraph not found | 404 | +| Property definition not found | 404 | +| No active session | 403 | +| Session mode not allowed | 403 | +| Generic property write targeting a reserved property ID | 400 | +| VSID conflict across linked usecases | 422 | +| Scenario VSID conflict during Audio → Voice cascade | 422 | + +--- + +## Out of Scope + +- **Subgraph type (`SUBGRAPH_TYPE_DRIVER_PROP_ID`)** — treated as a normal property, updated via `PATCH /subgraphs/:id/properties/:propSystemId`. No cascade, no dedicated endpoint. +- **ASoC properties (`ASoC_STREAM_PROPERTY`, `ASoC_DEVICE_PROPERTY`)** — completely out of scope. No read or write endpoints in this feature. +- **UI cache properties** (`AddUpdateSubGraphUiCacheProperty`, `RemoveSubGraphUiCacheProperty`) — no write endpoints needed. +- **VCPM CKV endpoints** — covered separately in the design doc (FR#5–FR#7). +- **Container ID change** — handled by `PATCH /subgraphs/:id/container-id`. +- **Subgraph export / import** — separate workflow. +- **VMID get / set / reset** — separate workflow. +- **Commit / undo / redo** — session lifecycle handled by the modification framework. diff --git a/packages/api/src/infrastructure-wrapper/persistence/unit-of-work/typeorm-unit-of-work.ts b/packages/api/src/infrastructure-wrapper/persistence/unit-of-work/typeorm-unit-of-work.ts index 4f9706def..3dd3841bb 100644 --- a/packages/api/src/infrastructure-wrapper/persistence/unit-of-work/typeorm-unit-of-work.ts +++ b/packages/api/src/infrastructure-wrapper/persistence/unit-of-work/typeorm-unit-of-work.ts @@ -19,6 +19,7 @@ import type { ControlLinkRepository, SubgraphRepository, SubsystemRepository, + VcpmDefinitionRepository, UsecaseRepository, } from '@arc/core'; import type {QueryRunner, EntityManager} from 'typeorm'; @@ -35,6 +36,7 @@ import { TypeOrmControlLinkRepository, TypeOrmSubgraphRepository, TypeOrmSubsystemRepository, + TypeOrmVcpmDefinitionRepository, TypeOrmUsecaseRepository, PendingChangeWriter, EditActionsQueryService, @@ -180,6 +182,7 @@ export class TypeOrmUnitOfWork implements UnitOfWork { this.getPendingChangeWriter(), this.queryRunner.manager, this, + this.idGeneration, ); } @@ -200,6 +203,15 @@ export class TypeOrmUnitOfWork implements UnitOfWork { ); } + getVcpmDefinitionRepository(): VcpmDefinitionRepository { + return new TypeOrmVcpmDefinitionRepository( + this.getPendingChangeWriter(), + this.queryRunner.manager, + this, + this.idGeneration, + ); + } + // ── Existing repositories ───────────────────────────────────────────────── getBulkImportRepository(): BulkImportRepository { diff --git a/packages/api/src/presentation/rest/modules/subgraph/subgraph.controller.ts b/packages/api/src/presentation/rest/modules/subgraph/subgraph.controller.ts index b4fb285d6..8d38c4f30 100644 --- a/packages/api/src/presentation/rest/modules/subgraph/subgraph.controller.ts +++ b/packages/api/src/presentation/rest/modules/subgraph/subgraph.controller.ts @@ -64,10 +64,11 @@ import { CommandBus, GetComponentsQuery, GetSubgraphPropertiesQuery, - UpdateSubgraphScenarioCommand, - UpdateSubgraphVsidCommand, - PatchSubgraphCommand, - UpdateSubgraphPropertyCommand, + GetSubgraphPropertyQuery, + SetSubgraphScenarioCommand, + SetSubgraphVsidCommand, + SetSubgraphCommand, + SetSubgraphPropertyCommand, UpdateSubgraphContainerIdCommand, GetVcpmCkvQuery, GetVcpmCalDataQuery, @@ -75,6 +76,7 @@ import { DeleteVcpmCkvCommand, UpdateVcpmCalDataCommand, Result, + mapPropertyToDto, type ActiveSession, COMPONENT_SCOPE_TYPE, type ComponentCollectionDto as CoreComponentCollectionDto, @@ -83,6 +85,7 @@ import { type VcpmCkvDto, type CreateVcpmCkvDto, type CkvCalDataDto, + type PropertyDataDto, } from '@arc/core'; /** * Controller to support all subgraph related APIs for usecase design. @@ -286,7 +289,7 @@ export class SubgraphController extends BaseController { /** * Set scenario property for a subgraph (Audio/Voice). */ - @Patch('/:subgraphSystemId/scenario') + @Put('/:subgraphSystemId/scenario') @ApiParam({ name: 'subgraphSystemId', required: true, @@ -302,7 +305,7 @@ export class SubgraphController extends BaseController { responses: [ { status: HttpStatus.OK, - description: 'Scenario updated', + description: 'Scenario replaced', dto: UpdateScenarioResponseDto, }, { @@ -311,7 +314,7 @@ export class SubgraphController extends BaseController { }, { status: HttpStatus.UNPROCESSABLE_ENTITY, - description: 'Failed to update scenario', + description: 'Failed to replace scenario', }, ], }) @@ -321,7 +324,7 @@ export class SubgraphController extends BaseController { @ArcSession() session: ActiveSession, ): Promise> { const result = await this.commandBus.execute( - new UpdateSubgraphScenarioCommand(subgraphSystemId, [dto]), + new SetSubgraphScenarioCommand(subgraphSystemId, dto.elements), session, ); return toApiResult(Result.ok(result)); @@ -330,7 +333,7 @@ export class SubgraphController extends BaseController { /** * Set VSID for a subgraph — propagates via BFS to all connected subgraphs. */ - @Patch('/:subgraphSystemId/vsid') + @Put('/:subgraphSystemId/vsid') @ApiParam({ name: 'subgraphSystemId', required: true, @@ -345,7 +348,7 @@ export class SubgraphController extends BaseController { responses: [ { status: HttpStatus.OK, - description: 'VSID updated', + description: 'VSID replaced', dto: UpdateVsidResponseDto, }, { @@ -354,7 +357,7 @@ export class SubgraphController extends BaseController { }, { status: HttpStatus.UNPROCESSABLE_ENTITY, - description: 'Failed to update VSID', + description: 'Failed to replace VSID', }, ], }) @@ -364,16 +367,16 @@ export class SubgraphController extends BaseController { @ArcSession() session: ActiveSession, ): Promise> { const result = await this.commandBus.execute( - new UpdateSubgraphVsidCommand(subgraphSystemId, [dto]), + new SetSubgraphVsidCommand(subgraphSystemId, dto.elements), session, ); return toApiResult(Result.ok(result)); } /** - * Patch a subgraph — currently supports `name`. + * Set subgraph properties — currently supports `name`. */ - @Patch('/:subgraphSystemId') + @Put('/:subgraphSystemId') @ApiParam({ name: 'subgraphSystemId', required: true, @@ -382,7 +385,7 @@ export class SubgraphController extends BaseController { }) @UseGuards(SessionGuard) @ApiDocumentationWithExample({ - summary: 'Patch a subgraph', + summary: 'Set subgraph properties', requestDto: PatchSubgraphRequestDto, responses: [ { @@ -400,26 +403,31 @@ export class SubgraphController extends BaseController { }, ], }) - async patchSubgraph( - @Param('projectId') _projectId: string, + async setSubgraph( + @Param('projectId') projectId: string, @Param('subgraphSystemId', ParseIntPipe) subgraphSystemId: number, @Body() dto: PatchSubgraphRequestDto, @ArcSession() session: ActiveSession, - ): Promise> { - await this.commandBus.execute( - new PatchSubgraphCommand(subgraphSystemId, dto.name), + ): Promise> { + await this.commandBus.execute<{groupId: string}>( + new SetSubgraphCommand(subgraphSystemId, dto.name), session, ); - throw new NotImplementedException( - 'patchSubgraph response not implemented yet', + const query = new GetSubgraphPropertiesQuery( + Number.parseInt(projectId, 10), + subgraphSystemId, + 'api-client', ); + const result = + await this.queryBus.execute>(query); + return toApiResult(result); } /** - * Update a low-cascading subgraph property. + * Replace a low-cascading subgraph property. * Returns 400 if propSystemId maps to a reserved property (scenario, VSID, ASoC). */ - @Patch('/:subgraphSystemId/properties/:propSystemId') + @Put('/:subgraphSystemId/properties/:propSystemId') @ApiParam({ name: 'subgraphSystemId', required: true, @@ -430,18 +438,18 @@ export class SubgraphController extends BaseController { name: 'propSystemId', required: true, type: String, - description: 'System id of the property to update', + description: 'System id of the property to replace', }) @UseGuards(SessionGuard) @ApiDocumentationWithExample({ - summary: 'Update a low-cascading subgraph property', + summary: 'Replace a low-cascading subgraph property', description: - 'Returns 400 if propSystemId maps to a reserved property (scenario, VSID, ASoC) — use the dedicated endpoint instead.', + 'Returns 400 if propSystemId maps to a reserved property (scenario, VSID, ASoC) — use the dedicated PUT endpoint instead.', requestDto: UpdatePropertyRequestDto, responses: [ { status: HttpStatus.OK, - description: 'Property updated', + description: 'Property replaced', dto: PropertyResponseDto, }, { @@ -454,29 +462,33 @@ export class SubgraphController extends BaseController { }, { status: HttpStatus.UNPROCESSABLE_ENTITY, - description: 'Failed to update property', + description: 'Failed to replace property', }, ], }) - async updateSubgraphProperty( + async putSubgraphProperty( @Param('projectId') projectId: string, @Param('subgraphSystemId', ParseIntPipe) subgraphSystemId: number, @Param('propSystemId', ParseIntPipe) propSystemId: number, @Body() dto: UpdatePropertyRequestDto, @ArcSession() session: ActiveSession, - ): Promise> { + ): Promise> { await this.commandBus.execute( - new UpdateSubgraphPropertyCommand(subgraphSystemId, propSystemId, [dto]), + new SetSubgraphPropertyCommand( + subgraphSystemId, + propSystemId, + dto.elements, + ), session, ); - const query = new GetSubgraphPropertiesQuery( + const query = new GetSubgraphPropertyQuery( Number.parseInt(projectId, 10), subgraphSystemId, + propSystemId, 'api-client', ); - const result = - await this.queryBus.execute>(query); - return toApiResult(result); + const result = await this.queryBus.execute>(query); + return toApiResult(result, data => mapPropertyToDto(data)); } /** diff --git a/packages/core/src/application/file-operations/download-file/services/chunk-serializers/usecase-data-chunk-serializer.ts b/packages/core/src/application/file-operations/download-file/services/chunk-serializers/usecase-data-chunk-serializer.ts index bdc0dc550..c71fe2afd 100644 --- a/packages/core/src/application/file-operations/download-file/services/chunk-serializers/usecase-data-chunk-serializer.ts +++ b/packages/core/src/application/file-operations/download-file/services/chunk-serializers/usecase-data-chunk-serializer.ts @@ -15,7 +15,6 @@ import type { import {BinaryUtils} from '../../../../../shared/utilities/binary-utils.js'; import { SPF_APM_MODULE_ID, - PARAM_ID_SUB_GRAPH_CONFIG, PARAM_ID_CONTAINER_CONFIG, PARAM_ID_MODULES_LIST, PARAM_ID_MODULE_PROP, @@ -27,7 +26,8 @@ import { CONTAINER_PROP_ID_PARENT_CONTAINER, HEAP_ID_DEFAULT, ID_DONT_CARE_DUMMY, -} from '../../../shared/constants/spf-ids.js'; +} from '../../../../../domain/entities/definitions/spf-ids.js'; +import {PARAM_ID_SUB_GRAPH_CONFIG} from '../../../../../domain/entities/definitions/subgraph/subgraph-ids.js'; import {isVoiceSubgraph} from '../../../shared/utils/subgraph-utils.js'; /** diff --git a/packages/core/src/application/file-operations/download-file/services/chunk-serializers/voice-calibration-chunk-serializer.ts b/packages/core/src/application/file-operations/download-file/services/chunk-serializers/voice-calibration-chunk-serializer.ts index fce0663f5..b80821d85 100644 --- a/packages/core/src/application/file-operations/download-file/services/chunk-serializers/voice-calibration-chunk-serializer.ts +++ b/packages/core/src/application/file-operations/download-file/services/chunk-serializers/voice-calibration-chunk-serializer.ts @@ -8,7 +8,7 @@ import {BinaryUtils} from '../../../../../shared/utilities/binary-utils.js'; import { SPF_VCPM_MODULE_ID, PARAM_ID_VOICE_CAL_TBL, -} from '../../../shared/constants/spf-ids.js'; +} from '../../../../../domain/entities/definitions/spf-ids.js'; /** * Result of voice calibration chunk serialization. diff --git a/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/control-link-property-utils.ts b/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/control-link-property-utils.ts index bbf3d2ccb..feffd236b 100644 --- a/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/control-link-property-utils.ts +++ b/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/control-link-property-utils.ts @@ -8,7 +8,7 @@ import { MODULE_PROP_ID_CTRL_HEAP_ID, MODULE_PROP_ID_CTRL_LINK_INTENTS, HEAP_ID_DEFAULT, -} from '../../constants/spf-ids.js'; +} from '../../../../../domain/entities/definitions/spf-ids.js'; /** * Extract heapId from control link properties map diff --git a/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/module-port-property.ts b/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/module-port-property.ts index 65fe54de1..bb5c3e9e6 100644 --- a/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/module-port-property.ts +++ b/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/module-port-property.ts @@ -13,7 +13,7 @@ import type { import { MODULE_PROP_ID_PORT_INFO, MODULE_PROP_ID_HEAP_ID, -} from '../../constants/spf-ids.js'; +} from '../../../../../domain/entities/definitions/spf-ids.js'; import {ModulePropertyConfigImpl} from './module-property-config-impl.js'; /** diff --git a/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/module-property-config-impl.ts b/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/module-property-config-impl.ts index 13b053960..2783db9f4 100644 --- a/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/module-property-config-impl.ts +++ b/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/module-property-config-impl.ts @@ -13,7 +13,7 @@ import type { import { MODULE_PROP_ID_PORT_INFO, MODULE_PROP_ID_HEAP_ID, -} from '../../constants/spf-ids.js'; +} from '../../../../../domain/entities/definitions/spf-ids.js'; /** * Implementation of ModulePropertyConfig interface with utility methods diff --git a/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/spf-properties.ts b/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/spf-properties.ts index ba9326189..54b43d7d4 100644 --- a/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/spf-properties.ts +++ b/packages/core/src/application/file-operations/shared/acdb-chunks/spf-properties/spf-properties.ts @@ -12,14 +12,14 @@ import {DataLinksProperty} from './data-links-property.js'; import {ControlLinksProperty} from './control-links-property.js'; import {VcpmConfigProperty} from './vcpm-config-property.js'; import { - PARAM_ID_SUB_GRAPH_CONFIG, PARAM_ID_CONTAINER_CONFIG, PARAM_ID_MODULES_LIST, PARAM_ID_MODULE_PROP, PARAM_ID_MODULE_DATA_LINK, PARAM_ID_MODULE_CTRL_LINK, PARAM_ID_VOICE_SG_CONFIG, -} from '../../constants/spf-ids.js'; +} from '../../../../../domain/entities/definitions/spf-ids.js'; +import {PARAM_ID_SUB_GRAPH_CONFIG} from '../../../../../domain/entities/definitions/subgraph/subgraph-ids.js'; /** * Main SPF Properties class that parses and contains all subgraph property data. diff --git a/packages/core/src/application/file-operations/shared/utils/subgraph-utils.ts b/packages/core/src/application/file-operations/shared/utils/subgraph-utils.ts index 18b74e1c4..256e1768f 100644 --- a/packages/core/src/application/file-operations/shared/utils/subgraph-utils.ts +++ b/packages/core/src/application/file-operations/shared/utils/subgraph-utils.ts @@ -6,7 +6,7 @@ import { SUB_GRAPH_PROP_ID_SCENARIO_ID, SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL, -} from '../constants/spf-ids.js'; +} from '../../../../domain/entities/definitions/subgraph/subgraph-ids.js'; import type {SubgraphPropertyDownloadModel} from '../../../ports/persistence/query-services/bulk-read/bulk-read-query-service.js'; /** diff --git a/packages/core/src/application/file-operations/upload-file/services/acdb-chunk-parsers/subgraph-pair-data-chunk-parser.ts b/packages/core/src/application/file-operations/upload-file/services/acdb-chunk-parsers/subgraph-pair-data-chunk-parser.ts index 464025a1a..33a82b421 100644 --- a/packages/core/src/application/file-operations/upload-file/services/acdb-chunk-parsers/subgraph-pair-data-chunk-parser.ts +++ b/packages/core/src/application/file-operations/upload-file/services/acdb-chunk-parsers/subgraph-pair-data-chunk-parser.ts @@ -21,7 +21,7 @@ import { SPF_APM_MODULE_ID, PARAM_ID_MODULE_DATA_LINK, PARAM_ID_MODULE_CTRL_LINK, -} from '../../../shared/constants/spf-ids.js'; +} from '../../../../../domain/entities/definitions/spf-ids.js'; import { extractHeapId, extractIntents, diff --git a/packages/core/src/application/file-operations/upload-file/services/entity-builders/calibration-data-builder.ts b/packages/core/src/application/file-operations/upload-file/services/entity-builders/calibration-data-builder.ts index e0e844883..48814f9ec 100644 --- a/packages/core/src/application/file-operations/upload-file/services/entity-builders/calibration-data-builder.ts +++ b/packages/core/src/application/file-operations/upload-file/services/entity-builders/calibration-data-builder.ts @@ -18,7 +18,7 @@ import type {KeyVectorInput} from '../../../../../domain/entities/usecase-data/u import type {UiMetadata} from '../../../shared/awsp-serializers/v1/ui-metadata/index.js'; import {parseKeyValueString} from '../../../shared/awsp-serializers/v1/ui-metadata/index.js'; import {PARSED_CHUNK_TYPES} from '../../../shared/constants/chunk-types.js'; -import {SPF_VCPM_MODULE_ID} from '../../../shared/constants/spf-ids.js'; +import {SPF_VCPM_MODULE_ID} from '../../../../../domain/entities/definitions/spf-ids.js'; import type { VoiceCalibrationChunk, VoiceSubgraphCalTable, diff --git a/packages/core/src/application/file-operations/upload-file/services/entity-builders/container-builder.ts b/packages/core/src/application/file-operations/upload-file/services/entity-builders/container-builder.ts index e1db4ce1b..7a50a5fbd 100644 --- a/packages/core/src/application/file-operations/upload-file/services/entity-builders/container-builder.ts +++ b/packages/core/src/application/file-operations/upload-file/services/entity-builders/container-builder.ts @@ -19,7 +19,7 @@ import { ISSUE_ENTITY_TYPE, } from '../../../../../shared/issues/index.js'; import {ERROR_CODES} from '../../../../../shared/errors/error-codes.js'; -import {CONTAINER_PROP_ID_PROC_DOMAIN} from '../../../shared/constants/spf-ids.js'; +import {CONTAINER_PROP_ID_PROC_DOMAIN} from '../../../../../domain/entities/definitions/spf-ids.js'; /** * Result of container building including processor ID mapping 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..fd51c9fb0 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 @@ -73,14 +73,14 @@ import {PatchSpfModuleCommand} from '../../../usecase-designer/spf-module/patch/ import {PatchSpfModuleHandler} from '../../../usecase-designer/spf-module/patch/patch-spf-module.handler.js'; import {CreateModuleCommand} from '../../../usecase-designer/spf-module/create-module/create-module.command.js'; import {CreateModuleHandler} from '../../../usecase-designer/spf-module/create-module/create-module.handler.js'; -import {UpdateSubgraphScenarioCommand} from '../../../usecase-designer/subgraph/update-scenario/update-subgraph-scenario.command.js'; -import {UpdateSubgraphScenarioHandler} from '../../../usecase-designer/subgraph/update-scenario/update-subgraph-scenario.handler.js'; -import {UpdateSubgraphVsidCommand} from '../../../usecase-designer/subgraph/update-vsid/update-subgraph-vsid.command.js'; -import {UpdateSubgraphVsidHandler} from '../../../usecase-designer/subgraph/update-vsid/update-subgraph-vsid.handler.js'; -import {PatchSubgraphCommand} from '../../../usecase-designer/subgraph/patch/patch-subgraph.command.js'; -import {PatchSubgraphHandler} from '../../../usecase-designer/subgraph/patch/patch-subgraph.handler.js'; -import {UpdateSubgraphPropertyCommand} from '../../../usecase-designer/subgraph/update-property/update-subgraph-property.command.js'; -import {UpdateSubgraphPropertyHandler} from '../../../usecase-designer/subgraph/update-property/update-subgraph-property.handler.js'; +import {SetSubgraphScenarioCommand} from '../../../usecase-designer/subgraph/set-scenario/set-subgraph-scenario.command.js'; +import {SetSubgraphScenarioHandler} from '../../../usecase-designer/subgraph/set-scenario/set-subgraph-scenario.handler.js'; +import {SetSubgraphVsidCommand} from '../../../usecase-designer/subgraph/set-vsid/set-subgraph-vsid.command.js'; +import {SetSubgraphVsidHandler} from '../../../usecase-designer/subgraph/set-vsid/set-subgraph-vsid.handler.js'; +import {SetSubgraphCommand} from '../../../usecase-designer/subgraph/set/set-subgraph.command.js'; +import {SetSubgraphHandler} from '../../../usecase-designer/subgraph/set/set-subgraph.handler.js'; +import {SetSubgraphPropertyCommand} from '../../../usecase-designer/subgraph/set-property/set-subgraph-property.command.js'; +import {SetSubgraphPropertyHandler} from '../../../usecase-designer/subgraph/set-property/set-subgraph-property.handler.js'; import {UpdateSubgraphContainerIdCommand} from '../../../usecase-designer/subgraph/update-container-id/update-subgraph-container-id.command.js'; import {UpdateSubgraphContainerIdHandler} from '../../../usecase-designer/subgraph/update-container-id/update-subgraph-container-id.handler.js'; import {CreateVcpmCkvCommand} from '../../../usecase-designer/subgraph/create-vcpm-ckv/create-vcpm-ckv.command.js'; @@ -224,20 +224,20 @@ export class CommandHandlerRegistry { ), }); - this.commandHandlerFactories.set(UpdateSubgraphScenarioCommand, { - create: deps => new UpdateSubgraphScenarioHandler(deps.uow), + this.commandHandlerFactories.set(SetSubgraphScenarioCommand, { + create: deps => new SetSubgraphScenarioHandler(deps.uow), }); - this.commandHandlerFactories.set(UpdateSubgraphVsidCommand, { - create: deps => new UpdateSubgraphVsidHandler(deps.uow), + this.commandHandlerFactories.set(SetSubgraphVsidCommand, { + create: deps => new SetSubgraphVsidHandler(deps.uow), }); - this.commandHandlerFactories.set(PatchSubgraphCommand, { - create: deps => new PatchSubgraphHandler(deps.uow), + this.commandHandlerFactories.set(SetSubgraphCommand, { + create: deps => new SetSubgraphHandler(deps.uow), }); - this.commandHandlerFactories.set(UpdateSubgraphPropertyCommand, { - create: deps => new UpdateSubgraphPropertyHandler(deps.uow), + this.commandHandlerFactories.set(SetSubgraphPropertyCommand, { + create: deps => new SetSubgraphPropertyHandler(deps.uow), }); this.commandHandlerFactories.set(UpdateSubgraphContainerIdCommand, { diff --git a/packages/core/src/application/orchestration/cqrs/registries/query-handler-registry.ts b/packages/core/src/application/orchestration/cqrs/registries/query-handler-registry.ts index 64d9113f1..ee14a81dd 100644 --- a/packages/core/src/application/orchestration/cqrs/registries/query-handler-registry.ts +++ b/packages/core/src/application/orchestration/cqrs/registries/query-handler-registry.ts @@ -75,6 +75,8 @@ import {GetProjectsQuery} from '../../../project/get-all/get-projects.query.js'; import {GetProjectsHandler} from '../../../project/get-all/get-projects.handler.js'; import {GetProjectQuery} from '../../../project/get/get-project.query.js'; import {GetProjectHandler} from '../../../project/get/get-project.handler.js'; +import {GetSubgraphPropertyQuery} from '../../../usecase-designer/subgraph/get-property/get-subgraph-property.query.js'; +import {GetSubgraphPropertyHandler} from '../../../usecase-designer/subgraph/get-property/get-subgraph-property.handler.js'; export interface QueryHandlerDependencies { queryServices: QueryServices; @@ -289,5 +291,10 @@ export class QueryHandlerRegistry { create: (deps: QueryHandlerDependencies) => new GetProjectHandler(deps.queryServices), }); + + this.queryHandlerFactories.set(GetSubgraphPropertyQuery, { + create: (deps: QueryHandlerDependencies) => + new GetSubgraphPropertyHandler(deps.queryServices), + }); } } diff --git a/packages/core/src/application/ports/persistence/query-services/query-services.ts b/packages/core/src/application/ports/persistence/query-services/query-services.ts index 8a145e6c8..31bf2e92f 100644 --- a/packages/core/src/application/ports/persistence/query-services/query-services.ts +++ b/packages/core/src/application/ports/persistence/query-services/query-services.ts @@ -21,6 +21,7 @@ import type {SubsystemQueryService} from './subsystem/subsystem-query-service.js import type {ContainerPropertyDefQueryService} from './container-property-definition/container-property-def-query-service.js'; import type {SubgraphPropertyDefQueryService} from './subgraph-property-definition/subgraph-property-def-query-service.js'; import type {LogQueryService} from './logging/log-query-service.js'; +import type {VcpmDefinitionQueryService} from './vcpm-definition/vcpm-definition-query-service.js'; export interface QueryServices { readonly modulesQueryService: ModuleQueryService; @@ -42,4 +43,5 @@ export interface QueryServices { readonly subgraphPropertyDefQueryService: SubgraphPropertyDefQueryService; readonly driverModuleDefinitionQueryService: DriverModuleDefinitionQueryService; readonly logQueryService: LogQueryService; + readonly vcpmDefinitionQueryService: VcpmDefinitionQueryService; } diff --git a/packages/core/src/application/ports/persistence/query-services/subgraph-property-definition/subgraph-property-def-query-service.ts b/packages/core/src/application/ports/persistence/query-services/subgraph-property-definition/subgraph-property-def-query-service.ts index 7ae3015bd..4ec6d6914 100644 --- a/packages/core/src/application/ports/persistence/query-services/subgraph-property-definition/subgraph-property-def-query-service.ts +++ b/packages/core/src/application/ports/persistence/query-services/subgraph-property-definition/subgraph-property-def-query-service.ts @@ -35,7 +35,16 @@ export interface SubgraphPropertyDefQueryService { * Returns all subgraph property definitions including the `elementsStructure` * binary field needed for parsing calibration payloads. Overlay is applied. */ - getAllDetailedSubgraphPropertyDefinitionsWithElements( + getSubgraphPropertiesWithElements( fileSystemId: number, ): Promise>; + + /** + * Returns a single subgraph property definition including elementsStructure. + * Result.fail with ERROR_CODES.ENTITY_NOT_FOUND if not found. + */ + getSubgraphPropertyWithElements( + propertySystemId: number, + fileSystemId: number, + ): Promise>; } diff --git a/packages/core/src/application/ports/persistence/query-services/vcpm-definition/vcpm-definition-query-service.ts b/packages/core/src/application/ports/persistence/query-services/vcpm-definition/vcpm-definition-query-service.ts new file mode 100644 index 000000000..623ae8b60 --- /dev/null +++ b/packages/core/src/application/ports/persistence/query-services/vcpm-definition/vcpm-definition-query-service.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import type {VcpmModuleDefinitionWithParamsReadModel} from '../../repositories/vcpm-definition/vcpm-definition.repository.js'; + +export interface VcpmDefinitionQueryService { + /** + * Returns all VCPM module definitions with their parameter definitions + * for the given fileSystemId. + */ + getAllVcpmModuleDefinitions( + fileSystemId: number, + ): Promise; +} 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..c6f1024b0 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 @@ -149,4 +149,35 @@ export interface ModuleRepository { payloadUpdates: PayloadUpdate[], uiPersistence?: string, ): Promise; + + /** + * Returns all non-deleted SpfModule rows belonging to a subgraph. + * Overlay-aware: excludes pending DELETE, includes pending CREATE. + */ + getModulesBySubgraphId( + subgraphSystemId: number, + fileSystemId: number, + ): Promise; + + /** + * TODO(subgraph-write-review): This contract still reflects the deferred + * zero-CKV reset-plan design. The reset behavior is intentionally disabled + * for this PR and will be reintroduced when core creates the plan. + * + * TODO(subgraph-write-review): CKV deletion is also temporarily disabled. + * The follow-up implementation will replace this compatibility operation + * with separate reset-plan read/application methods. + * + * Currently wipes only TKV/tag calibration data for a module. The returned + * CKV mutation lists remain for compatibility and are empty. + */ + wipeCalData( + moduleSystemId: number, + fileSystemId: number, + ): Promise; +} + +export interface WipeCalDataResult { + ckvsDeleted: number[]; + zeroCkvsAdded: number[]; } diff --git a/packages/core/src/application/ports/persistence/repositories/subgraph/subgraph.repository.ts b/packages/core/src/application/ports/persistence/repositories/subgraph/subgraph.repository.ts index 9d0dea05d..74eca981a 100644 --- a/packages/core/src/application/ports/persistence/repositories/subgraph/subgraph.repository.ts +++ b/packages/core/src/application/ports/persistence/repositories/subgraph/subgraph.repository.ts @@ -9,11 +9,16 @@ import type {SubgraphPropertyDefinition} from '../../../../../domain/entities/de import type {KvPair} from '../shared/kv-pair.js'; import type {SessionChanged} from '../shared/session-changed.js'; -/** - * Routing query model for one SGKV instance. Returned by - * SubgraphRepository.getSgkvs. Carries full KV-pair info so callers - * never need a separate keyDef lookup. - */ +export interface SubgraphWithProperties { + systemId: number; + properties: Array<{ + systemId: number; + propertySystemId: number; + payload: Uint8Array | null; + }>; +} + +/** A subgraph key/value instance with its resolved key and value definitions. */ export interface SgkvEntry { sgSystemId: number; sgkvSystemId: number; @@ -29,6 +34,12 @@ export interface SubgraphRepository { options?: EditOptions, ): Promise; + /** Returns SGKV instances for the requested subgraphs. */ + getSgkvs( + fileSystemId: number, + sgSystemIds: readonly number[], + ): Promise; + /** * Stages CREATE rows for the Subgraph aggregate root and all its * SubgraphPropertyData children. @@ -41,14 +52,62 @@ export interface SubgraphRepository { fileSystemId: number, ): Promise; + /** Returns effective subgraph property definitions for the active session. */ + getPropertyDefinitions( + fileSystemId: number, + ): Promise; + + /** Returns subgraph with overlay-aware property rows. null if not found. */ + getAggregate( + subgraphSystemId: number, + fileSystemId: number, + ): Promise; + /** - * Returns SgkvEntry objects for each SGKV belonging to the given SGs. - * Joins sgkv → sgkv_values → value_definitions in one query. + * Batch variant of getAggregate. + * Returns a map of subgraphSystemId → SubgraphWithProperties. + * Missing subgraphs are absent from the map (not null entries). + * Uses 2 queries total regardless of how many IDs are passed. */ - getSgkvs( + getAggregates( + subgraphSystemIds: number[], fileSystemId: number, - sgSystemIds: readonly number[], - ): Promise; + ): Promise>; + + /** Returns linked subgraphs reachable through shared use cases. */ + getSubgraphIdsInSameUsecasesForMany( + subgraphSystemIds: number[], + fileSystemId: number, + ): Promise; + + /** Stages a new SubgraphPropertyData row with a prepared payload. */ + addProperty( + subgraphSystemId: number, + propertySystemId: number, + payload: Uint8Array, + ): Promise; + + /** Stages a name delta on the Subgraph row. */ + rename(subgraphSystemId: number, name: string): Promise; + + /** + * Stages a payload delta on an existing SubgraphPropertyData row. + * Throws if the property row does not exist. + */ + setPropertyData( + subgraphSystemId: number, + propertySystemId: number, + data: Uint8Array, + ): Promise; + + /** Stages deletion of an existing SubgraphPropertyData row. */ + removeProperty( + subgraphSystemId: number, + propertyDataSystemId: number, + ): Promise; + + /** Stages deletion of all VCPM configuration data for a subgraph. */ + removeAllVcpmCfgData(subgraphSystemId: number): Promise; /** * Returns Subgraph aggregates by systemId. Missing IDs silently omitted. diff --git a/packages/core/src/application/ports/persistence/repositories/vcpm-definition/vcpm-definition.repository.ts b/packages/core/src/application/ports/persistence/repositories/vcpm-definition/vcpm-definition.repository.ts new file mode 100644 index 000000000..474156114 --- /dev/null +++ b/packages/core/src/application/ports/persistence/repositories/vcpm-definition/vcpm-definition.repository.ts @@ -0,0 +1,38 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +export interface VcpmModuleDefinitionWithParamsReadModel { + systemId: number; + moduleDefinitionId: number; + parameters: Array<{ + systemId: number; + paramId: number; + elementsStructure: string; + isReadOnly: boolean; + }>; +} + +export interface VcpmDefaultData { + definitionSystemId: number; + parameters: Array<{ + parameterSystemId: number; + payload: Uint8Array; + }>; +} + +/** + * VCPM definition reads and configuration writes used by write handlers. + * Payload bytes in addVcpmCfgDefaultData are final bytes produced by core. + */ +export interface VcpmDefinitionRepository { + getAllVcpmModuleDefinitions( + fileSystemId: number, + ): Promise; + + addVcpmCfgDefaultData( + subgraphSystemId: number, + defaults: readonly VcpmDefaultData[], + ): Promise; +} diff --git a/packages/core/src/application/ports/persistence/unit-of-work.ts b/packages/core/src/application/ports/persistence/unit-of-work.ts index d273faf42..4d92beb3e 100644 --- a/packages/core/src/application/ports/persistence/unit-of-work.ts +++ b/packages/core/src/application/ports/persistence/unit-of-work.ts @@ -17,6 +17,7 @@ import type {ControlLinkRepository} from './repositories/control-link/control-li import type {SubgraphRepository} from './repositories/subgraph/subgraph.repository.js'; import type {SubsystemRepository} from './repositories/subsystem/subsystem.repository.js'; import type {UsecaseRepository} from './repositories/usecase/usecase.repository.js'; +import type {VcpmDefinitionRepository} from './repositories/vcpm-definition/vcpm-definition.repository.js'; /** * Unit of Work pattern for managing database transactions and repository access. @@ -70,4 +71,5 @@ export interface UnitOfWork { getSubgraphRepository(): SubgraphRepository; getSubsystemRepository(): SubsystemRepository; getUsecaseRepository(): UsecaseRepository; + getVcpmDefinitionRepository(): VcpmDefinitionRepository; } diff --git a/packages/core/src/application/usecase-designer/container/build-container-copy.ts b/packages/core/src/application/usecase-designer/container/build-container-copy.ts index 2cd5618e9..586438c2b 100644 --- a/packages/core/src/application/usecase-designer/container/build-container-copy.ts +++ b/packages/core/src/application/usecase-designer/container/build-container-copy.ts @@ -6,8 +6,8 @@ import type {Container} from '../../../domain/entities/usecase-data/container/container.js'; import {Container as ContainerClass} from '../../../domain/entities/usecase-data/container/container.js'; import {ContainerPropertyValue} from '../../../domain/entities/usecase-data/container/value-objects/container-property.js'; -import {CONTAINER_PROP_ID_STACK_SIZE} from '../../file-operations/shared/constants/spf-ids.js'; -import {encodeStackSize} from '../../../domain/services/container-property/container-stack-size-codec.js'; +import {CONTAINER_PROP_ID_STACK_SIZE} from '../../../domain/entities/definitions/spf-ids.js'; +import {encodeStackSize} from '../shared/utils/container-stack-size-codec.js'; /** * Creates a new container by copying all properties from the source container. diff --git a/packages/core/src/application/usecase-designer/container/build-container-with-defaults.ts b/packages/core/src/application/usecase-designer/container/build-container-with-defaults.ts index 57307e58e..5bc62f4c4 100644 --- a/packages/core/src/application/usecase-designer/container/build-container-with-defaults.ts +++ b/packages/core/src/application/usecase-designer/container/build-container-with-defaults.ts @@ -5,9 +5,9 @@ import {Container} from '../../../domain/entities/usecase-data/container/container.js'; import {ContainerPropertyValue} from '../../../domain/entities/usecase-data/container/value-objects/container-property.js'; +import {CONTAINER_PROP_ID_STACK_SIZE} from '../../../domain/entities/definitions/spf-ids.js'; +import {encodeStackSize} from '../shared/utils/container-stack-size-codec.js'; import type {PropertyDefinition} from '../../../domain/entities/definitions/common/entities/property-definition.js'; -import {CONTAINER_PROP_ID_STACK_SIZE} from '../../file-operations/shared/constants/spf-ids.js'; -import {encodeStackSize} from '../../../domain/services/container-property/container-stack-size-codec.js'; export interface ContainerInit { systemId: number; diff --git a/packages/core/src/application/usecase-designer/container/services/container-stack-size.service.ts b/packages/core/src/application/usecase-designer/container/services/container-stack-size.service.ts index 2c9862d8d..79c173de3 100644 --- a/packages/core/src/application/usecase-designer/container/services/container-stack-size.service.ts +++ b/packages/core/src/application/usecase-designer/container/services/container-stack-size.service.ts @@ -3,12 +3,12 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {CONTAINER_PROP_ID_STACK_SIZE} from '../../../file-operations/shared/constants/spf-ids.js'; +import {CONTAINER_PROP_ID_STACK_SIZE} from '../../../../domain/entities/definitions/spf-ids.js'; import type {UnitOfWork} from '../../../ports/persistence/unit-of-work.js'; import { decodeStackSize, encodeStackSize, -} from '../../../../domain/services/container-property/container-stack-size-codec.js'; +} from '../../shared/utils/container-stack-size-codec.js'; export class ContainerStackSizeService { constructor(private readonly uow: UnitOfWork) {} diff --git a/packages/core/src/application/usecase-designer/shared/dto/parameter-element-summary.dto.ts b/packages/core/src/application/usecase-designer/shared/dto/parameter-element-summary.dto.ts new file mode 100644 index 000000000..468998eed --- /dev/null +++ b/packages/core/src/application/usecase-designer/shared/dto/parameter-element-summary.dto.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {z} from 'zod'; + +/** Write-side element shape shared by module and Subgraph property commands. */ +export const ConfigElementSummaryDtoSchema = z.object({ + type: z.literal('ConfigElement'), + name: z.string().describe('Element name').optional(), + value: z.unknown().describe('Value to write'), +}); +export type ConfigElementSummaryDto = z.infer< + typeof ConfigElementSummaryDtoSchema +>; + +export const ElementTemplateArraySummaryDtoSchema = z.object({ + type: z.literal('ElementTemplateArray'), + name: z.string().describe('Array element name').optional(), + value: z.unknown().describe('Array value to write'), +}); +export type ElementTemplateArraySummaryDto = z.infer< + typeof ElementTemplateArraySummaryDtoSchema +>; + +export const StructSummaryDtoSchema = z.object({ + type: z.literal('Struct'), + name: z.string().describe('Struct element name').optional(), + value: z.unknown().describe('Struct value to write'), +}); +export type StructSummaryDto = z.infer; + +export const ParameterElementSummaryDtoSchema = z.discriminatedUnion('type', [ + ConfigElementSummaryDtoSchema, + ElementTemplateArraySummaryDtoSchema, + StructSummaryDtoSchema, +]); +export type ParameterElementSummaryDto = z.infer< + typeof ParameterElementSummaryDtoSchema +>; diff --git a/packages/core/src/application/usecase-designer/shared/serialize-elements.ts b/packages/core/src/application/usecase-designer/shared/serialize-elements.ts index f6875d012..1839dfa08 100644 --- a/packages/core/src/application/usecase-designer/shared/serialize-elements.ts +++ b/packages/core/src/application/usecase-designer/shared/serialize-elements.ts @@ -406,3 +406,97 @@ function serializeStructArray( } return {ok: true, value: new Uint8Array(0)}; } + +/** + * Builds a binary blob from the default values declared in a parameter + * definition's elementsStructure. Used to seed property rows at entity + * creation time so they always have a valid (if default) payload. + */ +export function serializeDefaultParameterData(definition: { + systemId: number; + elementsStructure: string; +}): SerializeResult { + let schema: DefinitionElement[]; + try { + schema = convertParamDefinition(definition.elementsStructure); + } catch { + return {ok: false, error: 'Failed to parse elementsStructure JSON'}; + } + try { + const parsedSoFar = new Map(); + const defaultInputs = buildDefaultElements(schema, parsedSoFar); + const syntheticDef = { + systemId: definition.systemId, + isReadOnly: false, + elementsStructure: definition.elementsStructure, + }; + return serializeParameterData(syntheticDef, defaultInputs); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Missing default value', + }; + } +} + +function buildDefaultElements( + schema: DefinitionElement[], + parsedSoFar: Map, +): ElementCalData[] { + return schema.map(def => buildDefaultElement(def, parsedSoFar)); +} + +function buildDefaultElement( + def: DefinitionElement, + parsedSoFar: Map, +): ElementCalData { + switch (def.elementType) { + case PARAMETER_ELEMENT_TYPE.ConfigElement: { + if (def.defaultValue === undefined) { + throw new Error( + `Missing defaultValue for element "${def.name ?? ''}"`, + ); + } + if (def.name !== undefined) { + const numericValue = Number(def.defaultValue); + if (Number.isFinite(numericValue)) { + parsedSoFar.set(def.name, numericValue); + } + } + return { + type: PARAMETER_ELEMENT_TYPE.ConfigElement, + value: def.defaultValue, + } as ConfigElementData; + } + case PARAMETER_ELEMENT_TYPE.Struct: { + return { + type: PARAMETER_ELEMENT_TYPE.Struct, + value: buildDefaultElements(def.elements, parsedSoFar), + } as StructData; + } + case PARAMETER_ELEMENT_TYPE.ElementArray: + case PARAMETER_ELEMENT_TYPE.StructArray: { + const length = resolveDefaultArrayLength(def, parsedSoFar); + return { + type: PARAMETER_ELEMENT_TYPE.ElementArray, + value: Array.from({length}, () => + buildDefaultElement(def.template, parsedSoFar), + ), + } as ElementArrayData; + } + } +} + +function resolveDefaultArrayLength( + element: ElementArray | StructArray, + parsedSoFar: Map, +): number { + const length = element.arrayLenFormulaStr + ? evaluateFormula(element.arrayLenFormulaStr, parsedSoFar) + : (element.arrayLength ?? 0); + + if (!Number.isInteger(length) || length < 0) { + throw new Error(`Invalid default array length: ${length}`); + } + return length; +} diff --git a/packages/core/src/domain/services/container-property/container-stack-size-codec.ts b/packages/core/src/application/usecase-designer/shared/utils/container-stack-size-codec.ts similarity index 100% rename from packages/core/src/domain/services/container-property/container-stack-size-codec.ts rename to packages/core/src/application/usecase-designer/shared/utils/container-stack-size-codec.ts diff --git a/packages/core/src/application/usecase-designer/spf-module/dto/element-dto.ts b/packages/core/src/application/usecase-designer/spf-module/dto/element-dto.ts index a7eec6524..00dade501 100644 --- a/packages/core/src/application/usecase-designer/spf-module/dto/element-dto.ts +++ b/packages/core/src/application/usecase-designer/spf-module/dto/element-dto.ts @@ -11,6 +11,20 @@ import type { StructData, } from '../../../../domain/entities/definitions/common/types/element-data.js'; import {PARAMETER_ELEMENT_TYPE} from '../../shared/element-definition.js'; +import * as ParameterElementSummaryDtoModels from '../../shared/dto/parameter-element-summary.dto.js'; + +export { + ConfigElementSummaryDtoSchema, + ElementTemplateArraySummaryDtoSchema, + StructSummaryDtoSchema, + ParameterElementSummaryDtoSchema, +} from '../../shared/dto/parameter-element-summary.dto.js'; +export type { + ConfigElementSummaryDto, + ElementTemplateArraySummaryDto, + StructSummaryDto, + ParameterElementSummaryDto, +} from '../../shared/dto/parameter-element-summary.dto.js'; export const NameValuePairSchema = z.object({ name: z.string().describe('Display name'), @@ -18,61 +32,28 @@ export const NameValuePairSchema = z.object({ }); // Summary schemas — write-side shape (type + name + value only) -export const ConfigElementSummaryDtoSchema = z.object({ - type: z.literal('ConfigElement'), - name: z.string().describe('Element name').optional(), - value: z.unknown().describe('Value to write'), -}); -export type ConfigElementSummaryDto = z.infer< - typeof ConfigElementSummaryDtoSchema ->; - -export const ElementTemplateArraySummaryDtoSchema = z.object({ - type: z.literal('ElementTemplateArray'), - name: z.string().describe('Array element name').optional(), - value: z.unknown().describe('Array value to write'), -}); -export type ElementTemplateArraySummaryDto = z.infer< - typeof ElementTemplateArraySummaryDtoSchema ->; - -export const StructSummaryDtoSchema = z.object({ - type: z.literal('Struct'), - name: z.string().describe('Struct element name').optional(), - value: z.unknown().describe('Struct value to write'), -}); -export type StructSummaryDto = z.infer; - -export const ParameterElementSummaryDtoSchema = z.discriminatedUnion('type', [ - ConfigElementSummaryDtoSchema, - ElementTemplateArraySummaryDtoSchema, - StructSummaryDtoSchema, -]); -export type ParameterElementSummaryDto = z.infer< - typeof ParameterElementSummaryDtoSchema ->; - // Full read-side schemas — extend summary schemas to avoid duplicating type/name/value -export const ConfigElementSchema = ConfigElementSummaryDtoSchema.extend({ - value: z.string(), - dataType: z.string(), - isReadOnly: z.boolean(), - description: z.string().optional(), - group: z.string().optional(), - subgroup: z.string().optional(), - unit: z.string().optional(), - displayType: z.string().optional(), - policy: z.string().optional(), - qFormat: z.string().optional(), - precision: z.number().optional(), - min: z.number().optional(), - max: z.number().optional(), - allowedValues: z.array(NameValuePairSchema).optional(), -}); +export const ConfigElementSchema = + ParameterElementSummaryDtoModels.ConfigElementSummaryDtoSchema.extend({ + value: z.string(), + dataType: z.string(), + isReadOnly: z.boolean(), + description: z.string().optional(), + group: z.string().optional(), + subgroup: z.string().optional(), + unit: z.string().optional(), + displayType: z.string().optional(), + policy: z.string().optional(), + qFormat: z.string().optional(), + precision: z.number().optional(), + min: z.number().optional(), + max: z.number().optional(), + allowedValues: z.array(NameValuePairSchema).optional(), + }); // ElementTemplateArray and Struct use unknown[] for nested value/template to avoid infinite recursion export const ElementTemplateArraySchema = - ElementTemplateArraySummaryDtoSchema.extend({ + ParameterElementSummaryDtoModels.ElementTemplateArraySummaryDtoSchema.extend({ value: z.array(z.unknown()), isReadOnly: z.boolean(), template: z.array(z.unknown()), @@ -83,14 +64,15 @@ export const ElementTemplateArraySchema = lengthFormula: z.string().optional(), }); -export const StructSchema = StructSummaryDtoSchema.extend({ - value: z.array(z.unknown()), - isReadOnly: z.boolean(), - structType: z.string(), - description: z.string().optional(), - group: z.string().optional(), - subgroup: z.string().optional(), -}); +export const StructSchema = + ParameterElementSummaryDtoModels.StructSummaryDtoSchema.extend({ + value: z.array(z.unknown()), + isReadOnly: z.boolean(), + structType: z.string(), + description: z.string().optional(), + group: z.string().optional(), + subgroup: z.string().optional(), + }); export const ParameterElementDtoSchema = z.discriminatedUnion('type', [ ConfigElementSchema, diff --git a/packages/core/src/application/usecase-designer/spf-module/dto/parameter-dto.ts b/packages/core/src/application/usecase-designer/spf-module/dto/parameter-dto.ts index cca9106d2..4283945d3 100644 --- a/packages/core/src/application/usecase-designer/spf-module/dto/parameter-dto.ts +++ b/packages/core/src/application/usecase-designer/spf-module/dto/parameter-dto.ts @@ -4,10 +4,8 @@ */ import {z} from 'zod'; -import { - ParameterElementDtoSchema, - ParameterElementSummaryDtoSchema, -} from './element-dto.js'; +import {ParameterElementDtoSchema} from './element-dto.js'; +import {ParameterElementSummaryDtoSchema} from '../../shared/dto/parameter-element-summary.dto.js'; export const ParameterDtoSchema = z.object({ systemId: z.string().describe('System identifier'), 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..904fd5ad0 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 @@ -18,7 +18,7 @@ import { } from '@arc/core'; import type {CommandHandler} from '../../../orchestration/cqrs/commands/command-handler.js'; import {IssueFactory} from '../../../../shared/issues/factories.js'; -import {CONTAINER_PROP_ID_STACK_SIZE} from '../../../file-operations/shared/constants/spf-ids.js'; +import {CONTAINER_PROP_ID_STACK_SIZE} from '../../../../domain/entities/definitions/spf-ids.js'; 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'; diff --git a/packages/core/src/application/usecase-designer/subgraph/build-subgraph-with-defaults.ts b/packages/core/src/application/usecase-designer/subgraph/build-subgraph-with-defaults.ts index 88f82d4f7..d7cb37af9 100644 --- a/packages/core/src/application/usecase-designer/subgraph/build-subgraph-with-defaults.ts +++ b/packages/core/src/application/usecase-designer/subgraph/build-subgraph-with-defaults.ts @@ -6,7 +6,7 @@ import {Subgraph} from '../../../domain/entities/usecase-data/subgraph/subgraph.js'; import {SubgraphPropertyData} from '../../../domain/entities/usecase-data/subgraph/value-objects/subgraph-property.js'; import type {SubgraphPropertyDefinition} from '../../../domain/entities/definitions/subgraph/subgraph-property-definitions.js'; - +import {serializeDefaultParameterData} from '../shared/serialize-elements.js'; export interface SubgraphInit { systemId: number; subgraphNaturalId: number; @@ -14,28 +14,17 @@ export interface SubgraphInit { fileSystemId: number; } -/** - * Builds a complete Subgraph domain object with all property defaults seeded. - * - * Each property definition gets a SubgraphPropertyData with its default blob - * so that createSubgraph stages the subgraph row and all property data rows - * atomically as one complete aggregate. - * - * TODO(add-module-calibration-defaults): populate property blobs using - * serializeDefaultParameterData(propDef.elementsStructure) once that utility - * is implemented. See: docs/edit-crud/design/add-module-calibration-defaults-design.md §7 - */ export function buildSubgraphWithDefaults( init: SubgraphInit, propertyDefinitions: SubgraphPropertyDefinition[], ): Subgraph { - const properties = propertyDefinitions.map( - propDef => - new SubgraphPropertyData( - propDef.systemId, - null, // TODO: replace with serializeDefaultParameterData(propDef.elementsStructure) - ), - ); + const properties = propertyDefinitions.map(propDef => { + const serialized = serializeDefaultParameterData(propDef); + return new SubgraphPropertyData( + propDef.systemId, + serialized.ok ? serialized.value : null, + ); + }); return new Subgraph({ systemId: init.systemId, diff --git a/packages/core/src/application/usecase-designer/subgraph/dto/subgraph-write-result-types.ts b/packages/core/src/application/usecase-designer/subgraph/dto/subgraph-write-result-types.ts index 1d71cc1c9..f7b8fdea2 100644 --- a/packages/core/src/application/usecase-designer/subgraph/dto/subgraph-write-result-types.ts +++ b/packages/core/src/application/usecase-designer/subgraph/dto/subgraph-write-result-types.ts @@ -17,6 +17,7 @@ const CkvRefDtoSchema = z.object({ }); export const ScenarioChangeDtoSchema = z.object({ + groupId: z.string(), propertiesAdded: z.array(PropertyChangeDtoSchema), propertiesRemoved: z.array(PropertyChangeDtoSchema), moduleCkvsAdded: z.array(CkvRefDtoSchema), @@ -24,6 +25,7 @@ export const ScenarioChangeDtoSchema = z.object({ }); export const VsidUpdateDtoSchema = z.object({ + groupId: z.string(), affectedSubgraphSystemIds: z.array(z.string()), }); diff --git a/packages/core/src/application/usecase-designer/subgraph/get-properties/get-subgraph-properties.handler.ts b/packages/core/src/application/usecase-designer/subgraph/get-properties/get-subgraph-properties.handler.ts index 35cccf1c2..801a5a2cf 100644 --- a/packages/core/src/application/usecase-designer/subgraph/get-properties/get-subgraph-properties.handler.ts +++ b/packages/core/src/application/usecase-designer/subgraph/get-properties/get-subgraph-properties.handler.ts @@ -49,7 +49,7 @@ export class GetSubgraphPropertiesHandler implements QueryHandler< const payloads = payloadsResult.data; const definitionsResult = - await this.queryServices.subgraphPropertyDefQueryService.getAllDetailedSubgraphPropertyDefinitionsWithElements( + await this.queryServices.subgraphPropertyDefQueryService.getSubgraphPropertiesWithElements( fileSystemId, ); diff --git a/packages/core/src/application/usecase-designer/subgraph/get-property/get-subgraph-property.handler.ts b/packages/core/src/application/usecase-designer/subgraph/get-property/get-subgraph-property.handler.ts new file mode 100644 index 000000000..3581a19f9 --- /dev/null +++ b/packages/core/src/application/usecase-designer/subgraph/get-property/get-subgraph-property.handler.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {RESULT_KIND, Result} from '../../../shared/result/result.js'; +import {ResourceNotFoundException} from '../../../../shared/exceptions/resource-not-found.exception.js'; +import {parseParameterData} from '../../shared/parse-elements.js'; +import type {QueryHandler} from '../../../orchestration/cqrs/queries/query-handler.js'; +import type {QueryServices} from '../../../ports/persistence/query-services/query-services.js'; +import type {GetSubgraphPropertyQuery} from './get-subgraph-property.query.js'; +import type {PropertyDataDto} from '../../shared/property-read-model.js'; + +export class GetSubgraphPropertyHandler implements QueryHandler< + GetSubgraphPropertyQuery, + Promise> +> { + constructor(private readonly queryServices: QueryServices) {} + + async handle( + query: GetSubgraphPropertyQuery, + ): Promise> { + const fileSystemId = + await this.queryServices.projectQueryService.getFileIdByProjectId( + query.projectId, + ); + + const payloadsResult = + await this.queryServices.subgraphQueryService.findPropertyPayloads( + query.subgraphSystemId, + fileSystemId, + ); + if (payloadsResult.kind === RESULT_KIND.Fail) { + throw new Error( + payloadsResult.issues[0]?.message ?? + 'Failed to load subgraph properties', + ); + } + if (payloadsResult.data === null) { + throw new ResourceNotFoundException( + `Subgraph ${query.subgraphSystemId} not found`, + ); + } + + const parameterPayload = payloadsResult.data.find( + p => p.propertySystemId === query.propertySystemId, + ); + if (!parameterPayload) { + throw new ResourceNotFoundException( + `Property ${query.propertySystemId} not found on subgraph ${query.subgraphSystemId}`, + ); + } + + const defResult = + await this.queryServices.subgraphPropertyDefQueryService.getSubgraphPropertyWithElements( + query.propertySystemId, + fileSystemId, + ); + if (defResult.kind === RESULT_KIND.Fail) { + throw new ResourceNotFoundException( + `Property definition ${query.propertySystemId} not found`, + ); + } + + const elements = + parameterPayload.payload !== null + ? parseParameterData( + parameterPayload.payload, + defResult.data.elementsStructure, + ) + : []; + + return Result.ok({ + systemId: parameterPayload.systemId, + naturalId: defResult.data.naturalId, + propertyName: defResult.data.name, + elements, + }); + } +} diff --git a/packages/core/src/application/usecase-designer/subgraph/get-property/get-subgraph-property.query.ts b/packages/core/src/application/usecase-designer/subgraph/get-property/get-subgraph-property.query.ts new file mode 100644 index 000000000..1d5a4de8b --- /dev/null +++ b/packages/core/src/application/usecase-designer/subgraph/get-property/get-subgraph-property.query.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {BaseQuery} from '../../../shared/base-query.js'; + +export class GetSubgraphPropertyQuery extends BaseQuery { + public readonly projectId: number; + public readonly subgraphSystemId: number; + public readonly propertySystemId: number; + + constructor( + projectId: number, + subgraphSystemId: number, + propertySystemId: number, + clientId: string, + ) { + super(clientId); + this.projectId = projectId; + this.subgraphSystemId = subgraphSystemId; + this.propertySystemId = propertySystemId; + } +} diff --git a/packages/core/src/application/usecase-designer/subgraph/patch/patch-subgraph.handler.ts b/packages/core/src/application/usecase-designer/subgraph/patch/patch-subgraph.handler.ts deleted file mode 100644 index ac835739a..000000000 --- a/packages/core/src/application/usecase-designer/subgraph/patch/patch-subgraph.handler.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. - * SPDX-License-Identifier: BSD-3-Clause - */ - -import type {UnitOfWork} from '../../../ports/persistence/unit-of-work.js'; -import type {CommandHandler} from '../../../orchestration/cqrs/commands/command-handler.js'; -import type {PatchSubgraphCommand} from './patch-subgraph.command.js'; - -export class PatchSubgraphHandler implements CommandHandler< - PatchSubgraphCommand, - void -> { - constructor(_uow: UnitOfWork) {} - - handle(_command: PatchSubgraphCommand): Promise { - throw new Error('PatchSubgraphHandler not implemented yet'); - } -} diff --git a/packages/core/src/application/usecase-designer/subgraph/update-property/update-subgraph-property.command.ts b/packages/core/src/application/usecase-designer/subgraph/set-property/set-subgraph-property.command.ts similarity index 74% rename from packages/core/src/application/usecase-designer/subgraph/update-property/update-subgraph-property.command.ts rename to packages/core/src/application/usecase-designer/subgraph/set-property/set-subgraph-property.command.ts index 090e84d17..10540946d 100644 --- a/packages/core/src/application/usecase-designer/subgraph/update-property/update-subgraph-property.command.ts +++ b/packages/core/src/application/usecase-designer/subgraph/set-property/set-subgraph-property.command.ts @@ -6,8 +6,9 @@ import {BaseCommand} from '../../../shared/base-command.js'; import {SESSION_MODE} from '../../../shared/change-vocabulary.js'; import type {SessionMode} from '../../../shared/change-vocabulary.js'; +import type {ParameterElementSummaryDto} from '../../shared/dto/parameter-element-summary.dto.js'; -export class UpdateSubgraphPropertyCommand extends BaseCommand { +export class SetSubgraphPropertyCommand extends BaseCommand { static override readonly requiresSession = true; static override readonly allowedModes: readonly SessionMode[] = [ SESSION_MODE.Designer, @@ -17,7 +18,7 @@ export class UpdateSubgraphPropertyCommand extends BaseCommand { constructor( public readonly subgraphSystemId: number, public readonly propertySystemId: number, - public readonly data: unknown[], + public readonly elements: ParameterElementSummaryDto[], ) { super(); } diff --git a/packages/core/src/application/usecase-designer/subgraph/set-property/set-subgraph-property.handler.ts b/packages/core/src/application/usecase-designer/subgraph/set-property/set-subgraph-property.handler.ts new file mode 100644 index 000000000..6e2cd4ed2 --- /dev/null +++ b/packages/core/src/application/usecase-designer/subgraph/set-property/set-subgraph-property.handler.ts @@ -0,0 +1,76 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {ResourceNotFoundException} from '../../../../shared/exceptions/resource-not-found.exception.js'; +import {InvalidOperationException} from '../../../../shared/exceptions/invalid-operation.exception.js'; +import {serializeParameterData} from '../../shared/serialize-elements.js'; +import type {ElementData as ElementCalData} from '../../../../domain/entities/definitions/common/types/element-data.js'; +import { + SUB_GRAPH_PROP_ID_SCENARIO_ID, + SUB_GRAPH_PROP_ID_VSID, +} from '../../../../domain/entities/definitions/subgraph/subgraph-ids.js'; +import type {CommandHandler} from '../../../orchestration/cqrs/commands/command-handler.js'; +import type {UnitOfWork} from '../../../ports/persistence/unit-of-work.js'; +import type {SetSubgraphPropertyCommand} from './set-subgraph-property.command.js'; + +export class SetSubgraphPropertyHandler implements CommandHandler< + SetSubgraphPropertyCommand, + void +> { + constructor(private readonly uow: UnitOfWork) {} + + async handle(command: SetSubgraphPropertyCommand): Promise { + const {session} = this.uow.getWriteContext(); + + const exists = await this.uow + .getSubgraphRepository() + .subgraphExists(command.subgraphSystemId, session.fileSystemId); + if (!exists) { + throw new ResourceNotFoundException( + `Subgraph ${command.subgraphSystemId} not found`, + ); + } + + const repository = this.uow.getSubgraphRepository(); + const propertyDefinitions = await repository.getPropertyDefinitions( + session.fileSystemId, + ); + const propDef = propertyDefinitions.find( + definition => definition.systemId === command.propertySystemId, + ); + if (!propDef) { + throw new ResourceNotFoundException( + `Property definition ${command.propertySystemId} not found`, + ); + } + + if ( + propDef.naturalId === SUB_GRAPH_PROP_ID_SCENARIO_ID || + propDef.naturalId === SUB_GRAPH_PROP_ID_VSID + ) { + throw new InvalidOperationException( + `Property ${propDef.name} is reserved and cannot be replaced through the generic property operation.`, + ); + } + + const serialized = serializeParameterData( + { + systemId: propDef.systemId, + isReadOnly: false, + elementsStructure: propDef.elementsStructure, + }, + command.elements as unknown as ElementCalData[], + ); + if (!serialized.ok) { + throw new InvalidOperationException(serialized.error); + } + + await repository.setPropertyData( + command.subgraphSystemId, + command.propertySystemId, + serialized.value, + ); + } +} diff --git a/packages/core/src/application/usecase-designer/subgraph/update-vsid/update-subgraph-vsid.command.ts b/packages/core/src/application/usecase-designer/subgraph/set-scenario/set-subgraph-scenario.command.ts similarity index 72% rename from packages/core/src/application/usecase-designer/subgraph/update-vsid/update-subgraph-vsid.command.ts rename to packages/core/src/application/usecase-designer/subgraph/set-scenario/set-subgraph-scenario.command.ts index 99f89031c..07c2f2b44 100644 --- a/packages/core/src/application/usecase-designer/subgraph/update-vsid/update-subgraph-vsid.command.ts +++ b/packages/core/src/application/usecase-designer/subgraph/set-scenario/set-subgraph-scenario.command.ts @@ -6,8 +6,9 @@ import {BaseCommand} from '../../../shared/base-command.js'; import {SESSION_MODE} from '../../../shared/change-vocabulary.js'; import type {SessionMode} from '../../../shared/change-vocabulary.js'; +import type {ParameterElementSummaryDto} from '../../shared/dto/parameter-element-summary.dto.js'; -export class UpdateSubgraphVsidCommand extends BaseCommand { +export class SetSubgraphScenarioCommand extends BaseCommand { static override readonly requiresSession = true; static override readonly allowedModes: readonly SessionMode[] = [ SESSION_MODE.Designer, @@ -16,7 +17,7 @@ export class UpdateSubgraphVsidCommand extends BaseCommand { constructor( public readonly subgraphSystemId: number, - public readonly data: unknown[], + public readonly elements: ParameterElementSummaryDto[], ) { super(); } diff --git a/packages/core/src/application/usecase-designer/subgraph/set-scenario/set-subgraph-scenario.handler.ts b/packages/core/src/application/usecase-designer/subgraph/set-scenario/set-subgraph-scenario.handler.ts new file mode 100644 index 000000000..58d896d16 --- /dev/null +++ b/packages/core/src/application/usecase-designer/subgraph/set-scenario/set-subgraph-scenario.handler.ts @@ -0,0 +1,485 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {ResourceNotFoundException} from '../../../../shared/exceptions/resource-not-found.exception.js'; +import {InvalidOperationException} from '../../../../shared/exceptions/invalid-operation.exception.js'; +import {DomainRuleViolationException} from '../../../../shared/exceptions/domain-rule-violation.exception.js'; +import {IssueSeverity} from '../../../../shared/issues/severity.js'; +import { + serializeDefaultParameterData, + serializeParameterData, +} from '../../shared/serialize-elements.js'; +import type {ElementData as ElementCalData} from '../../../../domain/entities/definitions/common/types/element-data.js'; +import {BinaryDataReader} from '../../shared/utils/binary-data-reader.js'; +import {encodeVsidPayload} from '../../../../domain/services/subgraph-property/subgraph-property-payload-codec.js'; +import { + SUB_GRAPH_PROP_ID_SCENARIO_ID, + SUB_GRAPH_PROP_ID_VSID, + SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR, + SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL, +} from '../../../../domain/entities/definitions/subgraph/subgraph-ids.js'; +import type {CommandHandler} from '../../../orchestration/cqrs/commands/command-handler.js'; +import type {UnitOfWork} from '../../../ports/persistence/unit-of-work.js'; +import type {SetSubgraphScenarioCommand} from './set-subgraph-scenario.command.js'; +import type {ScenarioChangeDto} from '../dto/subgraph-write-result-types.js'; +import type {SubgraphPropertyDefinition} from '../../../../domain/entities/definitions/subgraph/subgraph-property-definitions.js'; +import type {SubgraphWithProperties} from '../../../ports/persistence/repositories/subgraph/subgraph.repository.js'; +import type {SpfModuleBase} from '../../../ports/persistence/repositories/module/module.repository.js'; + +type MutationLog = Pick< + ScenarioChangeDto, + | 'propertiesAdded' + | 'propertiesRemoved' + | 'moduleCkvsAdded' + | 'moduleCkvsDeleted' +>; + +export class SetSubgraphScenarioHandler implements CommandHandler< + SetSubgraphScenarioCommand, + ScenarioChangeDto +> { + constructor(private readonly uow: UnitOfWork) {} + + async handle( + command: SetSubgraphScenarioCommand, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + const {fileSystemId} = session; + const repository = this.uow.getSubgraphRepository(); + + const subgraph = await repository.getAggregate( + command.subgraphSystemId, + fileSystemId, + ); + if (!subgraph) { + throw new ResourceNotFoundException( + `Subgraph ${command.subgraphSystemId} not found`, + ); + } + + const allDefs = await repository.getPropertyDefinitions(fileSystemId); + const { + scenarioDef, + currentScenario, + requestedScenario, + serializedScenario, + } = this.resolveScenarioContext(command, subgraph, allDefs); + + if (currentScenario === requestedScenario) { + return { + groupId, + propertiesAdded: [], + propertiesRemoved: [], + moduleCkvsAdded: [], + moduleCkvsDeleted: [], + }; + } + + const isAudioToVoice = + currentScenario !== SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL && + requestedScenario === SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL; + const isVoiceToAudio = + currentScenario === SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL && + requestedScenario !== SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL; + + let optimalVsid: number | undefined; + if (isAudioToVoice) { + optimalVsid = await this.getOptimalVsid( + command.subgraphSystemId, + fileSystemId, + allDefs, + ); + } + + // Pre-fetch modules before transaction — reads must not be inside the write transaction + const modules = + isAudioToVoice || isVoiceToAudio + ? await this.uow + .getModuleRepository() + .getModulesBySubgraphId(command.subgraphSystemId, fileSystemId) + : []; + + const log: MutationLog = { + propertiesAdded: [], + propertiesRemoved: [], + moduleCkvsAdded: [], + moduleCkvsDeleted: [], + }; + + await this.uow.startTransaction(); + try { + if (isAudioToVoice) { + await this.audioToVoiceCascade( + command.subgraphSystemId, + fileSystemId, + subgraph, + allDefs, + optimalVsid, + modules, + log, + ); + } else if (isVoiceToAudio) { + await this.voiceToAudioCascade( + command.subgraphSystemId, + fileSystemId, + subgraph, + allDefs, + modules, + log, + ); + } + + await repository.setPropertyData( + command.subgraphSystemId, + scenarioDef.systemId, + serializedScenario, + ); + + await this.uow.commit(); + } catch (error) { + if (this.uow.isInTransaction()) await this.uow.rollback(); + throw error; + } + + return {groupId, ...log}; + } + + private resolveScenarioContext( + command: SetSubgraphScenarioCommand, + subgraph: SubgraphWithProperties, + definitions: SubgraphPropertyDefinition[], + ) { + const scenarioDef = definitions.find( + definition => definition.naturalId === SUB_GRAPH_PROP_ID_SCENARIO_ID, + ); + if (!scenarioDef) { + throw new ResourceNotFoundException( + 'Scenario property definition not found', + ); + } + + const scenarioProp = subgraph.properties.find( + p => p.propertySystemId === scenarioDef.systemId, + ); + const currentScenario = scenarioProp?.payload + ? new BinaryDataReader(scenarioProp.payload).readUInt32() + : undefined; + + const requestedScenario = Number(command.elements[0]?.value); + + const serialized = serializeParameterData( + { + systemId: scenarioDef.systemId, + isReadOnly: false, + elementsStructure: scenarioDef.elementsStructure, + }, + command.elements as unknown as ElementCalData[], + ); + if (!serialized.ok) { + throw new InvalidOperationException(serialized.error); + } + return { + scenarioDef, + currentScenario, + requestedScenario, + serializedScenario: serialized.value, + }; + } + + private async audioToVoiceCascade( + subgraphSystemId: number, + fileSystemId: number, + subgraph: SubgraphWithProperties, + allDefs: SubgraphPropertyDefinition[], + optimalVsid: number | undefined, + modules: SpfModuleBase[], + log: MutationLog, + ): Promise { + const voiceDefs = allDefs.filter(d => d.isVoice); + const clockScaleDef = allDefs.find( + d => d.naturalId === SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR, + ); + const existingPropIds = new Set( + subgraph.properties.map(p => p.propertySystemId), + ); + + for (const def of voiceDefs) { + if (existingPropIds.has(def.systemId)) continue; + const payload = this.serializeDefaultPropertyData(def); + const newId = await this.uow + .getSubgraphRepository() + .addProperty(subgraphSystemId, def.systemId, payload); + log.propertiesAdded.push({ + systemId: String(newId), + naturalId: def.naturalId, + propertyName: def.name, + }); + } + + if (clockScaleDef) { + const clockProp = subgraph.properties.find( + p => p.propertySystemId === clockScaleDef.systemId, + ); + if (clockProp) { + await this.uow + .getSubgraphRepository() + .removeProperty(subgraphSystemId, clockProp.systemId); + log.propertiesRemoved.push({ + systemId: String(clockProp.systemId), + naturalId: clockScaleDef.naturalId, + propertyName: clockScaleDef.name, + }); + } + } + + if (optimalVsid !== undefined) { + const vsidDef = allDefs.find( + definition => definition.naturalId === SUB_GRAPH_PROP_ID_VSID, + ); + if (vsidDef) { + await this.uow + .getSubgraphRepository() + .setPropertyData( + subgraphSystemId, + vsidDef.systemId, + encodeVsidPayload(optimalVsid), + ); + } + } + + await this.wipeModuleCalData(modules, fileSystemId, log); + + const vcpmDefs = await this.uow + .getVcpmDefinitionRepository() + .getAllVcpmModuleDefinitions(fileSystemId); + const defaults = vcpmDefs.map(definition => ({ + definitionSystemId: definition.systemId, + parameters: definition.parameters.map(parameter => { + const serialized = serializeDefaultParameterData(parameter); + if (!serialized.ok) { + throw new InvalidOperationException(serialized.error); + } + return { + parameterSystemId: parameter.systemId, + payload: serialized.value, + }; + }), + })); + await this.uow + .getVcpmDefinitionRepository() + .addVcpmCfgDefaultData(subgraphSystemId, defaults); + } + + private async voiceToAudioCascade( + subgraphSystemId: number, + fileSystemId: number, + subgraph: SubgraphWithProperties, + allDefs: SubgraphPropertyDefinition[], + modules: SpfModuleBase[], + log: MutationLog, + ): Promise { + await this.wipeModuleCalData(modules, fileSystemId, log); + + const voiceDefs = allDefs.filter(d => d.isVoice); + for (const def of voiceDefs) { + const voiceProp = subgraph.properties.find( + p => p.propertySystemId === def.systemId, + ); + if (!voiceProp) continue; + await this.uow + .getSubgraphRepository() + .removeProperty(subgraphSystemId, voiceProp.systemId); + log.propertiesRemoved.push({ + systemId: String(voiceProp.systemId), + naturalId: def.naturalId, + propertyName: def.name, + }); + } + + const clockScaleDef = allDefs.find( + d => d.naturalId === SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR, + ); + if (clockScaleDef) { + const payload = this.serializeDefaultPropertyData(clockScaleDef); + const newId = await this.uow + .getSubgraphRepository() + .addProperty(subgraphSystemId, clockScaleDef.systemId, payload); + log.propertiesAdded.push({ + systemId: String(newId), + naturalId: clockScaleDef.naturalId, + propertyName: clockScaleDef.name, + }); + } + + await this.uow + .getSubgraphRepository() + .removeAllVcpmCfgData(subgraphSystemId); + } + + private serializeDefaultPropertyData( + definition: SubgraphPropertyDefinition, + ): Uint8Array { + const serialized = serializeDefaultParameterData(definition); + if (!serialized.ok) { + throw new InvalidOperationException(serialized.error); + } + return serialized.value; + } + + private async wipeModuleCalData( + modules: SpfModuleBase[], + fileSystemId: number, + _log: MutationLog, + ): Promise { + await Promise.all( + modules.map(mod => + // TODO(subgraph-write-review): This compatibility call currently + // performs only TKV cleanup. CKV deletion and zero-CKV reset are + // intentionally disabled until core creates and passes a reset plan. + this.uow + .getModuleRepository() + .wipeCalData(mod.systemId, fileSystemId) + .then(wiped => ({mod, wiped})), + ), + ); + /* + * TODO(subgraph-write-review): Re-enable CKV deletion and zero-CKV reset + * reporting when core owns the CKV reset-plan creation. Both mutation-log + * updates are intentionally deferred from this PR. + * + * for (const {mod, wiped} of results) { + * log.moduleCkvsDeleted.push( + * ...wiped.ckvsDeleted.map(c => ({ + * moduleSystemId: String(mod.systemId), + * ckvSystemId: String(c), + * })), + * ); + * log.moduleCkvsAdded.push( + * ...wiped.zeroCkvsAdded.map(c => ({ + * moduleSystemId: String(mod.systemId), + * ckvSystemId: String(c), + * })), + * ); + * } + */ + } + + private async getOptimalVsid( + subgraphSystemId: number, + fileSystemId: number, + allDefs: SubgraphPropertyDefinition[], + ): Promise { + const vsidDef = allDefs.find(d => d.naturalId === SUB_GRAPH_PROP_ID_VSID); + if (!vsidDef) + throw new ResourceNotFoundException('VSID property definition not found'); + + const scenarioDefSystemId = allDefs.find( + d => d.naturalId === SUB_GRAPH_PROP_ID_SCENARIO_ID, + )?.systemId; + const foundVsids = await this.bfsCollectVoiceVsids( + subgraphSystemId, + fileSystemId, + vsidDef.systemId, + scenarioDefSystemId, + ); + + if (foundVsids.size === 0) { + const serialized = serializeDefaultParameterData(vsidDef); + if (!serialized.ok) { + throw new InvalidOperationException( + `Unable to generate default VSID payload: ${serialized.error}`, + ); + } + + try { + return new BinaryDataReader(serialized.value).readUInt32(); + } catch { + throw new InvalidOperationException( + 'Unable to read generated default VSID payload', + ); + } + } + if (foundVsids.size === 1) { + return [...foundVsids][0]; + } + throw new DomainRuleViolationException([ + { + code: 'VSID_CONFLICT', + message: `Conflicting VSIDs found across linked usecases: ${[...foundVsids].join(', ')}`, + severity: IssueSeverity.Error, + }, + ]); + } + + /** + * Subgraphs form a graph through their shared usecases. Traverse that + * graph to find linked voice subgraphs whose VSID can be reused when the + * current subgraph changes from an audio scenario to voice-call mode. + */ + private async bfsCollectVoiceVsids( + startSubgraphId: number, + fileSystemId: number, + vsidDefSystemId: number, + scenarioDefSystemId: number | undefined, + ): Promise> { + // Pass 1: BFS using only getSubgraphIdsInSameUsecases + const reachableIds = await this.bfsReachableIds( + startSubgraphId, + fileSystemId, + ); + reachableIds.delete(startSubgraphId); // exclude self — we only want linked Voice subgraphs + + if (reachableIds.size === 0) return new Set(); + + // Pass 2: batch-fetch properties in 2 queries + const subgraphMap = await this.uow + .getSubgraphRepository() + .getAggregates([...reachableIds], fileSystemId); + + // Pass 3: collect VSIDs from Voice subgraphs only + const foundVsids = new Set(); + for (const [, sg] of subgraphMap) { + if (scenarioDefSystemId !== undefined) { + const scenarioProp = sg.properties.find( + p => p.propertySystemId === scenarioDefSystemId, + ); + const scenarioVal = scenarioProp?.payload + ? new BinaryDataReader(scenarioProp.payload).readUInt32() + : undefined; + if (scenarioVal !== SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL) + continue; + } + const vsidProp = sg.properties.find( + p => p.propertySystemId === vsidDefSystemId, + ); + if (vsidProp?.payload) { + foundVsids.add(new BinaryDataReader(vsidProp.payload).readUInt32()); + } + } + return foundVsids; + } + + /** + * Expand the graph one level at a time. `visited` prevents revisiting a + * subgraph when usecases create cycles and guarantees termination. + * Repository traversal keeps the handler independent of link tables. + */ + private async bfsReachableIds( + startId: number, + fileSystemId: number, + ): Promise> { + const visited = new Set([startId]); + let frontier = [startId]; + + while (frontier.length > 0) { + const linked = await this.uow + .getSubgraphRepository() + .getSubgraphIdsInSameUsecasesForMany(frontier, fileSystemId); + frontier = linked.filter(id => !visited.has(id)); + for (const id of frontier) visited.add(id); + } + return visited; + } +} diff --git a/packages/core/src/application/usecase-designer/subgraph/update-scenario/update-subgraph-scenario.command.ts b/packages/core/src/application/usecase-designer/subgraph/set-vsid/set-subgraph-vsid.command.ts similarity index 72% rename from packages/core/src/application/usecase-designer/subgraph/update-scenario/update-subgraph-scenario.command.ts rename to packages/core/src/application/usecase-designer/subgraph/set-vsid/set-subgraph-vsid.command.ts index f76c3da6a..2ab58ba95 100644 --- a/packages/core/src/application/usecase-designer/subgraph/update-scenario/update-subgraph-scenario.command.ts +++ b/packages/core/src/application/usecase-designer/subgraph/set-vsid/set-subgraph-vsid.command.ts @@ -6,8 +6,9 @@ import {BaseCommand} from '../../../shared/base-command.js'; import {SESSION_MODE} from '../../../shared/change-vocabulary.js'; import type {SessionMode} from '../../../shared/change-vocabulary.js'; +import type {ParameterElementSummaryDto} from '../../shared/dto/parameter-element-summary.dto.js'; -export class UpdateSubgraphScenarioCommand extends BaseCommand { +export class SetSubgraphVsidCommand extends BaseCommand { static override readonly requiresSession = true; static override readonly allowedModes: readonly SessionMode[] = [ SESSION_MODE.Designer, @@ -16,7 +17,7 @@ export class UpdateSubgraphScenarioCommand extends BaseCommand { constructor( public readonly subgraphSystemId: number, - public readonly data: unknown[], + public readonly elements: ParameterElementSummaryDto[], ) { super(); } diff --git a/packages/core/src/application/usecase-designer/subgraph/set-vsid/set-subgraph-vsid.handler.ts b/packages/core/src/application/usecase-designer/subgraph/set-vsid/set-subgraph-vsid.handler.ts new file mode 100644 index 000000000..cee39a62e --- /dev/null +++ b/packages/core/src/application/usecase-designer/subgraph/set-vsid/set-subgraph-vsid.handler.ts @@ -0,0 +1,190 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {ResourceNotFoundException} from '../../../../shared/exceptions/resource-not-found.exception.js'; +import {InvalidOperationException} from '../../../../shared/exceptions/invalid-operation.exception.js'; +import {serializeParameterData} from '../../shared/serialize-elements.js'; +import type {ElementData as ElementCalData} from '../../../../domain/entities/definitions/common/types/element-data.js'; +import {BinaryDataReader} from '../../shared/utils/binary-data-reader.js'; +import { + SUB_GRAPH_PROP_ID_VSID, + SUB_GRAPH_PROP_ID_SCENARIO_ID, + SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL, +} from '../../../../domain/entities/definitions/subgraph/subgraph-ids.js'; +import type {CommandHandler} from '../../../orchestration/cqrs/commands/command-handler.js'; +import type {UnitOfWork} from '../../../ports/persistence/unit-of-work.js'; +import type {SetSubgraphVsidCommand} from './set-subgraph-vsid.command.js'; +import type {VsidUpdateDto} from '../dto/subgraph-write-result-types.js'; +import type {SubgraphWithProperties} from '../../../ports/persistence/repositories/subgraph/subgraph.repository.js'; + +export class SetSubgraphVsidHandler implements CommandHandler< + SetSubgraphVsidCommand, + VsidUpdateDto +> { + constructor(private readonly uow: UnitOfWork) {} + + async handle(command: SetSubgraphVsidCommand): Promise { + const {session, groupId} = this.uow.getWriteContext(); + const {fileSystemId} = session; + const repository = this.uow.getSubgraphRepository(); + + const subgraph = await repository.getAggregate( + command.subgraphSystemId, + fileSystemId, + ); + if (!subgraph) { + throw new ResourceNotFoundException( + `Subgraph ${command.subgraphSystemId} not found`, + ); + } + + const definitions = await repository.getPropertyDefinitions(fileSystemId); + const vsidDef = definitions.find( + definition => definition.naturalId === SUB_GRAPH_PROP_ID_VSID, + ); + if (!vsidDef) { + throw new ResourceNotFoundException('VSID property definition not found'); + } + + const scenarioDef = definitions.find( + definition => definition.naturalId === SUB_GRAPH_PROP_ID_SCENARIO_ID, + ); + + const vsidProp = subgraph.properties.find( + p => p.propertySystemId === vsidDef.systemId, + ); + const currentVsid = vsidProp?.payload + ? new BinaryDataReader(vsidProp.payload).readUInt32() + : undefined; + + const requestedVsid = Number(command.elements[0]?.value); + + if (currentVsid === requestedVsid) { + return {groupId, affectedSubgraphSystemIds: []}; + } + + const serialized = serializeParameterData( + { + systemId: vsidDef.systemId, + isReadOnly: false, + elementsStructure: vsidDef.elementsStructure, + }, + command.elements as unknown as ElementCalData[], + ); + if (!serialized.ok) { + throw new InvalidOperationException(serialized.error); + } + + // BFS across usecases + const toWrite = await this.collectSubgraphsToUpdate( + command.subgraphSystemId, + fileSystemId, + vsidDef.systemId, + scenarioDef?.systemId, + requestedVsid, + subgraph, + ); + + await this.uow.startTransaction(); + try { + await Promise.all( + [...toWrite].map(sgId => + this.uow + .getSubgraphRepository() + .setPropertyData(sgId, vsidDef.systemId, serialized.value), + ), + ); + await this.uow.commit(); + } catch (error) { + if (this.uow.isInTransaction()) await this.uow.rollback(); + throw error; + } + + return {groupId, affectedSubgraphSystemIds: [...toWrite].map(String)}; + } + + private async collectSubgraphsToUpdate( + startId: number, + fileSystemId: number, + vsidDefSystemId: number, + scenarioDefSystemId: number | undefined, + requestedVsid: number, + startSubgraph: SubgraphWithProperties, + ): Promise> { + // Pass 1: BFS to collect all reachable IDs + const reachableIds = await this.bfsReachableIds(startId, fileSystemId); + + // Pass 2: batch-fetch properties for linked subgraphs only (startId already fetched) + const linkedIds = [...reachableIds].filter(id => id !== startId); + const subgraphMap = + linkedIds.length > 0 + ? await this.uow + .getSubgraphRepository() + .getAggregates(linkedIds, fileSystemId) + : new Map(); + + // Seed the map with the already-fetched start subgraph + subgraphMap.set(startId, startSubgraph); + + // Pass 3: filter — determine which IDs need a VSID write + const toWrite = new Set([startId]); + for (const [id, sg] of subgraphMap) { + if (id === startId) continue; + if ( + this.shouldUpdateVsid( + sg, + vsidDefSystemId, + scenarioDefSystemId, + requestedVsid, + ) + ) { + toWrite.add(id); + } + } + return toWrite; + } + + private shouldUpdateVsid( + sg: SubgraphWithProperties, + vsidDefSystemId: number, + scenarioDefSystemId: number | undefined, + requestedVsid: number, + ): boolean { + if (scenarioDefSystemId !== undefined) { + const scenarioProp = sg.properties.find( + p => p.propertySystemId === scenarioDefSystemId, + ); + const scenarioVal = scenarioProp?.payload + ? new BinaryDataReader(scenarioProp.payload).readUInt32() + : undefined; + if (scenarioVal !== SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL) + return false; + } + const vsidProp = sg.properties.find( + p => p.propertySystemId === vsidDefSystemId, + ); + const linkedVsid = vsidProp?.payload + ? new BinaryDataReader(vsidProp.payload).readUInt32() + : undefined; + return linkedVsid !== requestedVsid; + } + + private async bfsReachableIds( + startId: number, + fileSystemId: number, + ): Promise> { + const visited = new Set([startId]); + let frontier = [startId]; + + while (frontier.length > 0) { + const linked = await this.uow + .getSubgraphRepository() + .getSubgraphIdsInSameUsecasesForMany(frontier, fileSystemId); + frontier = linked.filter(id => !visited.has(id)); + for (const id of frontier) visited.add(id); + } + return visited; + } +} diff --git a/packages/core/src/application/usecase-designer/subgraph/patch/patch-subgraph.command.ts b/packages/core/src/application/usecase-designer/subgraph/set/set-subgraph.command.ts similarity index 91% rename from packages/core/src/application/usecase-designer/subgraph/patch/patch-subgraph.command.ts rename to packages/core/src/application/usecase-designer/subgraph/set/set-subgraph.command.ts index bcd427032..3373e0e10 100644 --- a/packages/core/src/application/usecase-designer/subgraph/patch/patch-subgraph.command.ts +++ b/packages/core/src/application/usecase-designer/subgraph/set/set-subgraph.command.ts @@ -7,7 +7,7 @@ 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 PatchSubgraphCommand extends BaseCommand { +export class SetSubgraphCommand extends BaseCommand { static override readonly requiresSession = true; static override readonly allowedModes: readonly SessionMode[] = [ SESSION_MODE.Designer, diff --git a/packages/core/src/application/usecase-designer/subgraph/set/set-subgraph.handler.ts b/packages/core/src/application/usecase-designer/subgraph/set/set-subgraph.handler.ts new file mode 100644 index 000000000..9f6cbf69b --- /dev/null +++ b/packages/core/src/application/usecase-designer/subgraph/set/set-subgraph.handler.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {ResourceNotFoundException} from '../../../../shared/exceptions/resource-not-found.exception.js'; +import type {CommandHandler} from '../../../orchestration/cqrs/commands/command-handler.js'; +import type {UnitOfWork} from '../../../ports/persistence/unit-of-work.js'; +import type {SetSubgraphCommand} from './set-subgraph.command.js'; + +export class SetSubgraphHandler implements CommandHandler< + SetSubgraphCommand, + {groupId: string} +> { + constructor(private readonly uow: UnitOfWork) {} + + async handle(command: SetSubgraphCommand): Promise<{groupId: string}> { + const {session, groupId} = this.uow.getWriteContext(); + const repository = this.uow.getSubgraphRepository(); + + const exists = await repository.subgraphExists( + command.subgraphSystemId, + session.fileSystemId, + ); + if (!exists) { + throw new ResourceNotFoundException( + `Subgraph ${command.subgraphSystemId} not found`, + ); + } + + if (command.name !== undefined) { + await repository.rename(command.subgraphSystemId, command.name); + } + + return {groupId}; + } +} diff --git a/packages/core/src/application/usecase-designer/subgraph/update-property/update-subgraph-property.handler.ts b/packages/core/src/application/usecase-designer/subgraph/update-property/update-subgraph-property.handler.ts deleted file mode 100644 index 9e9cc224c..000000000 --- a/packages/core/src/application/usecase-designer/subgraph/update-property/update-subgraph-property.handler.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 {UpdateSubgraphPropertyCommand} from './update-subgraph-property.command.js'; - -export class UpdateSubgraphPropertyHandler implements CommandHandler< - UpdateSubgraphPropertyCommand, - void -> { - constructor(_uow: UnitOfWork) {} - - handle(_command: UpdateSubgraphPropertyCommand): Promise { - throw new Error('UpdateSubgraphPropertyHandler not implemented yet'); - } -} diff --git a/packages/core/src/application/usecase-designer/subgraph/update-scenario/update-subgraph-scenario.handler.ts b/packages/core/src/application/usecase-designer/subgraph/update-scenario/update-subgraph-scenario.handler.ts deleted file mode 100644 index 325397efe..000000000 --- a/packages/core/src/application/usecase-designer/subgraph/update-scenario/update-subgraph-scenario.handler.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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 {UpdateSubgraphScenarioCommand} from './update-subgraph-scenario.command.js'; -import type {ScenarioChangeDto} from '../dto/subgraph-write-result-types.js'; - -export class UpdateSubgraphScenarioHandler implements CommandHandler< - UpdateSubgraphScenarioCommand, - ScenarioChangeDto -> { - constructor(_uow: UnitOfWork) {} - - handle(_command: UpdateSubgraphScenarioCommand): Promise { - throw new Error('UpdateSubgraphScenarioHandler not implemented yet'); - } -} diff --git a/packages/core/src/application/usecase-designer/subgraph/update-vsid/update-subgraph-vsid.handler.ts b/packages/core/src/application/usecase-designer/subgraph/update-vsid/update-subgraph-vsid.handler.ts deleted file mode 100644 index 6d07ed68a..000000000 --- a/packages/core/src/application/usecase-designer/subgraph/update-vsid/update-subgraph-vsid.handler.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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 {UpdateSubgraphVsidCommand} from './update-subgraph-vsid.command.js'; -import type {VsidUpdateDto} from '../dto/subgraph-write-result-types.js'; - -export class UpdateSubgraphVsidHandler implements CommandHandler< - UpdateSubgraphVsidCommand, - VsidUpdateDto -> { - constructor(_uow: UnitOfWork) {} - - handle(_command: UpdateSubgraphVsidCommand): Promise { - throw new Error('UpdateSubgraphVsidHandler not implemented yet'); - } -} diff --git a/packages/core/src/application/file-operations/shared/constants/spf-ids.ts b/packages/core/src/domain/entities/definitions/spf-ids.ts similarity index 74% rename from packages/core/src/application/file-operations/shared/constants/spf-ids.ts rename to packages/core/src/domain/entities/definitions/spf-ids.ts index 063e5e313..4d54b5aa5 100644 --- a/packages/core/src/application/file-operations/shared/constants/spf-ids.ts +++ b/packages/core/src/domain/entities/definitions/spf-ids.ts @@ -12,7 +12,6 @@ export const SPF_APM_MODULE_ID = 0x00_00_00_01; export const SPF_VCPM_MODULE_ID = 0x00_00_00_04; // APM Parameter IDs -export const PARAM_ID_SUB_GRAPH_CONFIG = 0x08_00_10_01; export const PARAM_ID_CONTAINER_CONFIG = 0x08_00_10_00; export const PARAM_ID_MODULES_LIST = 0x08_00_10_02; export const PARAM_ID_MODULE_PROP = 0x08_00_10_03; @@ -24,13 +23,6 @@ export const SPF_VCPM_PARAM_ID_CAL_KEYS = 0x08_00_11_c1; export const PARAM_ID_VOICE_SG_CONFIG = 0x08_00_11_62; export const PARAM_ID_VOICE_CAL_TBL = 0x08_00_11_63; -// Subgraph Property IDs -export const SUB_GRAPH_PROP_ID_PERF_MODE = 0x08_00_10_0e; -export const SUB_GRAPH_PROP_ID_SCENARIO_ID = 0x08_00_10_10; -export const SUB_GRAPH_PROP_ID_DIRECTION = 0x08_00_10_0f; -export const SUB_GRAPH_PROP_ID_VSID = 0x08_00_10_cc; -export const SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR = 0x08_00_13_74; - // Container Property IDs export const CONTAINER_PROP_ID_CAPABILITY_LIST = 0x08_00_10_11; export const CONTAINER_PROP_ID_GRAPH_POS = 0x08_00_10_12; @@ -49,11 +41,6 @@ export const MODULE_PROP_ID_CTRL_HEAP_ID = 0x08_00_13_6f; // VCPM Property IDs export const VCPM_PROP_ID_TAG_INFO = 0x08_00_11_b2; -// Scenario Values -export const SUB_GRAPH_PROP_ID_SCENARIO_VALUE_AUDIO_PLAYBACK = 0x00_00_00_01; -export const SUB_GRAPH_PROP_ID_SCENARIO_VALUE_AUDIO_RECORDING = 0x00_00_00_02; -export const SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL = 0x00_00_00_03; - // Heap IDs export const HEAP_ID_DEFAULT = 1; export const HEAP_ID_LOW_POWER = 2; diff --git a/packages/core/src/domain/entities/definitions/subgraph/subgraph-ids.ts b/packages/core/src/domain/entities/definitions/subgraph/subgraph-ids.ts new file mode 100644 index 000000000..f81c7b87b --- /dev/null +++ b/packages/core/src/domain/entities/definitions/subgraph/subgraph-ids.ts @@ -0,0 +1,21 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** SPF parameter, property, and scenario IDs specific to subgraphs. */ + +// APM subgraph parameter ID +export const PARAM_ID_SUB_GRAPH_CONFIG = 0x08_00_10_01; + +// Subgraph Property IDs +export const SUB_GRAPH_PROP_ID_PERF_MODE = 0x08_00_10_0e; +export const SUB_GRAPH_PROP_ID_SCENARIO_ID = 0x08_00_10_10; +export const SUB_GRAPH_PROP_ID_DIRECTION = 0x08_00_10_0f; +export const SUB_GRAPH_PROP_ID_VSID = 0x08_00_10_cc; +export const SUB_GRAPH_PROP_CLOCK_SCALE_FACTOR = 0x08_00_13_74; + +// Scenario Values +export const SUB_GRAPH_PROP_ID_SCENARIO_VALUE_AUDIO_PLAYBACK = 0x00_00_00_01; +export const SUB_GRAPH_PROP_ID_SCENARIO_VALUE_AUDIO_RECORDING = 0x00_00_00_02; +export const SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL = 0x00_00_00_03; diff --git a/packages/core/src/domain/services/subgraph-property/subgraph-property-payload-codec.ts b/packages/core/src/domain/services/subgraph-property/subgraph-property-payload-codec.ts new file mode 100644 index 000000000..35a344d58 --- /dev/null +++ b/packages/core/src/domain/services/subgraph-property/subgraph-property-payload-codec.ts @@ -0,0 +1,14 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * Encodes a VSID into the 8-byte Subgraph property payload format. + * The value is a little-endian UInt32 followed by 4 bytes of alignment. + */ +export function encodeVsidPayload(vsid: number): Uint8Array { + const payload = new Uint8Array(8); + new DataView(payload.buffer).setUint32(0, vsid, true); + return payload; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f7fc96e75..f5dc6660a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -75,7 +75,15 @@ export type { SubsystemDataRouteContext, } from './application/ports/persistence/repositories/data-link/data-link.repository.js'; export type {ControlLinkRepository} from './application/ports/persistence/repositories/control-link/control-link.repository.js'; -export type {SubgraphRepository} from './application/ports/persistence/repositories/subgraph/subgraph.repository.js'; +export type { + SubgraphRepository, + SubgraphWithProperties, +} from './application/ports/persistence/repositories/subgraph/subgraph.repository.js'; +export type { + VcpmDefinitionRepository, + VcpmDefaultData, + VcpmModuleDefinitionWithParamsReadModel, +} from './application/ports/persistence/repositories/vcpm-definition/vcpm-definition.repository.js'; export type { SubsystemControlPortRef, SubsystemRepository, @@ -278,8 +286,12 @@ export type { ElementTemplateArrayDto, StructDto, } from './shared/dto/element-data/element-union.js'; -export {PropertyDtoSchema} from './shared/dto/property-dto.js'; +export { + PropertyDtoSchema, + mapPropertyToDto, +} from './shared/dto/property-dto.js'; export type {PropertyDto} from './shared/dto/property-dto.js'; +export type {PropertyDataDto} from './application/usecase-designer/shared/property-read-model.js'; // Container query handlers export * from './application/usecase-designer/container/query/query-containers.query.js'; @@ -400,15 +412,23 @@ export type { export {GetVcpmCkvQuery} from './application/usecase-designer/subgraph/get-vcpm-ckv/get-vcpm-ckv.query.js'; export {GetVcpmCalDataQuery} from './application/usecase-designer/subgraph/get-vcpm-cal-data/get-vcpm-cal-data.query.js'; // Subgraph write commands -export {UpdateSubgraphScenarioCommand} from './application/usecase-designer/subgraph/update-scenario/update-subgraph-scenario.command.js'; -export {UpdateSubgraphVsidCommand} from './application/usecase-designer/subgraph/update-vsid/update-subgraph-vsid.command.js'; -export {PatchSubgraphCommand} from './application/usecase-designer/subgraph/patch/patch-subgraph.command.js'; -export {UpdateSubgraphPropertyCommand} from './application/usecase-designer/subgraph/update-property/update-subgraph-property.command.js'; +export {SetSubgraphScenarioCommand} from './application/usecase-designer/subgraph/set-scenario/set-subgraph-scenario.command.js'; +export {SetSubgraphVsidCommand} from './application/usecase-designer/subgraph/set-vsid/set-subgraph-vsid.command.js'; +export {SetSubgraphCommand} from './application/usecase-designer/subgraph/set/set-subgraph.command.js'; +export {SetSubgraphPropertyCommand} from './application/usecase-designer/subgraph/set-property/set-subgraph-property.command.js'; export {UpdateSubgraphContainerIdCommand} from './application/usecase-designer/subgraph/update-container-id/update-subgraph-container-id.command.js'; export {CreateVcpmCkvCommand} from './application/usecase-designer/subgraph/create-vcpm-ckv/create-vcpm-ckv.command.js'; export type {CkvKeyValuePair} from './application/usecase-designer/subgraph/create-vcpm-ckv/create-vcpm-ckv.command.js'; export {DeleteVcpmCkvCommand} from './application/usecase-designer/subgraph/delete-vcpm-ckv/delete-vcpm-ckv.command.js'; export {UpdateVcpmCalDataCommand} from './application/usecase-designer/subgraph/update-vcpm-cal-data/update-vcpm-cal-data.command.js'; +// Subgraph get-property query + handler +export {GetSubgraphPropertyQuery} from './application/usecase-designer/subgraph/get-property/get-subgraph-property.query.js'; +export {GetSubgraphPropertyHandler} from './application/usecase-designer/subgraph/get-property/get-subgraph-property.handler.js'; +// Subgraph repository port types +// Module repository port types +export type {WipeCalDataResult} from './application/ports/persistence/repositories/module/module.repository.js'; +// VCPM definition query service port +export type {VcpmDefinitionQueryService} from './application/ports/persistence/query-services/vcpm-definition/vcpm-definition-query-service.js'; // Container write commands export {UpdateContainerPropertyCommand} from './application/usecase-designer/container/update-property/update-container-property.command.js'; export * from './application/usecase-designer/container/get-properties/get-container-properties.query.js'; @@ -630,7 +650,8 @@ export * from './application/validation/commands/acknowledge-data-loss.command.j export * from './application/validation/validation-orchestrator.js'; // SPF Constants -export * from './application/file-operations/shared/constants/spf-ids.js'; +export * from './domain/entities/definitions/spf-ids.js'; +export * from './domain/entities/definitions/subgraph/subgraph-ids.js'; // AWSP serializer v1 - configuration types (MODULE_PORT_STRATEGIES, PROCESSOR_DOMAINS, etc.) // MODULE_PORT_STRATEGIES canonical source is domain/entities/common/enums/module-port-strategy.ts @@ -647,7 +668,10 @@ export { export { encodeStackSize, decodeStackSize, -} from './domain/services/container-property/container-stack-size-codec.js'; +} from './application/usecase-designer/shared/utils/container-stack-size-codec.js'; + +// Subgraph property codecs +export {encodeVsidPayload} from './domain/services/subgraph-property/subgraph-property-payload-codec.js'; // Use-case-creator — types shared across port surface and persistence adapters export type {KvPair} from './application/ports/persistence/repositories/shared/kv-pair.js'; diff --git a/packages/core/tests/integration/application/file-operations/download-file/voice-calibration-download.integration.spec.ts b/packages/core/tests/integration/application/file-operations/download-file/voice-calibration-download.integration.spec.ts index faecd24bb..4283a8da9 100644 --- a/packages/core/tests/integration/application/file-operations/download-file/voice-calibration-download.integration.spec.ts +++ b/packages/core/tests/integration/application/file-operations/download-file/voice-calibration-download.integration.spec.ts @@ -9,7 +9,7 @@ import type {DownloadEntities} from '../../../../../src/application/ports/persis import { SUB_GRAPH_PROP_ID_SCENARIO_ID, SUB_GRAPH_PROP_ID_SCENARIO_VALUE_VOICE_CALL, -} from '../../../../../src/application/file-operations/shared/constants/spf-ids.js'; +} from '../../../../../src/domain/entities/definitions/subgraph/subgraph-ids.js'; /** * Build a 4-byte little-endian payload for the scenario ID property. diff --git a/packages/core/tests/unit/application/file-operations/download-file/services/chunk-serializers/usecase-data-chunk-serializer.spec.ts b/packages/core/tests/unit/application/file-operations/download-file/services/chunk-serializers/usecase-data-chunk-serializer.spec.ts index 709a46732..f11c4db5e 100644 --- a/packages/core/tests/unit/application/file-operations/download-file/services/chunk-serializers/usecase-data-chunk-serializer.spec.ts +++ b/packages/core/tests/unit/application/file-operations/download-file/services/chunk-serializers/usecase-data-chunk-serializer.spec.ts @@ -13,7 +13,7 @@ import { import {SubgraphPair} from '../../../../../../../src/shared/types/subgraph-pair.js'; import {DatapoolChunk} from '../../../../../../../src/application/file-operations/shared/acdb-chunks/datapool-chunk.js'; import {BinaryUtils} from '../../../../../../../src/shared/utilities/binary-utils.js'; -import {SPF_APM_MODULE_ID} from '../../../../../../../src/application/file-operations/shared/constants/spf-ids.js'; +import {SPF_APM_MODULE_ID} from '../../../../../../../src/domain/entities/definitions/spf-ids.js'; import type { SubgraphDownloadModel, ContainerDownloadModel, diff --git a/packages/core/tests/unit/application/file-operations/upload-file/services/entity-builders/calibration-data-builder.spec.ts b/packages/core/tests/unit/application/file-operations/upload-file/services/entity-builders/calibration-data-builder.spec.ts index 9ceac2114..26ec9d4f1 100644 --- a/packages/core/tests/unit/application/file-operations/upload-file/services/entity-builders/calibration-data-builder.spec.ts +++ b/packages/core/tests/unit/application/file-operations/upload-file/services/entity-builders/calibration-data-builder.spec.ts @@ -24,7 +24,7 @@ import { } from '../../../../../../../src/shared/types/branded-ids.js'; import {Subgraph} from '../../../../../../../src/domain/entities/usecase-data/subgraph/subgraph.js'; import {VcpmInstance} from '../../../../../../../src/domain/entities/usecase-data/subgraph/entities/vcpm-module-instance.js'; -import {SPF_VCPM_MODULE_ID} from '../../../../../../../src/application/file-operations/shared/constants/spf-ids.js'; +import {SPF_VCPM_MODULE_ID} from '../../../../../../../src/domain/entities/definitions/spf-ids.js'; import {KvData} from '../../../../../../../src/domain/entities/common/entities/kv-data.js'; describe('CalibrationDataBuilder', () => { diff --git a/packages/core/tests/unit/application/usecase-designer/shared/serialize-default-parameter-data.spec.ts b/packages/core/tests/unit/application/usecase-designer/shared/serialize-default-parameter-data.spec.ts new file mode 100644 index 000000000..7f338db3e --- /dev/null +++ b/packages/core/tests/unit/application/usecase-designer/shared/serialize-default-parameter-data.spec.ts @@ -0,0 +1,136 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ +import {describe, it, expect} from '@jest/globals'; +import {serializeDefaultParameterData} from '../../../../../src/application/usecase-designer/shared/serialize-elements.js'; + +const UINT32_DEF = { + systemId: 1, + elementsStructure: JSON.stringify([ + { + elementType: 'ConfigElement', + name: 'val', + dataType: 'UInt32', + defaultValue: '42', + }, + ]), +}; + +const UINT32_DEF_NO_DEFAULT = { + systemId: 2, + elementsStructure: JSON.stringify([ + {elementType: 'ConfigElement', name: 'val', dataType: 'UInt32'}, + ]), +}; + +const STRUCT_DEF = { + systemId: 3, + elementsStructure: JSON.stringify([ + { + elementType: 'Struct', + name: 's', + structureType: 'MyStruct', + elements: [ + { + elementType: 'ConfigElement', + name: 'a', + dataType: 'UInt16', + defaultValue: '7', + }, + ], + }, + ]), +}; + +const ARRAY_DEF = { + systemId: 4, + elementsStructure: JSON.stringify([ + { + elementType: 'ElementArray', + name: 'arr', + arrayLength: 3, + template: { + elementType: 'ConfigElement', + name: 'item', + dataType: 'UInt8', + defaultValue: '1', + }, + }, + ]), +}; + +const FORMULA_ARRAY_DEF = { + systemId: 5, + elementsStructure: JSON.stringify([ + { + elementType: 'ConfigElement', + name: 'count', + dataType: 'UInt8', + defaultValue: '3', + }, + { + elementType: 'ElementArray', + name: 'arr', + arrayLength: 0, + arrayLenFormulaStr: 'count', + template: { + elementType: 'ConfigElement', + name: 'item', + dataType: 'UInt8', + defaultValue: '1', + }, + }, + ]), +}; + +const BAD_DEF = {systemId: 6, elementsStructure: 'not-json'}; + +describe('serializeDefaultParameterData', () => { + it('serializes a single UInt32 ConfigElement using its defaultValue', () => { + const result = serializeDefaultParameterData(UINT32_DEF); + expect(result.ok).toBe(true); + if (!result.ok) return; + // 42 as UInt32 LE = [0x2A, 0x00, 0x00, 0x00], aligned to 8 bytes + expect(result.value[0]).toBe(0x2a); + expect(result.value[1]).toBe(0x00); + }); + + it('returns ok:false when defaultValue is absent', () => { + const result = serializeDefaultParameterData(UINT32_DEF_NO_DEFAULT); + expect(result).toEqual({ + ok: false, + error: 'Missing defaultValue for element "val"', + }); + }); + + it('recurses into Struct children', () => { + const result = serializeDefaultParameterData(STRUCT_DEF); + expect(result.ok).toBe(true); + if (!result.ok) return; + // UInt16 value 7 = [0x07, 0x00] + expect(result.value[0]).toBe(0x07); + }); + + it('repeats the template for each array slot', () => { + const result = serializeDefaultParameterData(ARRAY_DEF); + expect(result.ok).toBe(true); + if (!result.ok) return; + // 3 UInt8 bytes each = 1 + expect(result.value[0]).toBe(1); + expect(result.value[1]).toBe(1); + expect(result.value[2]).toBe(1); + }); + + it('uses preceding config defaults to resolve formula-sized arrays', () => { + const result = serializeDefaultParameterData(FORMULA_ARRAY_DEF); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect([...result.value.slice(0, 4)]).toEqual([3, 1, 1, 1]); + }); + + it('returns ok:false for malformed elementsStructure', () => { + const result = serializeDefaultParameterData(BAD_DEF); + expect(result.ok).toBe(false); + }); +}); diff --git a/packages/core/tests/unit/application/usecase-designer/subgraph/build-subgraph-with-defaults.spec.ts b/packages/core/tests/unit/application/usecase-designer/subgraph/build-subgraph-with-defaults.spec.ts new file mode 100644 index 000000000..d79e19aa8 --- /dev/null +++ b/packages/core/tests/unit/application/usecase-designer/subgraph/build-subgraph-with-defaults.spec.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ +import {describe, it, expect} from '@jest/globals'; +import {buildSubgraphWithDefaults} from '../../../../../src/application/usecase-designer/subgraph/build-subgraph-with-defaults.js'; +import type {SubgraphPropertyDefinitionRecord} from '../../../../../src/application/ports/persistence/repositories/property-definitions/property-definitions.repository.js'; + +const VALID_DEF: SubgraphPropertyDefinitionRecord = { + systemId: 10, + elementsStructure: JSON.stringify([ + { + elementType: 'ConfigElement', + name: 'v', + dataType: 'UInt32', + defaultValue: '5', + }, + ]), +}; + +const BAD_DEF: SubgraphPropertyDefinitionRecord = { + systemId: 11, + elementsStructure: 'not-json', +}; + +describe('buildSubgraphWithDefaults', () => { + it('sets a non-null Uint8Array payload for a valid elementsStructure', () => { + const sg = buildSubgraphWithDefaults( + {systemId: 1, subgraphNaturalId: 100, name: 'test', fileSystemId: 7}, + [VALID_DEF], + ); + const prop = sg.properties[0]; + expect(prop).toBeDefined(); + expect(prop.getPayloadCopy()).not.toBeNull(); + }); + + it('falls back to null payload when elementsStructure is malformed', () => { + const sg = buildSubgraphWithDefaults( + {systemId: 2, subgraphNaturalId: 101, name: 'bad', fileSystemId: 7}, + [BAD_DEF], + ); + expect(sg.properties[0].getPayloadCopy()).toBeNull(); + }); +}); diff --git a/packages/core/tests/unit/application/usecase-designer/subgraph/get-properties/get-subgraph-properties.handler.spec.ts b/packages/core/tests/unit/application/usecase-designer/subgraph/get-properties/get-subgraph-properties.handler.spec.ts index 5d38892f0..8548becdc 100644 --- a/packages/core/tests/unit/application/usecase-designer/subgraph/get-properties/get-subgraph-properties.handler.spec.ts +++ b/packages/core/tests/unit/application/usecase-designer/subgraph/get-properties/get-subgraph-properties.handler.spec.ts @@ -53,7 +53,7 @@ function makeServices( >; definitionsResult?: Awaited< ReturnType< - QueryServices['subgraphPropertyDefQueryService']['getAllDetailedSubgraphPropertyDefinitionsWithElements'] + QueryServices['subgraphPropertyDefQueryService']['getSubgraphPropertiesWithElements'] > >; } = {}, @@ -72,7 +72,7 @@ function makeServices( findPropertyPayloads: jest.fn().mockResolvedValue(payloadsResult), }, subgraphPropertyDefQueryService: { - getAllDetailedSubgraphPropertyDefinitionsWithElements: jest + getSubgraphPropertiesWithElements: jest .fn() .mockResolvedValue(definitionsResult), }, diff --git a/packages/core/tests/unit/application/usecase-designer/subgraph/get-property/get-subgraph-property.handler.spec.ts b/packages/core/tests/unit/application/usecase-designer/subgraph/get-property/get-subgraph-property.handler.spec.ts new file mode 100644 index 000000000..91198dacc --- /dev/null +++ b/packages/core/tests/unit/application/usecase-designer/subgraph/get-property/get-subgraph-property.handler.spec.ts @@ -0,0 +1,118 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ +import {jest, describe, it, expect} from '@jest/globals'; +import {GetSubgraphPropertyHandler} from '../../../../../../src/application/usecase-designer/subgraph/get-property/get-subgraph-property.handler.js'; +import {GetSubgraphPropertyQuery} from '../../../../../../src/application/usecase-designer/subgraph/get-property/get-subgraph-property.query.js'; +import {ResourceNotFoundException} from '../../../../../../src/shared/exceptions/resource-not-found.exception.js'; +import { + Result, + RESULT_KIND, +} from '../../../../../../src/application/shared/result/result.js'; +import type {QueryServices} from '../../../../../../src/application/ports/persistence/query-services/query-services.js'; + +const FILE_ID = 7; +const SG_ID = 20; +const PROP_DEF_ID = 101; +const PROJECT_ID = 2; + +const ELEMENTS_STRUCTURE = JSON.stringify([ + { + elementType: 'ConfigElement', + name: 'gain', + dataType: 'UInt32', + isReadOnly: false, + }, +]); + +const mockDef = { + systemId: PROP_DEF_ID, + naturalId: 55, + name: 'gain', + description: '', + propertyType: 'SPF', + maxSize: 4, + isVoice: false, + elementsStructure: ELEMENTS_STRUCTURE, +}; + +const mockPayload = { + systemId: 201, + propertySystemId: PROP_DEF_ID, + payload: new Uint8Array([0x03, 0x00, 0x00, 0x00]), +}; + +function makeServices( + overrides: { + fileId?: number; + payloadsResult?: any; + defResult?: any; + } = {}, +): QueryServices { + const { + fileId = FILE_ID, + payloadsResult = Result.ok([mockPayload]), + defResult = Result.ok(mockDef), + } = overrides; + + return { + projectQueryService: { + getFileIdByProjectId: jest.fn().mockResolvedValue(fileId), + }, + subgraphQueryService: { + findPropertyPayloads: jest.fn().mockResolvedValue(payloadsResult), + }, + subgraphPropertyDefQueryService: { + getSubgraphPropertyWithElements: jest.fn().mockResolvedValue(defResult), + }, + } as unknown as QueryServices; +} + +describe('GetSubgraphPropertyHandler', () => { + const query = new GetSubgraphPropertyQuery( + PROJECT_ID, + SG_ID, + PROP_DEF_ID, + 'c', + ); + + it('throws ResourceNotFoundException when subgraph not found', async () => { + const svc = makeServices({payloadsResult: Result.ok(null)}); + const handler = new GetSubgraphPropertyHandler(svc); + await expect(handler.handle(query)).rejects.toBeInstanceOf( + ResourceNotFoundException, + ); + }); + + it('throws ResourceNotFoundException when property not on subgraph', async () => { + const svc = makeServices({payloadsResult: Result.ok([])}); + const handler = new GetSubgraphPropertyHandler(svc); + await expect(handler.handle(query)).rejects.toBeInstanceOf( + ResourceNotFoundException, + ); + }); + + it('throws ResourceNotFoundException when property definition not found', async () => { + const svc = makeServices({ + defResult: Result.fail({ + code: 'ENTITY_NOT_FOUND', + message: 'not found', + severity: 'Error', + }), + }); + const handler = new GetSubgraphPropertyHandler(svc); + await expect(handler.handle(query)).rejects.toBeInstanceOf( + ResourceNotFoundException, + ); + }); + + it('returns PropertyDataDto with parsed elements on success', async () => { + const handler = new GetSubgraphPropertyHandler(makeServices()); + const result = await handler.handle(query); + expect(result.kind).toBe(RESULT_KIND.Ok); + if (result.kind !== RESULT_KIND.Ok) return; + expect(result.data.naturalId).toBe(55); + expect(result.data.elements).toHaveLength(1); + }); +}); diff --git a/packages/core/tests/unit/application/usecase-designer/subgraph/set-property/set-subgraph-property.handler.spec.ts b/packages/core/tests/unit/application/usecase-designer/subgraph/set-property/set-subgraph-property.handler.spec.ts new file mode 100644 index 000000000..6599b4545 --- /dev/null +++ b/packages/core/tests/unit/application/usecase-designer/subgraph/set-property/set-subgraph-property.handler.spec.ts @@ -0,0 +1,127 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ +import {jest, describe, it, expect} from '@jest/globals'; +import {SetSubgraphPropertyHandler} from '../../../../../../src/application/usecase-designer/subgraph/set-property/set-subgraph-property.handler.js'; +import {SetSubgraphPropertyCommand} from '../../../../../../src/application/usecase-designer/subgraph/set-property/set-subgraph-property.command.js'; +import { + SUB_GRAPH_PROP_ID_SCENARIO_ID, + SUB_GRAPH_PROP_ID_VSID, +} from '../../../../../../src/domain/entities/definitions/subgraph/subgraph-ids.js'; +import {ResourceNotFoundException} from '../../../../../../src/shared/exceptions/resource-not-found.exception.js'; +import {InvalidOperationException} from '../../../../../../src/shared/exceptions/invalid-operation.exception.js'; + +const SESSION = {sessionId: 1, fileSystemId: 7}; +const ELEMENTS_STRUCTURE = JSON.stringify([ + {elementType: 'ConfigElement', name: 'v', dataType: 'UInt32'}, +]); + +function makeDef(naturalId: number) { + return { + systemId: 101, + naturalId, + name: 'prop', + description: '', + propertyType: 'SPF', + maxSize: 4, + isVoice: false, + elementsStructure: ELEMENTS_STRUCTURE, + }; +} + +function makeUow(exists: boolean, definition: any = makeDef(0x1234)) { + const setPropertyData = jest.fn().mockResolvedValue(undefined); + return { + getWriteContext: jest + .fn() + .mockReturnValue({session: SESSION, groupId: 'g1'}), + getSubgraphRepository: jest.fn().mockReturnValue({ + subgraphExists: jest.fn().mockResolvedValue(exists), + setPropertyData, + getPropertyDefinitions: jest + .fn() + .mockResolvedValue(definition === null ? [] : [definition]), + }), + _setPropertyData: setPropertyData, + }; +} + +const GOOD_ELEMENTS = [ + { + type: 'ConfigElement', + name: 'v', + dataType: 'UInt32', + value: '3', + isReadOnly: false, + description: '', + }, +] as any; + +describe('SetSubgraphPropertyHandler', () => { + it('throws ResourceNotFoundException when subgraph not found', async () => { + const handler = new SetSubgraphPropertyHandler(makeUow(false) as any); + await expect( + handler.handle(new SetSubgraphPropertyCommand(99, 101, GOOD_ELEMENTS)), + ).rejects.toBeInstanceOf(ResourceNotFoundException); + }); + + it('throws ResourceNotFoundException when property definition not found', async () => { + const handler = new SetSubgraphPropertyHandler(makeUow(true, null) as any); + await expect( + handler.handle(new SetSubgraphPropertyCommand(10, 101, GOOD_ELEMENTS)), + ).rejects.toBeInstanceOf(ResourceNotFoundException); + }); + + it('throws InvalidOperationException for reserved scenario property', async () => { + const handler = new SetSubgraphPropertyHandler( + makeUow(true, makeDef(SUB_GRAPH_PROP_ID_SCENARIO_ID)) as any, + ); + await expect( + handler.handle(new SetSubgraphPropertyCommand(10, 101, GOOD_ELEMENTS)), + ).rejects.toThrow( + 'Property prop is reserved and cannot be replaced through the generic property operation.', + ); + }); + + it('throws InvalidOperationException for reserved VSID property', async () => { + const handler = new SetSubgraphPropertyHandler( + makeUow(true, makeDef(SUB_GRAPH_PROP_ID_VSID)) as any, + ); + await expect( + handler.handle(new SetSubgraphPropertyCommand(10, 101, GOOD_ELEMENTS)), + ).rejects.toThrow( + 'Property prop is reserved and cannot be replaced through the generic property operation.', + ); + }); + + it('throws InvalidOperationException when serialization fails', async () => { + const badElements = [ + { + type: 'ConfigElement', + name: 'v', + dataType: 'UInt32', + value: 'not-a-number', + isReadOnly: false, + description: '', + }, + ] as any; + const handler = new SetSubgraphPropertyHandler(makeUow(true) as any); + await expect( + handler.handle(new SetSubgraphPropertyCommand(10, 101, badElements)), + ).rejects.toBeInstanceOf(InvalidOperationException); + }); + + it('calls setPropertyData with serialized payload on success', async () => { + const uow = makeUow(true) as any; + const handler = new SetSubgraphPropertyHandler(uow); + await handler.handle( + new SetSubgraphPropertyCommand(10, 101, GOOD_ELEMENTS), + ); + expect(uow._setPropertyData).toHaveBeenCalledWith( + 10, + 101, + expect.any(Uint8Array), + ); + }); +}); diff --git a/packages/core/tests/unit/application/usecase-designer/subgraph/set-scenario/set-subgraph-scenario.handler.spec.ts b/packages/core/tests/unit/application/usecase-designer/subgraph/set-scenario/set-subgraph-scenario.handler.spec.ts new file mode 100644 index 000000000..039165d23 --- /dev/null +++ b/packages/core/tests/unit/application/usecase-designer/subgraph/set-scenario/set-subgraph-scenario.handler.spec.ts @@ -0,0 +1,188 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ +import {jest, describe, it, expect} from '@jest/globals'; +import {SetSubgraphScenarioHandler} from '../../../../../../src/application/usecase-designer/subgraph/set-scenario/set-subgraph-scenario.handler.js'; +import {SetSubgraphScenarioCommand} from '../../../../../../src/application/usecase-designer/subgraph/set-scenario/set-subgraph-scenario.command.js'; +import {ResourceNotFoundException} from '../../../../../../src/shared/exceptions/resource-not-found.exception.js'; +import {InvalidOperationException} from '../../../../../../src/shared/exceptions/invalid-operation.exception.js'; +import {SUB_GRAPH_PROP_ID_SCENARIO_VALUE_AUDIO_PLAYBACK} from '../../../../../../src/domain/entities/definitions/subgraph/subgraph-ids.js'; + +const SESSION = {sessionId: 1, fileSystemId: 7}; +const GROUP_ID = 'g1'; +const SCENARIO_DEF_SYS_ID = 50; +const SCENARIO_NATURAL_ID = 0x08001010; +const SCENARIO_ELEMENTS = JSON.stringify([ + { + elementType: 'ConfigElement', + name: 'scenario', + dataType: 'UInt32', + defaultValue: '1', + }, +]); + +function uint32Payload(v: number) { + const b = new Uint8Array(4); + new DataView(b.buffer).setUint32(0, v, true); + return b; +} + +function makeScenarioDef() { + return { + systemId: SCENARIO_DEF_SYS_ID, + naturalId: SCENARIO_NATURAL_ID, + name: 'scenario', + description: '', + propertyType: 'SPF', + maxSize: 4, + isVoice: false, + elementsStructure: SCENARIO_ELEMENTS, + }; +} + +function makeSubgraph(scenarioValue: number) { + return { + systemId: 10, + properties: [ + { + systemId: 200, + propertySystemId: SCENARIO_DEF_SYS_ID, + payload: uint32Payload(scenarioValue), + }, + ], + }; +} + +function makeUow(subgraph: any) { + const setPropertyData = jest.fn().mockResolvedValue(undefined); + const startTransaction = jest.fn().mockResolvedValue(undefined); + const commit = jest.fn().mockResolvedValue(undefined); + const rollback = jest.fn().mockResolvedValue(undefined); + const isInTransaction = jest.fn().mockReturnValue(false); + + return { + getWriteContext: jest + .fn() + .mockReturnValue({session: SESSION, groupId: GROUP_ID}), + getSubgraphRepository: jest.fn().mockReturnValue({ + getAggregate: jest.fn().mockResolvedValue(subgraph), + setPropertyData, + addProperty: jest.fn().mockResolvedValue(999), + removeProperty: jest.fn().mockResolvedValue(undefined), + removeAllVcpmCfgData: jest.fn().mockResolvedValue(undefined), + getPropertyDefinitions: jest.fn().mockResolvedValue([makeScenarioDef()]), + }), + getVcpmDefinitionRepository: jest.fn().mockReturnValue({ + getAllVcpmModuleDefinitions: jest.fn().mockResolvedValue([]), + addVcpmCfgDefaultData: jest.fn().mockResolvedValue(undefined), + }), + getModuleRepository: jest.fn().mockReturnValue({ + getModulesBySubgraphId: jest.fn().mockResolvedValue([]), + wipeCalData: jest + .fn() + .mockResolvedValue({ckvsDeleted: [], zeroCkvsAdded: []}), + }), + startTransaction, + commit, + rollback, + isInTransaction, + _setPropertyData: setPropertyData, + _commit: commit, + _rollback: rollback, + }; +} + +const AUDIO_RECORDING_ELEMENTS = [ + { + type: 'ConfigElement', + name: 'scenario', + dataType: 'UInt32', + value: '2', + isReadOnly: false, + description: '', + }, +] as any; + +describe('SetSubgraphScenarioHandler', () => { + it('throws ResourceNotFoundException when subgraph not found', async () => { + const uow = makeUow(null) as any; + const handler = new SetSubgraphScenarioHandler(uow); + await expect( + handler.handle( + new SetSubgraphScenarioCommand(10, AUDIO_RECORDING_ELEMENTS), + ), + ).rejects.toBeInstanceOf(ResourceNotFoundException); + }); + + it('returns empty mutation log when scenario unchanged', async () => { + const uow = makeUow( + makeSubgraph(SUB_GRAPH_PROP_ID_SCENARIO_VALUE_AUDIO_PLAYBACK), + ) as any; + const audioPlaybackElements = [ + { + type: 'ConfigElement', + name: 'scenario', + dataType: 'UInt32', + value: '1', + isReadOnly: false, + description: '', + }, + ] as any; + const handler = new SetSubgraphScenarioHandler(uow); + const result = await handler.handle( + new SetSubgraphScenarioCommand(10, audioPlaybackElements), + ); + expect(result.propertiesAdded).toHaveLength(0); + expect(uow._setPropertyData).not.toHaveBeenCalled(); + }); + + it('commits scenario write for audio→audio change', async () => { + const uow = makeUow( + makeSubgraph(SUB_GRAPH_PROP_ID_SCENARIO_VALUE_AUDIO_PLAYBACK), + ) as any; + const handler = new SetSubgraphScenarioHandler(uow); + const result = await handler.handle( + new SetSubgraphScenarioCommand(10, AUDIO_RECORDING_ELEMENTS), + ); + expect(uow._commit).toHaveBeenCalled(); + expect(result.groupId).toBe(GROUP_ID); + }); + + it('rolls back and rethrows when write fails', async () => { + const uow = makeUow( + makeSubgraph(SUB_GRAPH_PROP_ID_SCENARIO_VALUE_AUDIO_PLAYBACK), + ) as any; + uow + .getSubgraphRepository() + .setPropertyData.mockRejectedValueOnce(new Error('fail')); + uow.isInTransaction.mockReturnValue(true); + const handler = new SetSubgraphScenarioHandler(uow); + await expect( + handler.handle( + new SetSubgraphScenarioCommand(10, AUDIO_RECORDING_ELEMENTS), + ), + ).rejects.toThrow('fail'); + expect(uow._rollback).toHaveBeenCalled(); + }); + + it('throws InvalidOperationException when scenario serialization fails', async () => { + const badElements = [ + { + type: 'ConfigElement', + name: 'scenario', + dataType: 'UInt32', + value: 'nan', + isReadOnly: false, + description: '', + }, + ] as any; + const uow = makeUow( + makeSubgraph(SUB_GRAPH_PROP_ID_SCENARIO_VALUE_AUDIO_PLAYBACK), + ) as any; + const handler = new SetSubgraphScenarioHandler(uow); + await expect( + handler.handle(new SetSubgraphScenarioCommand(10, badElements)), + ).rejects.toBeInstanceOf(InvalidOperationException); + }); +}); diff --git a/packages/core/tests/unit/application/usecase-designer/subgraph/set-vsid/set-subgraph-vsid.handler.spec.ts b/packages/core/tests/unit/application/usecase-designer/subgraph/set-vsid/set-subgraph-vsid.handler.spec.ts new file mode 100644 index 000000000..170346e40 --- /dev/null +++ b/packages/core/tests/unit/application/usecase-designer/subgraph/set-vsid/set-subgraph-vsid.handler.spec.ts @@ -0,0 +1,175 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ +import {jest, describe, it, expect} from '@jest/globals'; +import {SetSubgraphVsidHandler} from '../../../../../../src/application/usecase-designer/subgraph/set-vsid/set-subgraph-vsid.handler.js'; +import {SetSubgraphVsidCommand} from '../../../../../../src/application/usecase-designer/subgraph/set-vsid/set-subgraph-vsid.command.js'; +import {ResourceNotFoundException} from '../../../../../../src/shared/exceptions/resource-not-found.exception.js'; +import {InvalidOperationException} from '../../../../../../src/shared/exceptions/invalid-operation.exception.js'; + +const SESSION = {sessionId: 1, fileSystemId: 7}; +const GROUP_ID = 'g1'; +const VSID_DEF_SYS_ID = 55; +const VSID_NATURAL_ID = 0x080010cc; +const VSID_ELEMENTS = JSON.stringify([ + { + elementType: 'ConfigElement', + name: 'vsid', + dataType: 'UInt32', + defaultValue: '0', + }, +]); + +function uint32Payload(v: number): Uint8Array { + const b = new Uint8Array(4); + new DataView(b.buffer).setUint32(0, v, true); + return b; +} + +function makeVsidDef(systemId = VSID_DEF_SYS_ID) { + return { + systemId, + naturalId: VSID_NATURAL_ID, + name: 'vsid', + description: '', + propertyType: 'SPF', + maxSize: 4, + isVoice: false, + elementsStructure: VSID_ELEMENTS, + }; +} + +function makeSubgraph(systemId: number, vsidValue?: number) { + return { + systemId, + properties: + vsidValue !== undefined + ? [ + { + systemId: 200 + systemId, + propertySystemId: VSID_DEF_SYS_ID, + payload: uint32Payload(vsidValue), + }, + ] + : [], + }; +} + +function makeUow(opts: {subgraph?: any; linkedIds?: number[]} = {}) { + const setPropertyData = jest.fn().mockResolvedValue(undefined); + const startTransaction = jest.fn().mockResolvedValue(undefined); + const commit = jest.fn().mockResolvedValue(undefined); + const rollback = jest.fn().mockResolvedValue(undefined); + const isInTransaction = jest.fn().mockReturnValue(false); + const {subgraph = makeSubgraph(10, 100), linkedIds = []} = opts; + + return { + getWriteContext: jest + .fn() + .mockReturnValue({session: SESSION, groupId: GROUP_ID}), + getSubgraphRepository: jest.fn().mockReturnValue({ + getAggregate: jest + .fn() + .mockImplementation((id: number) => + Promise.resolve(id === 10 ? subgraph : null), + ), + getAggregates: jest.fn().mockImplementation((ids: number[]) => { + const map = new Map(); + for (const id of ids) { + if (id === 10 && subgraph) map.set(id, subgraph); + } + return Promise.resolve(map); + }), + getSubgraphIdsInSameUsecases: jest.fn().mockResolvedValue(linkedIds), + getSubgraphIdsInSameUsecasesForMany: jest + .fn() + .mockResolvedValue(linkedIds), + setPropertyData, + getPropertyDefinitions: jest.fn().mockResolvedValue([makeVsidDef()]), + }), + startTransaction, + commit, + rollback, + isInTransaction, + _setPropertyData: setPropertyData, + _commit: commit, + _rollback: rollback, + }; +} + +const ELEMENTS = [ + { + type: 'ConfigElement', + name: 'vsid', + dataType: 'UInt32', + value: '200', + isReadOnly: false, + description: '', + }, +] as any; + +describe('SetSubgraphVsidHandler', () => { + it('throws ResourceNotFoundException when subgraph not found', async () => { + const uow = makeUow({subgraph: null}) as any; + const handler = new SetSubgraphVsidHandler(uow); + await expect( + handler.handle(new SetSubgraphVsidCommand(10, ELEMENTS)), + ).rejects.toBeInstanceOf(ResourceNotFoundException); + }); + + it('returns empty affectedSubgraphSystemIds when VSID unchanged', async () => { + const uow = makeUow({subgraph: makeSubgraph(10, 200)}) as any; + const handler = new SetSubgraphVsidHandler(uow); + const result = await handler.handle( + new SetSubgraphVsidCommand(10, ELEMENTS), + ); + expect(result.affectedSubgraphSystemIds).toHaveLength(0); + expect(uow._setPropertyData).not.toHaveBeenCalled(); + }); + + it('writes only target subgraph when no linked subgraphs', async () => { + const uow = makeUow({ + subgraph: makeSubgraph(10, 100), + linkedIds: [], + }) as any; + const handler = new SetSubgraphVsidHandler(uow); + const result = await handler.handle( + new SetSubgraphVsidCommand(10, ELEMENTS), + ); + expect(uow._setPropertyData).toHaveBeenCalledTimes(1); + expect(result.affectedSubgraphSystemIds).toContain('10'); + expect(uow._commit).toHaveBeenCalled(); + }); + + it('rolls back and rethrows when setPropertyData throws', async () => { + const uow = makeUow({subgraph: makeSubgraph(10, 100)}) as any; + uow + .getSubgraphRepository() + .setPropertyData.mockRejectedValueOnce(new Error('db error')); + uow.isInTransaction.mockReturnValue(true); + const handler = new SetSubgraphVsidHandler(uow); + await expect( + handler.handle(new SetSubgraphVsidCommand(10, ELEMENTS)), + ).rejects.toThrow('db error'); + expect(uow._rollback).toHaveBeenCalled(); + }); + + it('throws InvalidOperationException when VSID serialization fails', async () => { + const badElements = [ + { + type: 'ConfigElement', + name: 'vsid', + dataType: 'UInt32', + value: 'nan', + isReadOnly: false, + description: '', + }, + ] as any; + const uow = makeUow({subgraph: makeSubgraph(10, 100)}) as any; + const handler = new SetSubgraphVsidHandler(uow); + await expect( + handler.handle(new SetSubgraphVsidCommand(10, badElements)), + ).rejects.toBeInstanceOf(InvalidOperationException); + }); +}); diff --git a/packages/core/tests/unit/application/usecase-designer/subgraph/set/set-subgraph.handler.spec.ts b/packages/core/tests/unit/application/usecase-designer/subgraph/set/set-subgraph.handler.spec.ts new file mode 100644 index 000000000..4316665d6 --- /dev/null +++ b/packages/core/tests/unit/application/usecase-designer/subgraph/set/set-subgraph.handler.spec.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ +import {jest, describe, it, expect} from '@jest/globals'; +import {SetSubgraphHandler} from '../../../../../../src/application/usecase-designer/subgraph/set/set-subgraph.handler.js'; +import {SetSubgraphCommand} from '../../../../../../src/application/usecase-designer/subgraph/set/set-subgraph.command.js'; +import {ResourceNotFoundException} from '../../../../../../src/shared/exceptions/resource-not-found.exception.js'; + +const SESSION = { + sessionId: 1, + fileSystemId: 7, + userId: 'u', + clientId: 'c', + sessionMode: 'Designer', +}; +const GROUP_ID = 'g1'; + +function makeUow(exists: boolean) { + const rename = jest.fn().mockResolvedValue(undefined); + return { + getWriteContext: jest + .fn() + .mockReturnValue({session: SESSION, groupId: GROUP_ID}), + getSubgraphRepository: jest.fn().mockReturnValue({ + subgraphExists: jest.fn().mockResolvedValue(exists), + rename, + }), + _rename: rename, + }; +} + +describe('SetSubgraphHandler', () => { + it('throws ResourceNotFoundException when subgraph not found', async () => { + const uow = makeUow(false) as any; + const handler = new SetSubgraphHandler(uow); + await expect( + handler.handle(new SetSubgraphCommand(99, 'new name')), + ).rejects.toBeInstanceOf(ResourceNotFoundException); + }); + + it('calls rename when name is provided', async () => { + const uow = makeUow(true) as any; + const handler = new SetSubgraphHandler(uow); + await handler.handle(new SetSubgraphCommand(10, 'renamed')); + expect(uow._rename).toHaveBeenCalledWith(10, 'renamed'); + }); + + it('does not call rename when name is undefined', async () => { + const uow = makeUow(true) as any; + const handler = new SetSubgraphHandler(uow); + await handler.handle(new SetSubgraphCommand(10, undefined)); + expect(uow._rename).not.toHaveBeenCalled(); + }); + + it('returns groupId', async () => { + const uow = makeUow(true) as any; + const handler = new SetSubgraphHandler(uow); + const result = await handler.handle(new SetSubgraphCommand(10, 'x')); + expect(result).toEqual({groupId: GROUP_ID}); + }); +}); diff --git a/packages/core/tests/unit/domain/services/subgraph-property/subgraph-property-payload-codec.spec.ts b/packages/core/tests/unit/domain/services/subgraph-property/subgraph-property-payload-codec.spec.ts new file mode 100644 index 000000000..d3cc76867 --- /dev/null +++ b/packages/core/tests/unit/domain/services/subgraph-property/subgraph-property-payload-codec.spec.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {describe, expect, it} from '@jest/globals'; +import {encodeVsidPayload} from '../../../../../src/domain/services/subgraph-property/subgraph-property-payload-codec.js'; + +describe('encodeVsidPayload', () => { + it('encodes a little-endian UInt32 and 8-byte aligns the payload', () => { + expect([...encodeVsidPayload(0x12345678)]).toEqual([ + 0x78, 0x56, 0x34, 0x12, 0, 0, 0, 0, + ]); + }); +}); diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/index.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/index.ts index 2b207337e..0d4ed3a54 100644 --- a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/index.ts +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/index.ts @@ -27,3 +27,4 @@ export {TypeOrmControlLinkRepository} from './repositories/control-link/control- export {TypeOrmSubgraphRepository} from './repositories/subgraph/subgraph.repository.js'; export {TypeOrmSubsystemRepository} from './repositories/subsystem/subsystem.repository.js'; export {TypeOrmUsecaseRepository} from './repositories/usecase/use-case.repository.js'; +export {TypeOrmVcpmDefinitionRepository} from './repositories/vcpm-definition/vcpm-definition.repository.js'; diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/subgraph-property-definition/db-subgraph-property-def-query-service.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/subgraph-property-definition/db-subgraph-property-def-query-service.ts index fde59e312..f9c19c9aa 100644 --- a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/subgraph-property-definition/db-subgraph-property-def-query-service.ts +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/subgraph-property-definition/db-subgraph-property-def-query-service.ts @@ -119,7 +119,7 @@ export class DbSubgraphPropertyDefQueryService implements SubgraphPropertyDefQue } } - async getAllDetailedSubgraphPropertyDefinitionsWithElements( + async getSubgraphPropertiesWithElements( fileSystemId: number, ): Promise> { try { @@ -142,6 +142,37 @@ export class DbSubgraphPropertyDefQueryService implements SubgraphPropertyDefQue } } + async getSubgraphPropertyWithElements( + propertySystemId: number, + fileSystemId: number, + ): Promise> { + try { + const session = + await this.sessionRepo.findActiveSessionByFileSystemId(fileSystemId); + const rows = await this.fetcher.fetchAll( + fileSystemId, + session?.sessionId ?? null, + ); + const match = rows.find(r => r.systemId === propertySystemId); + return match + ? Result.ok(this.toDetailWithElementsReadModel(match)) + : Result.fail({ + code: ERROR_CODES.ENTITY_NOT_FOUND, + message: `SubgraphPropertyDefinition not found for systemId=${propertySystemId}`, + severity: IssueSeverity.Error, + }); + } catch (error) { + return Result.fail({ + code: ERROR_CODES.INTERNAL_ERROR, + message: + error instanceof Error + ? error.message + : 'Failed to load subgraph property definition', + severity: IssueSeverity.Error, + }); + } + } + // ── Private read model mappers ───────────────────────────────────────────── private toSummaryReadModel( 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..709ecd913 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 @@ -23,6 +23,7 @@ import type { DataLinkQueryService, ControlLinkQueryService, SubsystemQueryService, + VcpmDefinitionQueryService, Logger, } from '@arc/core'; import {DataSource} from 'typeorm'; @@ -45,6 +46,7 @@ import {DbDriverModuleDefinitionQueryService} from './driver-module-definition/d import {DbDataLinkQueryService} from './link/db-data-link-query-service.js'; import {DbControlLinkQueryService} from './link/db-control-link-query-service.js'; import {DbSubsystemQueryService} from './subsystem/db-subsystem-query-service.js'; +import {DbVcpmDefinitionQueryService} from './vcpm-definition/db-vcpm-definition-query-service.js'; 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'; @@ -82,6 +84,7 @@ export class DbQueryServices implements QueryServices { readonly dataLinkQueryService: DataLinkQueryService; readonly controlLinkQueryService: ControlLinkQueryService; readonly subsystemQueryService: SubsystemQueryService; + readonly vcpmDefinitionQueryService: VcpmDefinitionQueryService; constructor( dataSource: DataSource, @@ -268,6 +271,10 @@ export class DbQueryServices implements QueryServices { linkOverlayFetcher, ); + this.vcpmDefinitionQueryService = new DbVcpmDefinitionQueryService( + dataSource, + ); + this.logQueryService = logQueryService; } } diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/vcpm-definition/db-vcpm-definition-query-service.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/vcpm-definition/db-vcpm-definition-query-service.ts new file mode 100644 index 000000000..70880fe27 --- /dev/null +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/queries/vcpm-definition/db-vcpm-definition-query-service.ts @@ -0,0 +1,63 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import type {DataSource} from 'typeorm'; +import type { + VcpmDefinitionQueryService, + VcpmModuleDefinitionWithParamsReadModel, +} from '@arc/core'; +import {ENTITY_NAMES} from '../../entity-schema/entity-table-names.js'; + +export class DbVcpmDefinitionQueryService implements VcpmDefinitionQueryService { + constructor(private readonly dataSource: DataSource) {} + + async getAllVcpmModuleDefinitions( + fileSystemId: number, + ): Promise { + const rows = await this.dataSource.manager + .createQueryBuilder() + .select('vmd.systemId', 'moduleSystemId') + .addSelect('vmd.moduleDefinitionId', 'moduleDefinitionId') + .addSelect('vmpd.systemId', 'paramSystemId') + .addSelect('vmpd.paramId', 'paramId') + .addSelect('vmpd.elementsStructure', 'elementsStructure') + .addSelect('vmpd.isReadOnly', 'isReadOnly') + .from(ENTITY_NAMES.VcpmModuleDefinition, 'vmd') + .leftJoin( + ENTITY_NAMES.VcpmModuleParameterDefinition, + 'vmpd', + 'vmpd.vcpmModuleDefinitionSystemId = vmd.systemId', + ) + .where('vmd.fileSystemId = :fileSystemId', {fileSystemId}) + .getRawMany<{ + moduleSystemId: number; + moduleDefinitionId: number; + paramSystemId: number | null; + paramId: number | null; + elementsStructure: string | null; + isReadOnly: number | null; + }>(); + + const map = new Map(); + for (const row of rows) { + if (!map.has(row.moduleSystemId)) { + map.set(row.moduleSystemId, { + systemId: row.moduleSystemId, + moduleDefinitionId: row.moduleDefinitionId, + parameters: [], + }); + } + if (row.paramSystemId !== null) { + map.get(row.moduleSystemId)!.parameters.push({ + systemId: row.paramSystemId, + paramId: row.paramId ?? 0, + elementsStructure: row.elementsStructure ?? '', + isReadOnly: Boolean(row.isReadOnly), + }); + } + } + return [...map.values()]; + } +} 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..feb62f904 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 @@ -10,6 +10,7 @@ import type { EditOptions, SpfModuleBase, PayloadUpdate, + WipeCalDataResult, } from '@arc/core'; import { CONFIGURATION_INCLUDES, @@ -685,4 +686,352 @@ export class TypeOrmModuleRepository implements ModuleRepository { // See: docs/edit-crud/design/add-module-calibration-defaults-design.md §6 return Promise.reject(new Error('createCkv: not yet implemented')); } + + async getModulesBySubgraphId( + subgraphSystemId: number, + fileSystemId: number, + ): Promise { + const sessionId = this.uow.getWriteContext().session.sessionId; + const rows = await this.spfModuleFetcher.fetchMany( + fileSystemId, + sessionId, + {subgraphSystemId}, + ); + return rows.map(r => ({ + systemId: r.systemId, + definitionSystemId: r.definitionSystemId, + subgraphSystemId: r.subgraphSystemId, + containerSystemId: r.containerSystemId, + })); + } + + async wipeCalData( + moduleSystemId: number, + _fileSystemId: number, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + + /** + * Compatibility operation used by the current scenario transition. + * + * TODO(subgraph-write-review): Replace this method with the core-owned + * CKV reset-plan flow. Until then, CKV deletion and zero-CKV reset code + * remains commented out below; only TKV/tag cleanup is staged here. + */ + // TODO(subgraph-write-review): Move zero-CKV classification, default + // payload serialization, and reset-plan creation into packages/core. + // The zero-CKV implementation is commented out for this PR. The future + // design will pass an explicit reset plan to the adapter, leaving this + // layer responsible only for applying staged deletes and updates. + + /* + * Deferred CKV logic: + * - read effective CKVs through the overlay; + * - identify non-zero CKVs; + * - delete CKV payloads and CKV rows in FK order; + * - identify and reset the existing zero CKV. + * + * const ckvs = await this.ckvOverlayFetcher.fetchMany(...); + * const ckvDeletePlans = await this.readCkvDeletePlans(ckvs); + */ + // TKV/tag calibration data is handled independently from CKV data. + const tkvDeletePlans = await this.readTkvDeletePlans( + moduleSystemId, + session.sessionId, + ); + /* + * Deferred zero-CKV logic: + * - identify the zero CKV; + * - load its existing parameter payloads; + * - serialize factory-default payloads; + * - preserve the zero-CKV system ID while resetting its payloads. + * + * const zeroCkvResets = await this.readZeroCkvResets(...); + * const zeroCkv = ckvs.find(c => c.values.length === 0); + */ + + /* + * Deferred CKV deletion. This will be replaced by applying the reset plan + * produced in core in the follow-up implementation. + * + * const ckvsDeleted = await this.writeCkvDeletes(...); + */ + const ckvsDeleted: number[] = []; + await this.writeTkvDeletes( + tkvDeletePlans, + moduleSystemId, + session.sessionId, + groupId, + ); + /* + * Deferred zero-CKV payload reset. This will consume the reset plan + * generated in core after the follow-up refactor. + * + * const zeroCkvsAdded = await this.writeZeroCkvResets(...); + */ + const zeroCkvsAdded: number[] = []; + + return {ckvsDeleted, zeroCkvsAdded}; + } + + /* Deferred until the core CKV reset-plan refactor. + private async readCkvDeletePlans( + ckvs: Awaited>, + ): Promise> { + // A CKV with at least one key/value pair is non-zero and is removed during + // calibration reset. The payload IDs are collected first so persistence + // can delete payload rows before their parent CKV rows. + const nonZeroCkvs = ckvs.filter(c => c.values.length > 0); + if (nonZeroCkvs.length === 0) return []; + + // Batch-fetch all payloads for all non-zero CKVs in one query + const ckvIds = nonZeroCkvs.map(c => c.systemId); + const allPayloads = await this.manager + .getRepository(ENTITY_NAMES.CkvParameterPayload) + .createQueryBuilder('p') + .select('p.systemId', 'systemId') + .addSelect('p.ckvSystemId', 'ckvSystemId') + .where('p.ckvSystemId IN (:...ids)', {ids: ckvIds}) + .getRawMany<{systemId: number; ckvSystemId: number}>(); + + const payloadsByCkv = new Map(); + for (const p of allPayloads) { + const list = payloadsByCkv.get(p.ckvSystemId) ?? []; + list.push(p.systemId); + payloadsByCkv.set(p.ckvSystemId, list); + } + + return nonZeroCkvs.map(ckv => ({ + ckvId: ckv.systemId, + payloadIds: payloadsByCkv.get(ckv.systemId) ?? [], + })); + } + */ + + private async readTkvDeletePlans( + moduleSystemId: number, + sessionId: number, + ): Promise< + Array<{ + tagMapId: number; + tkvs: Array<{tkvId: number; payloadIds: number[]}>; + }> + > { + const tagMaps = await this.tkvOverlayFetcher.fetchMany( + moduleSystemId, + sessionId, + CONFIGURATION_INCLUDES.FullDetails, + ); + if (tagMaps.length === 0) return []; + + const allTkvs = tagMaps.flatMap(tm => tm.tkvs ?? []); + if (allTkvs.length === 0) { + return tagMaps.map(tm => ({tagMapId: tm.systemId, tkvs: []})); + } + + // Batch-fetch all TKV payloads in one query + const tkvIds = allTkvs.map(t => t.systemId); + const allPayloads = await this.manager + .getRepository(ENTITY_NAMES.TkvParameterPayload) + .createQueryBuilder('p') + .select('p.systemId', 'systemId') + .addSelect('p.tkvSystemId', 'tkvSystemId') + .where('p.tkvSystemId IN (:...ids)', {ids: tkvIds}) + .getRawMany<{systemId: number; tkvSystemId: number}>(); + + const payloadsByTkv = new Map(); + for (const p of allPayloads) { + const list = payloadsByTkv.get(p.tkvSystemId) ?? []; + list.push(p.systemId); + payloadsByTkv.set(p.tkvSystemId, list); + } + + return tagMaps.map(tagMap => ({ + tagMapId: tagMap.systemId, + tkvs: (tagMap.tkvs ?? []).map(tkv => ({ + tkvId: tkv.systemId, + payloadIds: payloadsByTkv.get(tkv.systemId) ?? [], + })), + })); + } + + /* Deferred until the core zero-CKV reset-plan refactor. + private async readZeroCkvResets( + ckvs: Awaited>, + moduleSystemId: number, + fileSystemId: number, + sessionId: number, + ): Promise< + Array<{payloadSystemId: number; defaultValue: Uint8Array | null}> + > { + // The zero CKV is the CKV with no key/value pairs. Its system ID is kept; + // only its existing parameter payloads are replaced with factory defaults. + const zeroCkv = ckvs.find(c => c.values.length === 0); + if (!zeroCkv) return []; + const mod = ( + await this.spfModuleFetcher.fetchMany(fileSystemId, sessionId, { + systemId: moduleSystemId, + }) + ).at(0); + if (!mod) return []; + const resets: Array<{ + payloadSystemId: number; + defaultValue: Uint8Array | null; + }> = []; + const existingPayloads = await this.ckvOverlayFetcher.fetchPayloads( + zeroCkv.systemId, + moduleSystemId, + sessionId, + ); + if (existingPayloads.length === 0) return []; + + // Resolve all parameter definitions in one query so each existing payload + // can be serialized using its definition's element structure. + const paramSystemIds = existingPayloads.map(p => p.parameterSystemId); + const allDefs = await this.uow + .getModuleDefinitionRepository() + .getParameterDefinitions(mod.definitionSystemId, paramSystemIds); + const defsByParamId = new Map(allDefs.map(d => [d.systemId, d])); + + for (const payload of existingPayloads) { + const def = defsByParamId.get(payload.parameterSystemId); + if (!def) continue; + // Default serialization currently lives here for compatibility. The + // planned core reset-plan function will produce these bytes instead. + const serialized = serializeDefaultParameterData(def); + resets.push({ + payloadSystemId: payload.systemId, + defaultValue: serialized.ok ? serialized.value : null, + }); + } + return resets; + } + */ + + /* Deferred until the core CKV reset-plan refactor. + private async writeCkvDeletes( + plans: Array<{ckvId: number; payloadIds: number[]}>, + moduleSystemId: number, + sessionId: number, + groupId: string, + ): Promise { + // Apply the delete plan in FK order: parameter payloads first, then CKV. + const deleted: number[] = []; + await Promise.all( + plans.map(async plan => { + // Delete payloads first (FK order), then the CKV row + await Promise.all( + plan.payloadIds.map(payloadId => + this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.CkvParameterPayload, + targetSystemId: payloadId, + aggregateId: moduleSystemId, + }, + sessionId, + groupId, + this.manager, + ), + ), + ); + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.Ckv, + targetSystemId: plan.ckvId, + aggregateId: moduleSystemId, + }, + sessionId, + groupId, + this.manager, + ); + deleted.push(plan.ckvId); + }), + ); + return deleted; + } + */ + + private async writeTkvDeletes( + plans: Array<{ + tagMapId: number; + tkvs: Array<{tkvId: number; payloadIds: number[]}>; + }>, + moduleSystemId: number, + sessionId: number, + groupId: string, + ): Promise { + await Promise.all( + plans.map(async plan => { + // Delete TKV payloads + TKV rows, then the ModuleTagIdMap row + await Promise.all( + plan.tkvs.map(async tkv => { + await Promise.all( + tkv.payloadIds.map(payloadId => + this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.TkvParameterPayload, + targetSystemId: payloadId, + aggregateId: plan.tagMapId, + }, + sessionId, + groupId, + this.manager, + ), + ), + ); + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.Tkv, + targetSystemId: tkv.tkvId, + aggregateId: plan.tagMapId, + }, + sessionId, + groupId, + this.manager, + ); + }), + ); + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.ModuleTagIdMap, + targetSystemId: plan.tagMapId, + aggregateId: moduleSystemId, + }, + sessionId, + groupId, + this.manager, + ); + }), + ); + } + + /* Deferred until the core zero-CKV reset-plan refactor. + private async writeZeroCkvResets( + resets: Array<{payloadSystemId: number; defaultValue: Uint8Array | null}>, + zeroCkvSystemId: number | undefined, + moduleSystemId: number, + sessionId: number, + groupId: string, + ): Promise { + if (!zeroCkvSystemId) return []; + // Update the existing zero-CKV payload rows so the zero-CKV system ID is + // preserved across the calibration reset. + let anyReset = false; + for (const reset of resets) { + await this.writer.writeDelta( + { + targetTable: ENTITY_NAMES.CkvParameterPayload, + targetSystemId: reset.payloadSystemId, + aggregateId: moduleSystemId, + delta: {payload: reset.defaultValue}, + }, + sessionId, + groupId, + this.manager, + ); + anyReset = true; + } + return anyReset ? [zeroCkvSystemId] : []; + } + */ } diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subgraph/subgraph.repository.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subgraph/subgraph.repository.ts index cfad8e0da..0d3c83e5f 100644 --- a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subgraph/subgraph.repository.ts +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/subgraph/subgraph.repository.ts @@ -6,6 +6,8 @@ import type {EntityManager} from 'typeorm'; import type { SubgraphRepository, + SubgraphWithProperties, + IdGenerationPort, UnitOfWork, EditOptions, Subgraph, @@ -19,8 +21,8 @@ import { import type {PendingChangeWriter} from '../../services/pending-change-writer.js'; import {ENTITY_NAMES} from '../../entity-schema/entity-table-names.js'; import {SubgraphOverlayFetcher} from '../../fetchers/subgraph-overlay-fetcher.js'; -import {SubgraphSgkvFetcher} from '../../fetchers/subgraph-sgkv-fetcher.js'; import {SubgraphPropertyDataFetcher} from '../../fetchers/subgraph-property-data-fetcher.js'; +import {SubgraphSgkvFetcher} from '../../fetchers/subgraph-sgkv-fetcher.js'; import {ValueDefinitionFetcher} from '../../fetchers/definitions/key-value/value-definition-fetcher.js'; import {SubgraphPropertyDefinitionFetcher} from '../../fetchers/definitions/subgraph-property-definition-fetcher.js'; import {EditActionsQueryService} from '../../queries/edit-session/edit-actions-query-service.js'; @@ -31,6 +33,7 @@ export class TypeOrmSubgraphRepository implements SubgraphRepository { private readonly subgraphFetcher: SubgraphOverlayFetcher; private readonly sgkvFetcher: SubgraphSgkvFetcher; private readonly valueDefFetcher: ValueDefinitionFetcher; + private readonly propertyDataFetcher: SubgraphPropertyDataFetcher; private readonly propertyDefinitionFetcher: SubgraphPropertyDefinitionFetcher; private readonly vcpmDataFetcher: SubgraphVcpmDataFetcher; @@ -38,17 +41,18 @@ export class TypeOrmSubgraphRepository implements SubgraphRepository { private readonly writer: PendingChangeWriter, private readonly manager: EntityManager, private readonly uow: UnitOfWork, + private readonly idGeneration: IdGenerationPort, ) { const editActionsQs = new EditActionsQueryService(manager); this.sgkvFetcher = new SubgraphSgkvFetcher(manager, editActionsQs); - const propertyDataFetcher = new SubgraphPropertyDataFetcher( + this.propertyDataFetcher = new SubgraphPropertyDataFetcher( manager, editActionsQs, ); this.subgraphFetcher = new SubgraphOverlayFetcher( manager, editActionsQs, - propertyDataFetcher, + this.propertyDataFetcher, this.sgkvFetcher, ); this.valueDefFetcher = new ValueDefinitionFetcher(manager, editActionsQs); @@ -260,7 +264,7 @@ export class TypeOrmSubgraphRepository implements SubgraphRepository { aggregateId: subgraph.systemId, payload: { subgraphSystemId: subgraph.systemId, - propertyDefinitionSystemId: prop.propertyDefinitionSystemId, + propertySystemId: prop.propertyDefinitionSystemId, payload: prop.getPayloadCopy() ?? null, }, ...options, @@ -272,9 +276,225 @@ export class TypeOrmSubgraphRepository implements SubgraphRepository { } } - // ── Hydration ───────────────────────────────────────────────────────────────── + async getAggregate( + subgraphSystemId: number, + fileSystemId: number, + ): Promise { + const sessionId = this.uow.getWriteContext().session.sessionId; + const overlaid = await this.subgraphFetcher.fetchOne( + subgraphSystemId, + fileSystemId, + sessionId, + ); + if (!overlaid) return null; + return { + systemId: overlaid.systemId, + properties: overlaid.properties.map(p => ({ + systemId: p.systemId, + propertySystemId: p.propertySystemId, + payload: p.payload, + })), + }; + } + + async getAggregates( + subgraphSystemIds: number[], + fileSystemId: number, + ): Promise> { + if (subgraphSystemIds.length === 0) return new Map(); + const sessionId = this.uow.getWriteContext().session.sessionId; + + // One query for all subgraph rows + const rows = await this.subgraphFetcher.fetchMany(fileSystemId, sessionId, { + systemId: subgraphSystemIds, + }); + + // One query for all property rows across all requested subgraphs + const allProperties = await this.propertyDataFetcher.fetchMany( + subgraphSystemIds, + sessionId, + ); + + // Group properties by subgraphSystemId + const propsBySubgraph = new Map(); + for (const prop of allProperties) { + const list = propsBySubgraph.get(prop.subgraphSystemId) ?? []; + list.push(prop); + propsBySubgraph.set(prop.subgraphSystemId, list); + } + + const result = new Map(); + for (const row of rows) { + result.set(row.systemId, { + systemId: row.systemId, + properties: (propsBySubgraph.get(row.systemId) ?? []).map(p => ({ + systemId: p.systemId, + propertySystemId: p.propertySystemId, + payload: p.payload, + })), + }); + } + return result; + } + + async getSubgraphIdsInSameUsecasesForMany( + subgraphSystemIds: number[], + _fileSystemId: number, + ): Promise { + if (subgraphSystemIds.length === 0) return []; + + const usecaseRows = await this.manager + .getRepository(ENTITY_NAMES.UseCaseSubgraph) + .createQueryBuilder('ucs') + .select('DISTINCT ucs.usecaseSystemId', 'usecaseSystemId') + .where('ucs.subgraphSystemId IN (:...subgraphSystemIds)', { + subgraphSystemIds, + }) + .getRawMany<{usecaseSystemId: number}>(); + if (usecaseRows.length === 0) return []; + + const usecaseSystemIds = usecaseRows.map(row => row.usecaseSystemId); + const gkvRows = await this.manager + .getRepository(ENTITY_NAMES.UsecaseGkvValues) + .createQueryBuilder('ugkv') + .select('DISTINCT ugkv.usecaseSystemId', 'usecaseSystemId') + .where('ugkv.usecaseSystemId IN (:...usecaseSystemIds)', { + usecaseSystemIds, + }) + .getRawMany<{usecaseSystemId: number}>(); + if (gkvRows.length === 0) return []; + + const linkedUsecaseSystemIds = gkvRows.map(row => row.usecaseSystemId); + const linkedRows = await this.manager + .getRepository(ENTITY_NAMES.UseCaseSubgraph) + .createQueryBuilder('ucs') + .select('DISTINCT ucs.subgraphSystemId', 'subgraphSystemId') + .where('ucs.usecaseSystemId IN (:...linkedUsecaseSystemIds)', { + linkedUsecaseSystemIds, + }) + .getRawMany<{subgraphSystemId: number}>(); + + const inputIds = new Set(subgraphSystemIds); + return linkedRows + .map(row => row.subgraphSystemId) + .filter(systemId => !inputIds.has(systemId)); + } + + async addProperty( + subgraphSystemId: number, + propertySystemId: number, + payload: Uint8Array, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + const systemId = await this.idGeneration.getNextId(session.fileSystemId); + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.SubgraphPropertyData, + targetSystemId: systemId, + aggregateId: subgraphSystemId, + payload: {subgraphSystemId, propertySystemId, payload}, + }, + session.sessionId, + groupId, + this.manager, + ); + return systemId; + } + + async rename(subgraphSystemId: number, name: string): Promise { + const {session, groupId} = this.uow.getWriteContext(); + await this.writer.writeDelta( + { + targetTable: ENTITY_NAMES.Subgraph, + targetSystemId: subgraphSystemId, + aggregateId: subgraphSystemId, + delta: {name}, + }, + session.sessionId, + groupId, + this.manager, + ); + } + + async setPropertyData( + subgraphSystemId: number, + propertySystemId: number, + data: Uint8Array, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + const properties = await this.propertyDataFetcher.fetchMany( + [subgraphSystemId], + session.sessionId, + ); + const prop = properties.find( + row => row.propertySystemId === propertySystemId, + ); + if (!prop) { + throw new Error( + `SubgraphPropertyData for property ${propertySystemId} not found on subgraph ${subgraphSystemId}.`, + ); + } + await this.writer.writeDelta( + { + targetTable: ENTITY_NAMES.SubgraphPropertyData, + targetSystemId: prop.systemId, + aggregateId: subgraphSystemId, + delta: {payload: data}, + }, + session.sessionId, + groupId, + this.manager, + ); + } + + async removeProperty( + subgraphSystemId: number, + propertyDataSystemId: number, + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + await this.writer.writeDelete( + { + targetTable: ENTITY_NAMES.SubgraphPropertyData, + targetSystemId: propertyDataSystemId, + aggregateId: subgraphSystemId, + }, + session.sessionId, + groupId, + this.manager, + ); + } + + async removeAllVcpmCfgData(subgraphSystemId: number): Promise { + const {session, groupId} = this.uow.getWriteContext(); + const data = await this.vcpmDataFetcher.fetchForSubgraph( + subgraphSystemId, + session.sessionId, + ); + const ownedRows = [ + { + targetTable: ENTITY_NAMES.VcpmParameterPayload, + rows: data.parameterPayloads, + }, + {targetTable: ENTITY_NAMES.VcpmCkv, rows: data.ckvs}, + {targetTable: ENTITY_NAMES.VcpmInstance, rows: data.instances}, + ]; + for (const owned of ownedRows) { + for (const row of owned.rows) { + await this.writer.writeDelete( + { + targetTable: owned.targetTable, + targetSystemId: row.systemId, + aggregateId: subgraphSystemId, + }, + session.sessionId, + groupId, + this.manager, + ); + } + } + } - private hydrate(base: SubgraphBase): Subgraph { + private hydrate(base: SubgraphBase): SubgraphEntity { return new SubgraphEntity({ systemId: base.systemId, naturalId: base.naturalId, diff --git a/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/vcpm-definition/vcpm-definition.repository.ts b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/vcpm-definition/vcpm-definition.repository.ts new file mode 100644 index 000000000..616105918 --- /dev/null +++ b/packages/infrastructure/persistence/src/persistence-typeorm-sqllite/repositories/vcpm-definition/vcpm-definition.repository.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import type {EntityManager} from 'typeorm'; +import type { + IdGenerationPort, + UnitOfWork, + VcpmDefaultData, + VcpmDefinitionRepository, + VcpmModuleDefinitionWithParamsReadModel, +} from '@arc/core'; +import {ENTITY_NAMES} from '../../entity-schema/entity-table-names.js'; +import type {PendingChangeWriter} from '../../services/pending-change-writer.js'; + +/** TypeORM adapter for effective VCPM definition reads and staged defaults. */ +export class TypeOrmVcpmDefinitionRepository implements VcpmDefinitionRepository { + constructor( + private readonly writer: PendingChangeWriter, + private readonly manager: EntityManager, + private readonly uow: UnitOfWork, + private readonly idGeneration: IdGenerationPort, + ) {} + + async getAllVcpmModuleDefinitions( + fileSystemId: number, + ): Promise { + const rows = await this.manager + .createQueryBuilder() + .select('vmd.systemId', 'moduleSystemId') + .addSelect('vmd.naturalId', 'moduleDefinitionId') + .addSelect('vmpd.systemId', 'paramSystemId') + .addSelect('vmpd.naturalId', 'paramId') + .addSelect('vmpd.elementsStructure', 'elementsStructure') + .addSelect('vmpd.isReadOnly', 'isReadOnly') + .from(ENTITY_NAMES.VcpmModuleDefinition, 'vmd') + .leftJoin( + ENTITY_NAMES.VcpmModuleParameterDefinition, + 'vmpd', + 'vmpd.vcpmModuleDefinitionSystemId = vmd.systemId', + ) + .where('vmd.fileSystemId = :fileSystemId', {fileSystemId}) + .getRawMany<{ + moduleSystemId: number; + moduleDefinitionId: number; + paramSystemId: number | null; + paramId: number | null; + elementsStructure: string | null; + isReadOnly: number | null; + }>(); + + const definitions = new Map< + number, + VcpmModuleDefinitionWithParamsReadModel + >(); + for (const row of rows) { + if (!definitions.has(row.moduleSystemId)) { + definitions.set(row.moduleSystemId, { + systemId: row.moduleSystemId, + moduleDefinitionId: row.moduleDefinitionId, + parameters: [], + }); + } + if (row.paramSystemId !== null) { + definitions.get(row.moduleSystemId)!.parameters.push({ + systemId: row.paramSystemId, + paramId: row.paramId ?? 0, + elementsStructure: row.elementsStructure ?? '', + isReadOnly: Boolean(row.isReadOnly), + }); + } + } + return [...definitions.values()]; + } + + async addVcpmCfgDefaultData( + subgraphSystemId: number, + defaults: readonly VcpmDefaultData[], + ): Promise { + const {session, groupId} = this.uow.getWriteContext(); + for (const definition of defaults) { + const instanceSystemId = await this.idGeneration.getNextId( + session.fileSystemId, + ); + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.VcpmInstance, + targetSystemId: instanceSystemId, + aggregateId: subgraphSystemId, + payload: { + subgraphSystemId, + vcpmDefinitionId: definition.definitionSystemId, + }, + }, + session.sessionId, + groupId, + this.manager, + ); + + const ckvSystemId = await this.idGeneration.getNextId( + session.fileSystemId, + ); + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.VcpmCkv, + targetSystemId: ckvSystemId, + aggregateId: subgraphSystemId, + payload: {vcpmInstanceSystemId: instanceSystemId}, + }, + session.sessionId, + groupId, + this.manager, + ); + + for (const parameter of definition.parameters) { + const payloadSystemId = await this.idGeneration.getNextId( + session.fileSystemId, + ); + await this.writer.writeCreate( + { + targetTable: ENTITY_NAMES.VcpmParameterPayload, + targetSystemId: payloadSystemId, + aggregateId: subgraphSystemId, + payload: { + vcpmCkvSystemId: ckvSystemId, + vcpmParameterSystemId: parameter.parameterSystemId, + payload: parameter.payload, + }, + }, + session.sessionId, + groupId, + this.manager, + ); + } + } + } +} diff --git a/packages/infrastructure/persistence/tests/integration/queries/subgraph-property-definition/db-subgraph-property-def-query-service.spec.ts b/packages/infrastructure/persistence/tests/integration/queries/subgraph-property-definition/db-subgraph-property-def-query-service.spec.ts index 6d7343fef..bf452b475 100644 --- a/packages/infrastructure/persistence/tests/integration/queries/subgraph-property-definition/db-subgraph-property-def-query-service.spec.ts +++ b/packages/infrastructure/persistence/tests/integration/queries/subgraph-property-definition/db-subgraph-property-def-query-service.spec.ts @@ -338,13 +338,11 @@ describe('DbSubgraphPropertyDefQueryService Integration Tests', () => { }); }); - describe('getAllDetailedSubgraphPropertyDefinitionsWithElements', () => { + describe('getSubgraphPropertiesWithElements', () => { it('returns empty array when no definitions exist', async () => { const {fileSystemId} = await createFileDependency(); const result = - await service.getAllDetailedSubgraphPropertyDefinitionsWithElements( - fileSystemId, - ); + await service.getSubgraphPropertiesWithElements(fileSystemId); expect(result.kind).toBe(RESULT_KIND.Ok); if (result.kind !== RESULT_KIND.Ok) return; expect(result.data).toEqual([]); @@ -364,9 +362,7 @@ describe('DbSubgraphPropertyDefQueryService Integration Tests', () => { isVoice: false, }); const result = - await service.getAllDetailedSubgraphPropertyDefinitionsWithElements( - fileSystemId, - ); + await service.getSubgraphPropertiesWithElements(fileSystemId); expect(result.kind).toBe(RESULT_KIND.Ok); if (result.kind !== RESULT_KIND.Ok) return; expect(result.data).toHaveLength(1); @@ -390,9 +386,7 @@ describe('DbSubgraphPropertyDefQueryService Integration Tests', () => { isVoice: true, }); const result = - await service.getAllDetailedSubgraphPropertyDefinitionsWithElements( - fileSystemId, - ); + await service.getSubgraphPropertiesWithElements(fileSystemId); expect(result.data![0].isVoice).toBe(true); }); @@ -410,9 +404,7 @@ describe('DbSubgraphPropertyDefQueryService Integration Tests', () => { isVoice: false, }); const result = - await service.getAllDetailedSubgraphPropertyDefinitionsWithElements( - fileSystemId, - ); + await service.getSubgraphPropertiesWithElements(fileSystemId); expect(result.data![0].elementsStructure).toBe(''); }); }); diff --git a/packages/infrastructure/persistence/tests/integration/repositories/subgraph/subgraph-property.repository.spec.ts b/packages/infrastructure/persistence/tests/integration/repositories/subgraph/subgraph-property.repository.spec.ts new file mode 100644 index 000000000..1eb6ad3af --- /dev/null +++ b/packages/infrastructure/persistence/tests/integration/repositories/subgraph/subgraph-property.repository.spec.ts @@ -0,0 +1,246 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, +} from '@jest/globals'; +import type {DataSource} from 'typeorm'; +import { + setupIntegrationTest, + teardownIntegrationTest, + setupEachTest, + getTestDataSource, + getTestRepository, +} from '../../helpers/test-database-setup.js'; +import {TypeOrmSubgraphRepository} from '../../../../src/persistence-typeorm-sqllite/repositories/subgraph/subgraph.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 {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, + SESSION_MODE, + SESSION_STATUS, +} from '../../../../src/persistence-typeorm-sqllite/entity-schema/edit-session/project-session.schema.js'; + +const FILE_ID = 200; +const SG_ID = 50; +const PROP_DEF_SYS_ID = 101; +const PROP_DATA_SYS_ID = 301; + +beforeAll(async () => setupIntegrationTest()); +afterAll(async () => teardownIntegrationTest()); +beforeEach(async () => setupEachTest()); + +async function seedBase(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, + }); + await ds.query( + `INSERT INTO subgraphs (system_id, subgraph_id, name, is_imported, file_system_id) VALUES (?, 10, 'sg', 0, ?)`, + [SG_ID, FILE_ID], + ); + await ds.query( + `INSERT INTO subgraph_property_definitions (system_id, property_id, name, property_type, is_voice, file_system_id, max_size, elements_structure) VALUES (?, 55, 'gain', 'SPF', 0, ?, 4, '[]')`, + [PROP_DEF_SYS_ID, FILE_ID], + ); + await ds.query( + `INSERT INTO subgraph_property_data (system_id, subgraph_system_id, subgraph_property_system_id, payload) VALUES (?, ?, ?, X'00000000')`, + [PROP_DATA_SYS_ID, SG_ID, PROP_DEF_SYS_ID], + ); +} + +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; +} + +function makeRepo( + ds: DataSource, + sessionId: number, + fileSystemId = FILE_ID, +): TypeOrmSubgraphRepository { + const editActionsQs = new EditActionsQueryService(ds); + const cache = new PendingChangeCache(); + const writer = new PendingChangeWriter(editActionsQs, cache); + const uow = { + getWriteContext: () => ({ + session: {sessionId, fileSystemId}, + groupId: 'g1', + }), + } as any; + const idGeneration = { + getNextId: async () => Math.floor(Math.random() * 100_000) + 10_000, + } as any; + return new TypeOrmSubgraphRepository(writer, ds.manager, uow, idGeneration); +} + +describe('TypeOrmSubgraphRepository — rename', () => { + it('writes a delta edit_action row on the Subgraph row', async () => { + const ds = getTestDataSource(); + await seedBase(ds); + const sessionId = await seedSession(ds); + const repo = makeRepo(ds, sessionId); + + await repo.rename(SG_ID, 'renamed'); + + const rows = await ds.manager + .getRepository(ENTITY_NAMES.EditAction) + .createQueryBuilder('ea') + .where('ea.targetTable = :t AND ea.targetSystemId = :id', { + t: 'Subgraph', + id: SG_ID, + }) + .getMany(); + expect(rows).toHaveLength(1); + // newValue holds the serialized field update + expect(rows[0]!.newValue).toBeDefined(); + }); +}); + +describe('TypeOrmSubgraphRepository — setPropertyData', () => { + it('writes a delta edit_action row on SubgraphPropertyData', async () => { + const ds = getTestDataSource(); + await seedBase(ds); + const sessionId = await seedSession(ds); + const repo = makeRepo(ds, sessionId); + + await repo.setPropertyData( + SG_ID, + PROP_DEF_SYS_ID, + new Uint8Array([1, 2, 3, 4]), + ); + + const rows = await ds.manager + .getRepository(ENTITY_NAMES.EditAction) + .createQueryBuilder('ea') + .where('ea.targetTable = :t AND ea.targetSystemId = :id', { + t: 'SubgraphPropertyData', + id: PROP_DATA_SYS_ID, + }) + .getMany(); + expect(rows).toHaveLength(1); + }); + + it('throws when property row does not exist on subgraph', async () => { + const ds = getTestDataSource(); + await seedBase(ds); + const sessionId = await seedSession(ds); + const repo = makeRepo(ds, sessionId); + + await expect( + repo.setPropertyData(SG_ID, 9999, new Uint8Array([1])), + ).rejects.toThrow(); + }); +}); + +describe('TypeOrmSubgraphRepository — addProperty', () => { + it('writes the prepared payload without loading a property definition', async () => { + const ds = getTestDataSource(); + await seedBase(ds); + const sessionId = await seedSession(ds); + const repo = makeRepo(ds, sessionId); + const payload = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + + const propertyDataSystemId = await repo.addProperty( + SG_ID, + PROP_DEF_SYS_ID, + payload, + ); + + const rows: Array<{ + target_table: string; + aggregate_id: number; + target_system_id: number; + new_value: string; + }> = await ds.query( + `SELECT target_table, aggregate_id, target_system_id, new_value FROM edit_actions WHERE session_id = ?`, + [sessionId], + ); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + target_table: ENTITY_NAMES.SubgraphPropertyData, + aggregate_id: SG_ID, + target_system_id: propertyDataSystemId, + }); + const value = JSON.parse(rows[0]!.new_value) as { + subgraphSystemId: number; + propertySystemId: number; + payload: {__blob: string}; + }; + expect(value).toMatchObject({ + subgraphSystemId: SG_ID, + propertySystemId: PROP_DEF_SYS_ID, + }); + expect(Buffer.from(value.payload.__blob, 'base64')).toEqual( + Buffer.from(payload), + ); + }); +}); + +describe('TypeOrmSubgraphRepository — getAggregate', () => { + it('returns subgraph with property rows from base data', async () => { + const ds = getTestDataSource(); + await seedBase(ds); + const sessionId = await seedSession(ds); + const repo = makeRepo(ds, sessionId); + + const result = await repo.getAggregate(SG_ID, FILE_ID); + expect(result).not.toBeNull(); + expect(result!.systemId).toBe(SG_ID); + expect(result!.properties).toHaveLength(1); + }); + + it('returns null when subgraph does not exist', async () => { + const ds = getTestDataSource(); + await seedBase(ds); + const sessionId = await seedSession(ds); + const repo = makeRepo(ds, sessionId); + + const result = await repo.getAggregate(9999, FILE_ID); + expect(result).toBeNull(); + }); +}); + +describe('TypeOrmSubgraphRepository — getSubgraphIdsInSameUsecasesForMany', () => { + it('returns empty array when subgraph has no usecases', async () => { + const ds = getTestDataSource(); + await seedBase(ds); + const sessionId = await seedSession(ds); + const repo = makeRepo(ds, sessionId); + + const result = await repo.getSubgraphIdsInSameUsecasesForMany( + [SG_ID], + FILE_ID, + ); + expect(result).toEqual([]); + }); +}); diff --git a/packages/infrastructure/persistence/tests/integration/repositories/subgraph/subgraph.repository.integration.spec.ts b/packages/infrastructure/persistence/tests/integration/repositories/subgraph/subgraph.repository.integration.spec.ts index 09b27c351..edcf21411 100644 --- a/packages/infrastructure/persistence/tests/integration/repositories/subgraph/subgraph.repository.integration.spec.ts +++ b/packages/infrastructure/persistence/tests/integration/repositories/subgraph/subgraph.repository.integration.spec.ts @@ -144,7 +144,10 @@ function makeRepo( groupId: 'test-group', }), } as any; - return new TypeOrmSubgraphRepository(writer, manager, uow); + const idGeneration = { + getNextId: async () => 10_000, + } as any; + return new TypeOrmSubgraphRepository(writer, manager, uow, idGeneration); } describe('TypeOrmSubgraphRepository (integration)', () => { diff --git a/packages/infrastructure/persistence/tests/integration/repositories/vcpm-definition/vcpm-definition.repository.integration.spec.ts b/packages/infrastructure/persistence/tests/integration/repositories/vcpm-definition/vcpm-definition.repository.integration.spec.ts new file mode 100644 index 000000000..4d8b5fa6a --- /dev/null +++ b/packages/infrastructure/persistence/tests/integration/repositories/vcpm-definition/vcpm-definition.repository.integration.spec.ts @@ -0,0 +1,133 @@ +/* + * Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from '@jest/globals'; +import type {DataSource} from 'typeorm'; +import { + getTestDataSource, + getTestRepository, + setupEachTest, + setupIntegrationTest, + teardownIntegrationTest, +} from '../../helpers/test-database-setup.js'; +import {TypeOrmVcpmDefinitionRepository} from '../../../../src/persistence-typeorm-sqllite/repositories/vcpm-definition/vcpm-definition.repository.js'; +import {EditActionsQueryService} from '../../../../src/persistence-typeorm-sqllite/queries/edit-session/edit-actions-query-service.js'; +import {PendingChangeCache} from '../../../../src/persistence-typeorm-sqllite/services/pending-change-cache.js'; +import {PendingChangeWriter} from '../../../../src/persistence-typeorm-sqllite/services/pending-change-writer.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, + SESSION_MODE, + SESSION_STATUS, +} from '../../../../src/persistence-typeorm-sqllite/entity-schema/edit-session/project-session.schema.js'; +import {VcpmModuleDefinitionSchema} from '../../../../src/persistence-typeorm-sqllite/entity-schema/definitions/subgraph/vcpm/vcpm-module-definition.schema.js'; +import {VcpmModuleParameterDefinitionSchema} from '../../../../src/persistence-typeorm-sqllite/entity-schema/definitions/subgraph/vcpm/vcpm-module-parameter-definition.schema.js'; + +const FILE_ID = 200; + +beforeAll(async () => setupIntegrationTest()); +afterAll(async () => teardownIntegrationTest()); +beforeEach(async () => setupEachTest()); + +async function seedBase(ds: DataSource): Promise { + 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, + }); + const session = await getTestRepository(ProjectSessionSchema).save({ + fileSystemId: FILE_ID, + userId: 'u', + sessionMode: SESSION_MODE.Designer, + status: SESSION_STATUS.Active, + endedAt: null, + }); + return session.sessionId; +} + +function makeRepository( + ds: DataSource, + sessionId: number, +): TypeOrmVcpmDefinitionRepository { + const editActionsQs = new EditActionsQueryService(ds); + const writer = new PendingChangeWriter( + editActionsQs, + new PendingChangeCache(), + ); + const uow = { + getWriteContext: () => ({ + session: {sessionId, fileSystemId: FILE_ID}, + groupId: 'g1', + }), + } as any; + const idGeneration = {getNextId: async () => 1} as any; + return new TypeOrmVcpmDefinitionRepository( + writer, + ds.manager, + uow, + idGeneration, + ); +} + +describe('TypeOrmVcpmDefinitionRepository', () => { + it('groups VCPM module definitions with their parameters', async () => { + const ds = getTestDataSource(); + const sessionId = await seedBase(ds); + await getTestRepository(VcpmModuleDefinitionSchema).save({ + systemId: 401, + naturalId: 9001, + name: 'VCPM', + fileSystemId: FILE_ID, + }); + await getTestRepository(VcpmModuleParameterDefinitionSchema).save({ + systemId: 402, + naturalId: 7, + name: 'param', + maxSize: 4, + pidType: 'UInt32', + isPersistent: true, + isReadOnly: false, + elementsStructure: '[]', + vcpmModuleDefinitionSystemId: 401, + }); + + const repository = makeRepository(ds, sessionId); + + await expect( + repository.getAllVcpmModuleDefinitions(FILE_ID), + ).resolves.toEqual([ + { + systemId: 401, + moduleDefinitionId: 9001, + parameters: [ + { + systemId: 402, + paramId: 7, + elementsStructure: '[]', + isReadOnly: false, + }, + ], + }, + ]); + }); +});