From b2cdf2c8179cd531ab7f116d2129de9b0c240a6e Mon Sep 17 00:00:00 2001 From: stack72 Date: Mon, 4 May 2026 23:13:20 +0100 Subject: [PATCH] refactor(domain): lift UnifiedDataRepository port into the domain layer (swamp-club#229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the UnifiedDataRepository interface, OwnershipValidationError class, and GarbageCollectionResult type from src/infrastructure/persistence/ into a new src/domain/data/repositories.ts so the domain layer owns the contract. The infra file imports them back and re-exports for non-domain consumers (cli, libswamp, integration tests). FileSystemUnifiedDataRepository keeps its implements clause against the now-domain-side interface. Switch all 13 production domain importers (and 10 domain test files) of UnifiedDataRepository to import from the new domain path. Update the two data services (rename, delete) to also depend on the domain-side DefinitionRepository instead of the concrete YamlDefinitionRepository — findDefinitionByIdOrName already accepts the interface, so no other call-site changes are needed. Decrement KNOWN_DOMAIN_INFRA_VIOLATIONS in integration/ddd_layer_rules_test.ts from 31 to 22 (down 9 — files where UnifiedDataRepository was the sole infrastructure import). Files that retain other infra imports (CatalogStore, CatalogRow, YamlOutputRepository, FileSystemUnifiedDataRepository concrete class) update their UnifiedDataRepository import path for canonical-source correctness but stay on the violations list — those are deeper refactors out of scope here. Add src/infrastructure/persistence/unified_data_repository_re_export_test.ts to lock the value re-export contract: asserts class identity is preserved across both import paths and that instanceof works against either side. Guards against a future regression that converts the value re-export to a type-only re-export or duplicates the class definition. Co-Authored-By: Claude Opus 4.7 (1M context) --- integration/ddd_layer_rules_test.ts | 9 +- src/domain/data/data_access_service.ts | 2 +- src/domain/data/data_access_service_test.ts | 2 +- src/domain/data/data_delete_service.ts | 6 +- src/domain/data/data_lifecycle_service.ts | 2 +- src/domain/data/data_query_service.ts | 2 +- src/domain/data/data_record_mapper.ts | 2 +- src/domain/data/data_record_mapper_test.ts | 2 +- src/domain/data/data_rename_service.ts | 6 +- src/domain/data/repositories.ts | 403 ++++++++++++++++++ .../drivers/raw_execution_driver_test.ts | 2 +- src/domain/expressions/model_resolver.ts | 2 +- .../models/command/shell/shell_model_test.ts | 2 +- src/domain/models/data_writer.ts | 2 +- src/domain/models/data_writer_test.ts | 2 +- src/domain/models/method_context_test.ts | 2 +- .../models/method_execution_service_test.ts | 2 +- src/domain/models/model.ts | 2 +- src/domain/models/model_test.ts | 2 +- src/domain/models/user_model_loader_test.ts | 2 +- src/domain/models/validation_service_test.ts | 2 +- src/domain/reports/report_context.ts | 2 +- .../reports/report_execution_service.ts | 2 +- src/domain/workflows/execution_service.ts | 2 +- src/domain/workflows/method_report_runner.ts | 2 +- .../persistence/unified_data_repository.ts | 394 +---------------- .../unified_data_repository_re_export_test.ts | 42 ++ 27 files changed, 488 insertions(+), 414 deletions(-) create mode 100644 src/domain/data/repositories.ts create mode 100644 src/infrastructure/persistence/unified_data_repository_re_export_test.ts diff --git a/integration/ddd_layer_rules_test.ts b/integration/ddd_layer_rules_test.ts index 56d28abc..249889ea 100644 --- a/integration/ddd_layer_rules_test.ts +++ b/integration/ddd_layer_rules_test.ts @@ -69,9 +69,7 @@ function isTracingImport(filePath: string, importPath: string): boolean { // If someone fixes a violation, the count decreases and the test still passes. // If someone adds a new violation, the count increases and the test fails. // -// Tracked refactor (data services → domain-side ports): swamp-club#229. -// -// Issue #223 (W1b extension catalog rearchitecture) added 4 new +// Issue #223 (W1b extension catalog rearchitecture) added 4 transitional // domain→infrastructure imports: // - src/domain/extensions/bundle_location.ts → canonicalizePath // - src/domain/extensions/source_location.ts → canonicalizePath @@ -83,9 +81,8 @@ function isTracingImport(filePath: string, importPath: string): boolean { // references for I-Repo-1 cross-aggregate uniqueness. Both are accepted // as transitional ports — the canonicalizer should move to a shared // path-utility module (W3 territory) and ExtensionKind should hoist to -// the domain layer when the catalog gets fully replaced (W4). Until -// then the violations are bounded and the ratchet rises by 4 (27 + 4). -const KNOWN_DOMAIN_INFRA_VIOLATIONS = 31; +// the domain layer when the catalog gets fully replaced (W4). +const KNOWN_DOMAIN_INFRA_VIOLATIONS = 22; Deno.test( "domain layer must not add new infrastructure imports (ratchet)", diff --git a/src/domain/data/data_access_service.ts b/src/domain/data/data_access_service.ts index d35544bc..8862ae61 100644 --- a/src/domain/data/data_access_service.ts +++ b/src/domain/data/data_access_service.ts @@ -19,7 +19,7 @@ import type { DefinitionRepository } from "../definitions/repositories.ts"; import type { ModelType } from "../models/model_type.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "./repositories.ts"; import type { VaultService } from "../vaults/vault_service.ts"; import type { SecretRedactor } from "../secrets/mod.ts"; import type { Data } from "./data.ts"; diff --git a/src/domain/data/data_access_service_test.ts b/src/domain/data/data_access_service_test.ts index 24b61b93..0fbdd147 100644 --- a/src/domain/data/data_access_service_test.ts +++ b/src/domain/data/data_access_service_test.ts @@ -24,7 +24,7 @@ import { ModelType } from "../models/model_type.ts"; import { Definition } from "../definitions/definition.ts"; import { computeDefinitionHash } from "../models/model_output.ts"; import type { DefinitionRepository } from "../definitions/repositories.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "./repositories.ts"; import type { VaultService } from "../vaults/vault_service.ts"; // Import models barrel to trigger self-registration diff --git a/src/domain/data/data_delete_service.ts b/src/domain/data/data_delete_service.ts index 491c5173..cb8eb0f7 100644 --- a/src/domain/data/data_delete_service.ts +++ b/src/domain/data/data_delete_service.ts @@ -17,8 +17,8 @@ // You should have received a copy of the GNU Affero General Public License // along with Swamp. If not, see . -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; -import type { YamlDefinitionRepository } from "../../infrastructure/persistence/yaml_definition_repository.ts"; +import type { UnifiedDataRepository } from "./repositories.ts"; +import type { DefinitionRepository } from "../definitions/repositories.ts"; import { findDefinitionByIdOrName } from "../models/model_lookup.ts"; /** @@ -60,7 +60,7 @@ export interface DeletePreview { export class DataDeleteService { constructor( private readonly dataRepo: UnifiedDataRepository, - private readonly definitionRepo: YamlDefinitionRepository, + private readonly definitionRepo: DefinitionRepository, ) {} async delete( diff --git a/src/domain/data/data_lifecycle_service.ts b/src/domain/data/data_lifecycle_service.ts index 7c0b5f74..b27d2ce1 100644 --- a/src/domain/data/data_lifecycle_service.ts +++ b/src/domain/data/data_lifecycle_service.ts @@ -21,7 +21,7 @@ import { getLogger } from "@logtape/logtape"; import type { Data } from "./data.ts"; import type { Lifetime } from "./data_metadata.ts"; import { parseDataDuration } from "./duration.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "./repositories.ts"; import type { WorkflowRunRepository } from "../workflows/repositories.ts"; import type { ModelType } from "../models/model_type.ts"; import { diff --git a/src/domain/data/data_query_service.ts b/src/domain/data/data_query_service.ts index ac0589a6..53ef27a9 100644 --- a/src/domain/data/data_query_service.ts +++ b/src/domain/data/data_query_service.ts @@ -23,7 +23,7 @@ import type { CatalogRow, CatalogStore, } from "../../infrastructure/persistence/catalog_store.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "./repositories.ts"; import type { DataRecord } from "./data_record.ts"; import { type ASTNode, diff --git a/src/domain/data/data_record_mapper.ts b/src/domain/data/data_record_mapper.ts index 9a883282..1c49b8ff 100644 --- a/src/domain/data/data_record_mapper.ts +++ b/src/domain/data/data_record_mapper.ts @@ -21,7 +21,7 @@ import type { CatalogRow } from "../../infrastructure/persistence/catalog_store. import type { DataRecord, FileDataRecord } from "./data_record.ts"; import type { Data } from "./data.ts"; import { ModelType } from "../models/model_type.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "./repositories.ts"; import type { VaultService } from "../vaults/vault_service.ts"; import type { SecretRedactor } from "../secrets/mod.ts"; import type { DataHandle } from "../models/model.ts"; diff --git a/src/domain/data/data_record_mapper_test.ts b/src/domain/data/data_record_mapper_test.ts index 960f31c9..750b72c2 100644 --- a/src/domain/data/data_record_mapper_test.ts +++ b/src/domain/data/data_record_mapper_test.ts @@ -20,7 +20,7 @@ import { assertEquals } from "@std/assert"; import { fromData, fromRow } from "./data_record_mapper.ts"; import type { CatalogRow } from "../../infrastructure/persistence/catalog_store.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "./repositories.ts"; import { Data } from "./data.ts"; import { ModelType } from "../models/model_type.ts"; diff --git a/src/domain/data/data_rename_service.ts b/src/domain/data/data_rename_service.ts index a3e40b14..34cb1e1a 100644 --- a/src/domain/data/data_rename_service.ts +++ b/src/domain/data/data_rename_service.ts @@ -18,8 +18,8 @@ // along with Swamp. If not, see . import { DataMetadataSchema } from "./data_metadata.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; -import type { YamlDefinitionRepository } from "../../infrastructure/persistence/yaml_definition_repository.ts"; +import type { UnifiedDataRepository } from "./repositories.ts"; +import type { DefinitionRepository } from "../definitions/repositories.ts"; import { findDefinitionByIdOrName } from "../models/model_lookup.ts"; /** @@ -44,7 +44,7 @@ export interface RenameResult { export class DataRenameService { constructor( private readonly dataRepo: UnifiedDataRepository, - private readonly definitionRepo: YamlDefinitionRepository, + private readonly definitionRepo: DefinitionRepository, ) {} /** diff --git a/src/domain/data/repositories.ts b/src/domain/data/repositories.ts new file mode 100644 index 00000000..8d437d79 --- /dev/null +++ b/src/domain/data/repositories.ts @@ -0,0 +1,403 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import type { Data } from "./data.ts"; +import type { DataId } from "./data_id.ts"; +import type { ModelType } from "../models/model_type.ts"; + +/** + * Error thrown when ownership validation fails. + */ +export class OwnershipValidationError extends Error { + constructor( + readonly dataName: string, + readonly existingOwner: { ownerType: string; ownerRef: string }, + readonly newOwner: { ownerType: string; ownerRef: string }, + ) { + super( + `Ownership validation failed for "${dataName}": ` + + `existing owner "${existingOwner.ownerType}:${existingOwner.ownerRef}" ` + + `does not match new owner "${newOwner.ownerType}:${newOwner.ownerRef}"`, + ); + this.name = "OwnershipValidationError"; + } +} + +/** + * Result of garbage collection operation. + */ +export interface GarbageCollectionResult { + versionsRemoved: number; + bytesReclaimed: number; +} + +/** + * Repository interface for unified Data storage with versioning. + */ +export interface UnifiedDataRepository { + /** + * Finds all data across all model types and models. + * + * @returns Array of data with their model type and model ID + */ + findAllGlobal(): Promise< + Array<{ data: Data; modelType: ModelType; modelId: string }> + >; + + /** + * Finds data by name, optionally for a specific version. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataName - The data name + * @param version - Optional version (defaults to latest) + * @returns The data if found, or null + */ + findByName( + type: ModelType, + modelId: string, + dataName: string, + version?: number, + ): Promise; + + /** + * Finds data by ID, optionally for a specific version. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataId - The data ID + * @param version - Optional version (defaults to latest) + * @returns The data if found, or null + */ + findById( + type: ModelType, + modelId: string, + dataId: DataId, + version?: number, + ): Promise; + + /** + * Lists all versions for a data name. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataName - The data name + * @returns Array of version numbers in ascending order + */ + listVersions( + type: ModelType, + modelId: string, + dataName: string, + ): Promise; + + /** + * Finds all data for a model. + * + * @param type - The model type + * @param modelId - The model input ID + * @returns Array of data (latest version of each) + */ + findAllForModel(type: ModelType, modelId: string): Promise; + + /** + * Saves data with its content, creating a new version. + * Validates ownership if data with the same name already exists. + * + * @param type - The model type + * @param modelId - The model input ID + * @param data - The data entity + * @param content - The content to save + * @returns The saved version number + * @throws OwnershipValidationError if ownership validation fails + */ + save( + type: ModelType, + modelId: string, + data: Data, + content: Uint8Array, + ): Promise<{ version: number }>; + + /** + * Appends content to streaming data. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataName - The data name + * @param content - The content to append + */ + append( + type: ModelType, + modelId: string, + dataName: string, + content: Uint8Array, + ): Promise; + + /** + * Streams content from data. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataName - The data name + * @param version - Optional version (defaults to latest) + * @returns Async iterable of content chunks + */ + stream( + type: ModelType, + modelId: string, + dataName: string, + version?: number, + ): AsyncIterable; + + /** + * Gets the full content of data. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataName - The data name + * @param version - Optional version (defaults to latest) + * @returns The content or null if not found + */ + getContent( + type: ModelType, + modelId: string, + dataName: string, + version?: number, + ): Promise; + + /** + * Deletes data, optionally for a specific version. + * If no version is specified, deletes all versions. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataName - The data name + * @param version - Optional version to delete (all versions if not specified) + */ + delete( + type: ModelType, + modelId: string, + dataName: string, + version?: number, + ): Promise; + + /** + * Removes the latest symlink for expired data (soft delete). + * Version directories remain on disk but data becomes inaccessible. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataName - The data name + */ + removeLatestMarker( + type: ModelType, + modelId: string, + dataName: string, + ): Promise; + + /** + * Allocates a new version directory without writing content. + * Used by DataWriter for direct file I/O. + * + * @param type - The model type + * @param modelId - The model input ID + * @param data - The data entity (for ownership validation) + * @returns The allocated version number and content file path + */ + allocateVersion( + type: ModelType, + modelId: string, + data: Data, + ): Promise<{ version: number; contentPath: string }>; + + /** + * Finalizes a previously allocated version by writing metadata and updating symlinks. + * Content must already exist on disk at the content path. + * + * @param type - The model type + * @param modelId - The model input ID + * @param data - The data entity + * @param version - The version number to finalize + * @returns Size and checksum of the content + */ + finalizeVersion( + type: ModelType, + modelId: string, + data: Data, + version: number, + ): Promise<{ size: number; checksum: string }>; + + /** + * Generates a new unique ID. + */ + nextId(): DataId; + + /** + * Returns the directory path for a data version. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataName - The data name + * @param version - The version number + * @returns The directory path + */ + getPath( + type: ModelType, + modelId: string, + dataName: string, + version: number, + ): string; + + /** + * Returns the content file path for a data version. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataName - The data name + * @param version - The version number + * @returns The content file path + */ + getContentPath( + type: ModelType, + modelId: string, + dataName: string, + version: number, + ): string; + + /** + * Collects garbage according to each data's garbage collection policy. + * + * When `options.dryRun` is true, no versions are deleted — the returned + * `versionsRemoved` and `bytesReclaimed` reflect what would be removed. + * + * @param type - The model type + * @param modelId - The model input ID + * @param options - Options for the operation + * @returns The result of garbage collection + */ + collectGarbage( + type: ModelType, + modelId: string, + options?: { dryRun?: boolean }, + ): Promise; + + /** + * Renames a data instance by copying the latest version to a new name + * and writing a tombstone with a forward reference on the old name. + * + * @param type - The model type + * @param modelId - The model input ID + * @param oldName - The current data name + * @param newName - The new data name + * @returns The rename result with version info + */ + rename( + type: ModelType, + modelId: string, + oldName: string, + newName: string, + ): Promise< + { + oldName: string; + newName: string; + copiedVersion: number; + newVersion: number; + } + >; + + // --- Sync read methods (for CEL expression evaluation) --- + + /** + * Gets the latest version number synchronously. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataName - The data name + * @returns The latest version number, or null if not found + */ + getLatestVersionSync( + type: ModelType, + modelId: string, + dataName: string, + ): number | null; + + /** + * Finds data by name synchronously, optionally for a specific version. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataName - The data name + * @param version - Optional version (defaults to latest) + * @returns The data if found, or null + */ + findByNameSync( + type: ModelType, + modelId: string, + dataName: string, + version?: number, + ): Data | null; + + /** + * Lists all versions for a data name synchronously. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataName - The data name + * @returns Array of version numbers in ascending order + */ + listVersionsSync( + type: ModelType, + modelId: string, + dataName: string, + ): number[]; + + /** + * Gets the full content of data synchronously. + * + * @param type - The model type + * @param modelId - The model input ID + * @param dataName - The data name + * @param version - Optional version (defaults to latest) + * @returns The content or null if not found + */ + getContentSync( + type: ModelType, + modelId: string, + dataName: string, + version?: number, + ): Uint8Array | null; + + /** + * Finds all data for a model synchronously. + * + * @param type - The model type + * @param modelId - The model input ID + * @returns Array of data (latest version of each) + */ + findAllForModelSync(type: ModelType, modelId: string): Data[]; + + /** + * Finds all data across all model types and models synchronously. + * Used by DataQueryService for catalog backfill in sync contexts. + * + * @returns Array of data with their model type and model ID + */ + findAllGlobalSync(): Array< + { data: Data; modelType: ModelType; modelId: string } + >; +} diff --git a/src/domain/drivers/raw_execution_driver_test.ts b/src/domain/drivers/raw_execution_driver_test.ts index 96b4d5d3..3e88f460 100644 --- a/src/domain/drivers/raw_execution_driver_test.ts +++ b/src/domain/drivers/raw_execution_driver_test.ts @@ -30,7 +30,7 @@ import type { ModelDefinition, } from "../models/model.ts"; import { z } from "zod"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; import type { DefinitionRepository } from "../definitions/repositories.ts"; import { type DataId, generateDataId } from "../data/data_id.ts"; import { getLogger } from "@logtape/logtape"; diff --git a/src/domain/expressions/model_resolver.ts b/src/domain/expressions/model_resolver.ts index 4b6502d4..6d7888a1 100644 --- a/src/domain/expressions/model_resolver.ts +++ b/src/domain/expressions/model_resolver.ts @@ -22,7 +22,7 @@ import type { ModelType } from "../models/model_type.ts"; import type { Definition, InputsSchema } from "../definitions/definition.ts"; import type { YamlOutputRepository } from "../../infrastructure/persistence/yaml_output_repository.ts"; import type { YamlDefinitionRepository } from "../../infrastructure/persistence/yaml_definition_repository.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; import type { Data } from "../data/data.ts"; import type { DataRecord } from "../data/data_record.ts"; import type { DataQueryService } from "../data/data_query_service.ts"; diff --git a/src/domain/models/command/shell/shell_model_test.ts b/src/domain/models/command/shell/shell_model_test.ts index 25eb777d..fa15fd27 100644 --- a/src/domain/models/command/shell/shell_model_test.ts +++ b/src/domain/models/command/shell/shell_model_test.ts @@ -28,7 +28,7 @@ import { shellModel, } from "./shell_model.ts"; import type { DataHandle, DataWriter, MethodContext } from "../../model.ts"; -import type { UnifiedDataRepository } from "../../../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../../../data/repositories.ts"; import type { DefinitionRepository } from "../../../definitions/repositories.ts"; import { type DataId, generateDataId } from "../../../data/data_id.ts"; import { getLogger } from "@logtape/logtape"; diff --git a/src/domain/models/data_writer.ts b/src/domain/models/data_writer.ts index 3acddfd5..9334f87d 100644 --- a/src/domain/models/data_writer.ts +++ b/src/domain/models/data_writer.ts @@ -21,7 +21,7 @@ import { z } from "zod"; import { getLogger } from "@logtape/logtape"; import { Data, isReservedDataName } from "../data/mod.ts"; import type { DataId, OwnerDefinition } from "../data/mod.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; import type { ModelType } from "./model_type.ts"; import type { MethodExecutionEvent } from "./method_events.ts"; import type { diff --git a/src/domain/models/data_writer_test.ts b/src/domain/models/data_writer_test.ts index 8a75704c..9a6fd4ac 100644 --- a/src/domain/models/data_writer_test.ts +++ b/src/domain/models/data_writer_test.ts @@ -29,7 +29,7 @@ import { } from "./data_writer.ts"; import { ModelType } from "./model_type.ts"; import type { ResourceOutputSpec } from "./model.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; import { generateDataId } from "../data/data_id.ts"; import { VaultService } from "../vaults/vault_service.ts"; diff --git a/src/domain/models/method_context_test.ts b/src/domain/models/method_context_test.ts index d5793c69..53a56058 100644 --- a/src/domain/models/method_context_test.ts +++ b/src/domain/models/method_context_test.ts @@ -31,7 +31,7 @@ import type { DefinitionRepository } from "../definitions/repositories.ts"; import type { OutputRepository } from "./repositories.ts"; import type { VaultService } from "../vaults/vault_service.ts"; import type { SecretRedactor } from "../secrets/mod.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; function makeCommon( overrides: Partial = {}, diff --git a/src/domain/models/method_execution_service_test.ts b/src/domain/models/method_execution_service_test.ts index 5a4d55f2..a06d46bb 100644 --- a/src/domain/models/method_execution_service_test.ts +++ b/src/domain/models/method_execution_service_test.ts @@ -29,7 +29,7 @@ import type { ModelDefinition, } from "./model.ts"; import { z } from "zod"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; import type { DefinitionRepository } from "../definitions/repositories.ts"; import { type DataId, generateDataId } from "../data/data_id.ts"; import { Data } from "../data/data.ts"; diff --git a/src/domain/models/model.ts b/src/domain/models/model.ts index 01b00174..1c0f5655 100644 --- a/src/domain/models/model.ts +++ b/src/domain/models/model.ts @@ -38,7 +38,7 @@ import { LifetimeSchema, type OwnerDefinition, } from "../data/mod.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; import type { OutputRepository } from "./repositories.ts"; import type { DataRecord } from "../data/data_record.ts"; diff --git a/src/domain/models/model_test.ts b/src/domain/models/model_test.ts index 1b946132..c5da77c5 100644 --- a/src/domain/models/model_test.ts +++ b/src/domain/models/model_test.ts @@ -32,7 +32,7 @@ import { } from "./model.ts"; import { ModelType } from "./model_type.ts"; import { createDefinitionId } from "../definitions/definition.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; import type { DefinitionRepository } from "../definitions/repositories.ts"; import { type DataId, generateDataId } from "../data/data_id.ts"; import { getLogger } from "@logtape/logtape"; diff --git a/src/domain/models/user_model_loader_test.ts b/src/domain/models/user_model_loader_test.ts index cbf3edb6..30e69088 100644 --- a/src/domain/models/user_model_loader_test.ts +++ b/src/domain/models/user_model_loader_test.ts @@ -42,7 +42,7 @@ function makeRepoForCatalog( } import type { DataHandle, DataWriter, MethodContext } from "./model.ts"; import type { ModelType } from "./model_type.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; import type { DefinitionRepository } from "../definitions/repositories.ts"; import { type DataId, generateDataId } from "../data/data_id.ts"; import { createDefinitionId } from "../definitions/definition.ts"; diff --git a/src/domain/models/validation_service_test.ts b/src/domain/models/validation_service_test.ts index 02f2b85c..5eaf3fd6 100644 --- a/src/domain/models/validation_service_test.ts +++ b/src/domain/models/validation_service_test.ts @@ -1078,7 +1078,7 @@ Deno.test("validateModel loads lazy types before resolving cross-model reference // ---------- Check Validation Tests ---------- import type { CheckValidationContext } from "./validation_service.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; import { generateDataId } from "../data/data_id.ts"; import { createDefinitionId } from "../definitions/definition.ts"; diff --git a/src/domain/reports/report_context.ts b/src/domain/reports/report_context.ts index d9bb221d..40d2dc29 100644 --- a/src/domain/reports/report_context.ts +++ b/src/domain/reports/report_context.ts @@ -20,7 +20,7 @@ import type { Logger } from "@logtape/logtape"; import type { ModelType } from "../models/model_type.ts"; import type { DataHandle } from "../models/model.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; import type { DefinitionRepository } from "../definitions/repositories.ts"; import { resolveExtensionFile } from "../extensions/extension_file_resolver.ts"; diff --git a/src/domain/reports/report_execution_service.ts b/src/domain/reports/report_execution_service.ts index e86a322c..5851bc61 100644 --- a/src/domain/reports/report_execution_service.ts +++ b/src/domain/reports/report_execution_service.ts @@ -23,7 +23,7 @@ import type { ReportRef, ReportSelection } from "./report_selection.ts"; import type { ReportRegistry } from "./report_registry.ts"; import type { DataHandle } from "../models/model.ts"; import type { ModelType } from "../models/model_type.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; import { DefaultDataWriter } from "../models/data_writer.ts"; import { modelRegistry } from "../models/model.ts"; import { diff --git a/src/domain/workflows/execution_service.ts b/src/domain/workflows/execution_service.ts index 5dd9ba91..5c345702 100644 --- a/src/domain/workflows/execution_service.ts +++ b/src/domain/workflows/execution_service.ts @@ -39,7 +39,7 @@ import type { import { YamlDefinitionRepository } from "../../infrastructure/persistence/yaml_definition_repository.ts"; import type { DefinitionRepository } from "../definitions/repositories.ts"; import type { OutputRepository } from "../models/repositories.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; import type { MethodExecutionService } from "../models/method_execution_service.ts"; import { YamlEvaluatedDefinitionRepository } from "../../infrastructure/persistence/yaml_evaluated_definition_repository.ts"; import { YamlEvaluatedWorkflowRepository } from "../../infrastructure/persistence/yaml_evaluated_workflow_repository.ts"; diff --git a/src/domain/workflows/method_report_runner.ts b/src/domain/workflows/method_report_runner.ts index b91b49c9..eee0e28d 100644 --- a/src/domain/workflows/method_report_runner.ts +++ b/src/domain/workflows/method_report_runner.ts @@ -24,7 +24,7 @@ import { modelRegistry } from "../models/model.ts"; import type { Definition } from "../definitions/definition.ts"; import type { DataArtifactRef } from "../models/model_output.ts"; import type { DefinitionRepository } from "../definitions/repositories.ts"; -import type { UnifiedDataRepository } from "../../infrastructure/persistence/unified_data_repository.ts"; +import type { UnifiedDataRepository } from "../data/repositories.ts"; import { buildOutputSpecs } from "../models/output_spec_builder.ts"; import { executeReports, diff --git a/src/infrastructure/persistence/unified_data_repository.ts b/src/infrastructure/persistence/unified_data_repository.ts index 1801c10f..702814d8 100644 --- a/src/infrastructure/persistence/unified_data_repository.ts +++ b/src/infrastructure/persistence/unified_data_repository.ts @@ -35,390 +35,22 @@ import { import { ModelType } from "../../domain/models/model_type.ts"; import type { MarkDirtyHook } from "../../domain/datastore/datastore_sync_service.ts"; import type { CatalogStore } from "./catalog_store.ts"; +import { + type GarbageCollectionResult, + OwnershipValidationError, + type UnifiedDataRepository, +} from "../../domain/data/repositories.ts"; + +// Re-export domain repository types so existing infra-path importers keep working. +// New domain code should import directly from src/domain/data/repositories.ts. +export { + type GarbageCollectionResult, + OwnershipValidationError, + type UnifiedDataRepository, +}; const logger = getSwampLogger(["data", "repository"]); -/** - * Error thrown when ownership validation fails. - */ -export class OwnershipValidationError extends Error { - constructor( - readonly dataName: string, - readonly existingOwner: { ownerType: string; ownerRef: string }, - readonly newOwner: { ownerType: string; ownerRef: string }, - ) { - super( - `Ownership validation failed for "${dataName}": ` + - `existing owner "${existingOwner.ownerType}:${existingOwner.ownerRef}" ` + - `does not match new owner "${newOwner.ownerType}:${newOwner.ownerRef}"`, - ); - this.name = "OwnershipValidationError"; - } -} - -/** - * Result of garbage collection operation. - */ -export interface GarbageCollectionResult { - versionsRemoved: number; - bytesReclaimed: number; -} - -/** - * Repository interface for unified Data storage with versioning. - */ -export interface UnifiedDataRepository { - /** - * Finds all data across all model types and models. - * - * @returns Array of data with their model type and model ID - */ - findAllGlobal(): Promise< - Array<{ data: Data; modelType: ModelType; modelId: string }> - >; - - /** - * Finds data by name, optionally for a specific version. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataName - The data name - * @param version - Optional version (defaults to latest) - * @returns The data if found, or null - */ - findByName( - type: ModelType, - modelId: string, - dataName: string, - version?: number, - ): Promise; - - /** - * Finds data by ID, optionally for a specific version. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataId - The data ID - * @param version - Optional version (defaults to latest) - * @returns The data if found, or null - */ - findById( - type: ModelType, - modelId: string, - dataId: DataId, - version?: number, - ): Promise; - - /** - * Lists all versions for a data name. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataName - The data name - * @returns Array of version numbers in ascending order - */ - listVersions( - type: ModelType, - modelId: string, - dataName: string, - ): Promise; - - /** - * Finds all data for a model. - * - * @param type - The model type - * @param modelId - The model input ID - * @returns Array of data (latest version of each) - */ - findAllForModel(type: ModelType, modelId: string): Promise; - - /** - * Saves data with its content, creating a new version. - * Validates ownership if data with the same name already exists. - * - * @param type - The model type - * @param modelId - The model input ID - * @param data - The data entity - * @param content - The content to save - * @returns The saved version number - * @throws OwnershipValidationError if ownership validation fails - */ - save( - type: ModelType, - modelId: string, - data: Data, - content: Uint8Array, - ): Promise<{ version: number }>; - - /** - * Appends content to streaming data. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataName - The data name - * @param content - The content to append - */ - append( - type: ModelType, - modelId: string, - dataName: string, - content: Uint8Array, - ): Promise; - - /** - * Streams content from data. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataName - The data name - * @param version - Optional version (defaults to latest) - * @returns Async iterable of content chunks - */ - stream( - type: ModelType, - modelId: string, - dataName: string, - version?: number, - ): AsyncIterable; - - /** - * Gets the full content of data. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataName - The data name - * @param version - Optional version (defaults to latest) - * @returns The content or null if not found - */ - getContent( - type: ModelType, - modelId: string, - dataName: string, - version?: number, - ): Promise; - - /** - * Deletes data, optionally for a specific version. - * If no version is specified, deletes all versions. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataName - The data name - * @param version - Optional version to delete (all versions if not specified) - */ - delete( - type: ModelType, - modelId: string, - dataName: string, - version?: number, - ): Promise; - - /** - * Removes the latest symlink for expired data (soft delete). - * Version directories remain on disk but data becomes inaccessible. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataName - The data name - */ - removeLatestMarker( - type: ModelType, - modelId: string, - dataName: string, - ): Promise; - - /** - * Allocates a new version directory without writing content. - * Used by DataWriter for direct file I/O. - * - * @param type - The model type - * @param modelId - The model input ID - * @param data - The data entity (for ownership validation) - * @returns The allocated version number and content file path - */ - allocateVersion( - type: ModelType, - modelId: string, - data: Data, - ): Promise<{ version: number; contentPath: string }>; - - /** - * Finalizes a previously allocated version by writing metadata and updating symlinks. - * Content must already exist on disk at the content path. - * - * @param type - The model type - * @param modelId - The model input ID - * @param data - The data entity - * @param version - The version number to finalize - * @returns Size and checksum of the content - */ - finalizeVersion( - type: ModelType, - modelId: string, - data: Data, - version: number, - ): Promise<{ size: number; checksum: string }>; - - /** - * Generates a new unique ID. - */ - nextId(): DataId; - - /** - * Returns the directory path for a data version. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataName - The data name - * @param version - The version number - * @returns The directory path - */ - getPath( - type: ModelType, - modelId: string, - dataName: string, - version: number, - ): string; - - /** - * Returns the content file path for a data version. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataName - The data name - * @param version - The version number - * @returns The content file path - */ - getContentPath( - type: ModelType, - modelId: string, - dataName: string, - version: number, - ): string; - - /** - * Collects garbage according to each data's garbage collection policy. - * - * When `options.dryRun` is true, no versions are deleted — the returned - * `versionsRemoved` and `bytesReclaimed` reflect what would be removed. - * - * @param type - The model type - * @param modelId - The model input ID - * @param options - Options for the operation - * @returns The result of garbage collection - */ - collectGarbage( - type: ModelType, - modelId: string, - options?: { dryRun?: boolean }, - ): Promise; - - /** - * Renames a data instance by copying the latest version to a new name - * and writing a tombstone with a forward reference on the old name. - * - * @param type - The model type - * @param modelId - The model input ID - * @param oldName - The current data name - * @param newName - The new data name - * @returns The rename result with version info - */ - rename( - type: ModelType, - modelId: string, - oldName: string, - newName: string, - ): Promise< - { - oldName: string; - newName: string; - copiedVersion: number; - newVersion: number; - } - >; - - // --- Sync read methods (for CEL expression evaluation) --- - - /** - * Gets the latest version number synchronously. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataName - The data name - * @returns The latest version number, or null if not found - */ - getLatestVersionSync( - type: ModelType, - modelId: string, - dataName: string, - ): number | null; - - /** - * Finds data by name synchronously, optionally for a specific version. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataName - The data name - * @param version - Optional version (defaults to latest) - * @returns The data if found, or null - */ - findByNameSync( - type: ModelType, - modelId: string, - dataName: string, - version?: number, - ): Data | null; - - /** - * Lists all versions for a data name synchronously. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataName - The data name - * @returns Array of version numbers in ascending order - */ - listVersionsSync( - type: ModelType, - modelId: string, - dataName: string, - ): number[]; - - /** - * Gets the full content of data synchronously. - * - * @param type - The model type - * @param modelId - The model input ID - * @param dataName - The data name - * @param version - Optional version (defaults to latest) - * @returns The content or null if not found - */ - getContentSync( - type: ModelType, - modelId: string, - dataName: string, - version?: number, - ): Uint8Array | null; - - /** - * Finds all data for a model synchronously. - * - * @param type - The model type - * @param modelId - The model input ID - * @returns Array of data (latest version of each) - */ - findAllForModelSync(type: ModelType, modelId: string): Data[]; - - /** - * Finds all data across all model types and models synchronously. - * Used by DataQueryService for catalog backfill in sync contexts. - * - * @returns Array of data with their model type and model ID - */ - findAllGlobalSync(): Array< - { data: Data; modelType: ModelType; modelId: string } - >; -} - /** * File system implementation of UnifiedDataRepository. * diff --git a/src/infrastructure/persistence/unified_data_repository_re_export_test.ts b/src/infrastructure/persistence/unified_data_repository_re_export_test.ts new file mode 100644 index 00000000..3689a7dd --- /dev/null +++ b/src/infrastructure/persistence/unified_data_repository_re_export_test.ts @@ -0,0 +1,42 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { assert, assertStrictEquals } from "@std/assert"; +import { OwnershipValidationError as DomainOwnershipValidationError } from "../../domain/data/repositories.ts"; +import { OwnershipValidationError as InfraOwnershipValidationError } from "./unified_data_repository.ts"; + +// Locks the value re-export contract. UnifiedDataRepository's canonical home is +// the domain layer; the infra module re-exports the symbols so existing +// importers keep working. A future regression that converts this to a type-only +// re-export, or duplicates the class definition, would silently break +// `instanceof` checks across the two import paths — these tests catch that. + +Deno.test("OwnershipValidationError class identity is preserved across the re-export", () => { + assertStrictEquals( + DomainOwnershipValidationError, + InfraOwnershipValidationError, + ); +}); + +Deno.test("OwnershipValidationError instances are caught by instanceof against either import path", () => { + const owner = { ownerType: "model-method", ownerRef: "x:y" }; + const thrown = new InfraOwnershipValidationError("name", owner, owner); + assert(thrown instanceof DomainOwnershipValidationError); + assert(thrown instanceof InfraOwnershipValidationError); +});