diff --git a/appinfo/info.xml b/appinfo/info.xml index baf7f299..0e8a8675 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -83,13 +83,13 @@ Vrij en open source onder de EUPL-1.2-licentie. OCA\OpenBuilt\Repair\InitializeSettings - OCA\OpenBuilt\Repair\SeedHelloWorld + OCA\OpenBuilt\Repair\MigrateToVersionedModel OCA\OpenBuilt\Repair\PopulateApplicationPermissions OCA\OpenBuilt\Repair\SeedApplicationTemplates OCA\OpenBuilt\Repair\InitializeSettings - OCA\OpenBuilt\Repair\SeedHelloWorld + OCA\OpenBuilt\Repair\MigrateToVersionedModel OCA\OpenBuilt\Repair\PopulateApplicationPermissions OCA\OpenBuilt\Repair\SeedApplicationTemplates diff --git a/appinfo/routes.php b/appinfo/routes.php index fd15b5cf..3d9052be 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -45,6 +45,18 @@ // Specific route MUST precede the SPA catch-all (memory rule: Symfony specific-first). ['name' => 'applications#diffVersions', 'url' => '/api/applications/{slug}/versions/diff', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + // ApplicationVersion CRUD + strategy-aware delete (spec + // `application-versions` REQ-OBV-107 / REQ-OBV-108 of + // openbuilt-versioning-model). Specific routes MUST precede the + // SPA catch-all to win Symfony's order-sensitive router (memory + // rule: specific-first). The `/diff` route above stays first + // because its URL is more specific than `{versionSlug}`. + ['name' => 'applicationVersions#index', 'url' => '/api/applications/{slug}/versions', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + ['name' => 'applicationVersions#create', 'url' => '/api/applications/{slug}/versions', 'verb' => 'POST', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + ['name' => 'applicationVersions#show', 'url' => '/api/applications/{slug}/versions/{versionSlug}', 'verb' => 'GET', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]', 'versionSlug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + ['name' => 'applicationVersions#update', 'url' => '/api/applications/{slug}/versions/{versionSlug}', 'verb' => 'PUT', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]', 'versionSlug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + ['name' => 'applicationVersions#destroy', 'url' => '/api/applications/{slug}/versions/{versionSlug}', 'verb' => 'DELETE', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]', 'versionSlug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], + // Export pipeline (Phase-2 graduation). ['name' => 'exports#submit', 'url' => '/api/applications/{slug}/exports', 'verb' => 'POST', 'requirements' => ['slug' => '[a-z0-9][a-z0-9-]*[a-z0-9]']], ['name' => 'exports#download', 'url' => '/api/exports/{uuid}/download', 'verb' => 'GET'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index ce2f30eb..fabeac6c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -24,11 +24,12 @@ namespace OCA\OpenBuilt\AppInfo; -use OCA\OpenBuilt\Listener\ApplicationVersionSnapshotListener; +use OCA\OpenBuilt\Listener\ProductionVersionGuardListener; use OCA\OpenBuilt\Listener\DeepLinkRegistrationListener; use OCA\OpenBuilt\Mcp\OpenBuiltToolProvider; use OCA\OpenRegister\Event\DeepLinkRegistrationEvent; -use OCA\OpenRegister\Event\ObjectTransitionedEvent; +use OCA\OpenRegister\Event\ObjectCreatingEvent; +use OCA\OpenRegister\Event\ObjectUpdatingEvent; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -69,13 +70,25 @@ public function register(IRegistrationContext $context): void listener: DeepLinkRegistrationListener::class ); - // Snapshot the Application's manifest into ApplicationVersion on - // draft→published transitions (chain spec #6 openbuilt-versioning, - // ADR-031 §Exceptions(1) — declarative-first fallback because OR's - // engine does not yet execute on_transition.create_relation). + // Per ADR-002 the snapshot-on-publish writeback listener has been + // retired. ApplicationVersion is now a first-class long-lived row, + // not an append-only snapshot, and `Application.currentVersion` has + // been removed in favour of an explicit `productionVersion` relation + // set by the admin. Object time-travel on the ApplicationVersion row + // captures audit history. The corresponding spec retirement lives + // in openbuilt-versioning-model/specs/openbuilt-version-snapshots. + // Cross-row integrity guard: on every Application save (create or + // update), verify that `productionVersion` (when set) points at an + // ApplicationVersion whose `application` relation refers back to + // this Application (ADR-031 §Exceptions(1) — cross-row validation + // that OR's per-row x-openregister-validation cannot perform). $context->registerEventListener( - event: ObjectTransitionedEvent::class, - listener: ApplicationVersionSnapshotListener::class + event: ObjectCreatingEvent::class, + listener: ProductionVersionGuardListener::class + ); + $context->registerEventListener( + event: ObjectUpdatingEvent::class, + listener: ProductionVersionGuardListener::class ); // Register OpenBuiltToolProvider as the MCP tool provider for the AI Chat Companion. @@ -88,7 +101,7 @@ public function register(IRegistrationContext $context): void OpenBuiltToolProvider::class ); - // Repair steps (InitializeSettings + SeedHelloWorld) are declared in info.xml. + // Repair steps (InitializeSettings + MigrateToVersionedModel + …) are declared in info.xml. }//end register() /** diff --git a/lib/Controller/ApplicationVersionsController.php b/lib/Controller/ApplicationVersionsController.php new file mode 100644 index 00000000..6e1f75b5 --- /dev/null +++ b/lib/Controller/ApplicationVersionsController.php @@ -0,0 +1,669 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Controller; + +use OCA\OpenBuilt\AppInfo\Application; +use OCA\OpenBuilt\Service\ApplicationVersionService; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\ObjectService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Controller serving the ApplicationVersion CRUD + strategy-delete surface. + */ +class ApplicationVersionsController extends Controller +{ + /** + * Nextcloud admin group identifier used as the RBAC bypass anchor. + */ + private const ADMIN_GROUP = 'admin'; + + /** + * Roles that grant write access to ApplicationVersion rows. + * + * @var array + */ + private const WRITE_ROLES = ['owners', 'editors']; + + /** + * Roles that grant read access to ApplicationVersion rows. + * + * @var array + */ + private const READ_ROLES = ['owners', 'editors', 'viewers']; + + /** + * Constructor. + * + * @param IRequest $request The current HTTP request + * @param LoggerInterface $logger PSR logger for diagnostics + * @param ObjectService $objectService OpenRegister object service + * @param RegisterMapper $registerMapper Resolves register slugs + * @param SchemaMapper $schemaMapper Resolves schema slugs + * @param IUserSession $userSession Current Nextcloud user session + * @param IGroupManager $groupManager Group membership resolver + * @param ApplicationVersionService $versionService Owner of the imperative logic + * + * @return void + */ + public function __construct( + IRequest $request, + private readonly LoggerInterface $logger, + private readonly ObjectService $objectService, + private readonly RegisterMapper $registerMapper, + private readonly SchemaMapper $schemaMapper, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly ApplicationVersionService $versionService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * List ApplicationVersions for the named Application (spec REQ-OBV-107). + * + * @param string $slug Parent Application slug + * + * @return JSONResponse Versions array on 200, error envelope on miss + */ + #[NoAdminRequired] + public function index(string $slug): JSONResponse + { + $authError = $this->requireRole(slug: $slug, roles: self::READ_ROLES); + if ($authError !== null) { + return $authError; + } + + try { + $application = $this->loadApplication(slug: $slug); + if ($application === null) { + return $this->errorResponse(code: 'not_found', detail: 'Application '.$slug.' not found', status: Http::STATUS_NOT_FOUND); + } + + $applicationUuid = (string) ($application['id'] ?? $application['uuid'] ?? ''); + $registerId = $this->registerMapper->find( + ApplicationVersionService::REGISTER_SLUG, + _multitenancy: false + )->getId(); + $schemaId = $this->schemaMapper->find( + ApplicationVersionService::APPLICATION_VERSION_SCHEMA, + _multitenancy: false + )->getId(); + + $rows = $this->objectService->searchObjects( + query: [ + '@self' => [ + 'register' => $registerId, + 'schema' => $schemaId, + ], + 'application' => $applicationUuid, + ] + ); + + $rowsList = []; + if (is_array($rows) === true) { + $rowsList = $rows; + } + + $normalised = array_map( + fn ($row): array => $this->normaliseObject(object: $row), + $rowsList + ); + + return new JSONResponse(data: $normalised, statusCode: Http::STATUS_OK); + } catch (Throwable $e) { + $this->logger->error( + 'OpenBuilt: ApplicationVersionsController::index failed for slug '.$slug.': '.$e->getMessage(), + ['exception' => $e] + ); + return $this->errorResponse(code: 'internal_error', detail: 'Failed to load versions'); + }//end try + }//end index() + + /** + * Fetch a single ApplicationVersion by version slug (spec REQ-OBV-107). + * + * @param string $slug Parent Application slug + * @param string $versionSlug ApplicationVersion slug + * + * @return JSONResponse The version on 200, error envelope on miss + */ + #[NoAdminRequired] + public function show(string $slug, string $versionSlug): JSONResponse + { + $authError = $this->requireRole(slug: $slug, roles: self::READ_ROLES); + if ($authError !== null) { + return $authError; + } + + $version = $this->findVersionForApplication(slug: $slug, versionSlug: $versionSlug); + if ($version === null) { + return $this->errorResponse(code: 'not_found', detail: $versionSlug, status: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse(data: $version, statusCode: Http::STATUS_OK); + }//end show() + + /** + * Create an ApplicationVersion under the named Application (spec REQ-OBV-107 / REQ-OBV-102). + * + * @param string $slug Parent Application slug + * + * @return JSONResponse 201 with the created version, or error envelope + */ + #[NoAdminRequired] + public function create(string $slug): JSONResponse + { + $authError = $this->requireRole(slug: $slug, roles: self::WRITE_ROLES); + if ($authError !== null) { + return $authError; + } + + try { + $application = $this->loadApplication(slug: $slug); + if ($application === null) { + return $this->errorResponse(code: 'not_found', detail: 'Application '.$slug.' not found', status: Http::STATUS_NOT_FOUND); + } + + $applicationUuid = (string) ($application['id'] ?? $application['uuid'] ?? ''); + $payload = $this->collectPayload(); + + // Strip any client-supplied UUID — OR mints its own on create. + unset($payload['id'], $payload['uuid'], $payload['@self']); + // Honour the back-reference even if the client forgot to send it. + $payload['application'] = $applicationUuid; + + $payload = $this->versionService->onSave(current: null, next: $payload); + + $promotesTo = (string) ($payload['promotesTo'] ?? ''); + if ($promotesTo !== '') { + // Cycle guard requires a uuid; for a brand-new row use a stable + // placeholder string that cannot occur in OR's actual UUID space. + $this->versionService->guardNoCycle( + currentUuid: '__pending_create__', + proposedTargetUuid: $promotesTo + ); + } + + $created = $this->objectService->saveObject( + object: $payload, + register: ApplicationVersionService::REGISTER_SLUG, + schema: ApplicationVersionService::APPLICATION_VERSION_SCHEMA + ); + + return new JSONResponse( + data: $this->normaliseObject(object: $created), + statusCode: Http::STATUS_CREATED + ); + } catch (Throwable $e) { + $this->logger->error( + 'OpenBuilt: ApplicationVersionsController::create failed for slug '.$slug.': '.$e->getMessage(), + ['exception' => $e] + ); + return $this->errorResponse( + code: 'create_failed', + detail: $e->getMessage(), + status: Http::STATUS_UNPROCESSABLE_ENTITY + ); + }//end try + }//end create() + + /** + * Update an ApplicationVersion (spec REQ-OBV-103 / REQ-OBV-104 / REQ-OBV-107). + * + * @param string $slug Parent Application slug + * @param string $versionSlug ApplicationVersion slug + * + * @return JSONResponse 200 with the updated version, or error envelope + */ + #[NoAdminRequired] + public function update(string $slug, string $versionSlug): JSONResponse + { + $authError = $this->requireRole(slug: $slug, roles: self::WRITE_ROLES); + if ($authError !== null) { + return $authError; + } + + try { + $current = $this->findVersionForApplication(slug: $slug, versionSlug: $versionSlug); + if ($current === null) { + return $this->errorResponse(code: 'not_found', detail: $versionSlug, status: Http::STATUS_NOT_FOUND); + } + + $currentUuid = (string) ($current['id'] ?? $current['uuid'] ?? ''); + $payload = array_merge($current, $this->collectPayload()); + unset($payload['@self']); + + // Preserve immutable fields. + $payload['application'] = $current['application'] ?? null; + $payload['id'] = $currentUuid; + + // Cycle guard on cross-row. + $proposedPromotesTo = $payload['promotesTo'] ?? null; + if (is_string($proposedPromotesTo) === true) { + $cycleTarget = $proposedPromotesTo; + if ($cycleTarget === '') { + $cycleTarget = null; + } + + $this->versionService->guardNoCycle( + currentUuid: $currentUuid, + proposedTargetUuid: $cycleTarget + ); + } + + $payload = $this->versionService->onSave(current: $current, next: $payload); + + $updated = $this->objectService->saveObject( + object: $payload, + register: ApplicationVersionService::REGISTER_SLUG, + schema: ApplicationVersionService::APPLICATION_VERSION_SCHEMA, + uuid: $currentUuid + ); + + return new JSONResponse( + data: $this->normaliseObject(object: $updated), + statusCode: Http::STATUS_OK + ); + } catch (Throwable $e) { + $this->logger->error( + 'OpenBuilt: ApplicationVersionsController::update failed for slug '.$slug.'/'.$versionSlug.': '.$e->getMessage(), + ['exception' => $e] + ); + return $this->errorResponse( + code: 'update_failed', + detail: $e->getMessage(), + status: Http::STATUS_UNPROCESSABLE_ENTITY + ); + }//end try + }//end update() + + /** + * Delete an ApplicationVersion using the requested strategy (spec REQ-OBV-108). + * + * Accepts the `strategy` query parameter (`delete-now | + * orphan-grace | keep-register`). Missing/unknown values yield 400. + * Attempts to delete the parent Application's production version + * yield 422. + * + * @param string $slug Parent Application slug + * @param string $versionSlug ApplicationVersion slug + * + * @return JSONResponse 204 on success, error envelope otherwise + */ + #[NoAdminRequired] + public function destroy(string $slug, string $versionSlug): JSONResponse + { + $authError = $this->requireRole(slug: $slug, roles: self::WRITE_ROLES); + if ($authError !== null) { + return $authError; + } + + $strategy = (string) $this->request->getParam('strategy', ''); + if ($strategy === '') { + return $this->errorResponse( + code: 'missing_strategy', + detail: 'Query parameter `strategy` is required (delete-now | orphan-grace | keep-register).', + status: Http::STATUS_BAD_REQUEST + ); + } + + try { + $current = $this->findVersionForApplication(slug: $slug, versionSlug: $versionSlug); + if ($current === null) { + return $this->errorResponse(code: 'not_found', detail: $versionSlug, status: Http::STATUS_NOT_FOUND); + } + + $currentUuid = (string) ($current['id'] ?? $current['uuid'] ?? ''); + + $this->versionService->deleteVersion(versionUuid: $currentUuid, strategy: $strategy); + + return new JSONResponse(data: [], statusCode: Http::STATUS_NO_CONTENT); + } catch (Throwable $e) { + $this->logger->info( + 'OpenBuilt: ApplicationVersionsController::destroy refused for slug '.$slug.'/'.$versionSlug.': '.$e->getMessage() + ); + + $message = $e->getMessage(); + $status = Http::STATUS_UNPROCESSABLE_ENTITY; + $code = 'delete_failed'; + if (str_contains($message, 'Unknown deletion strategy') === true) { + $status = Http::STATUS_BAD_REQUEST; + $code = 'invalid_strategy'; + } + + return $this->errorResponse(code: $code, detail: $message, status: $status); + }//end try + }//end destroy() + + /** + * Resolve the parent Application by slug, returning a normalised array. + * + * @param string $slug Parent Application slug + * + * @return array|null Application record or null when missing + */ + private function loadApplication(string $slug): ?array + { + $registerId = $this->registerMapper->find( + ApplicationVersionService::REGISTER_SLUG, + _multitenancy: false + )->getId(); + $schemaId = $this->schemaMapper->find( + ApplicationVersionService::APPLICATION_SCHEMA, + _multitenancy: false + )->getId(); + + $rows = $this->objectService->searchObjects( + query: [ + '@self' => [ + 'register' => $registerId, + 'schema' => $schemaId, + ], + 'slug' => $slug, + ] + ); + + if (is_array($rows) === false || $rows === []) { + return null; + } + + return $this->normaliseObject(object: $rows[0]); + }//end loadApplication() + + /** + * Resolve an ApplicationVersion by version slug, scoped to the parent Application. + * + * Returns null when either the Application or the version is missing, + * or when the version's `application` relation does not back-reference + * this Application (IDOR-safe). + * + * @param string $slug Parent Application slug + * @param string $versionSlug ApplicationVersion slug + * + * @return array|null Version record or null on miss + */ + private function findVersionForApplication(string $slug, string $versionSlug): ?array + { + $application = $this->loadApplication(slug: $slug); + if ($application === null) { + return null; + } + + $applicationUuid = (string) ($application['id'] ?? $application['uuid'] ?? ''); + + $registerId = $this->registerMapper->find( + ApplicationVersionService::REGISTER_SLUG, + _multitenancy: false + )->getId(); + $schemaId = $this->schemaMapper->find( + ApplicationVersionService::APPLICATION_VERSION_SCHEMA, + _multitenancy: false + )->getId(); + + $rows = $this->objectService->searchObjects( + query: [ + '@self' => [ + 'register' => $registerId, + 'schema' => $schemaId, + ], + 'slug' => $versionSlug, + 'application' => $applicationUuid, + ] + ); + + if (is_array($rows) === false || $rows === []) { + return null; + } + + return $this->normaliseObject(object: $rows[0]); + }//end findVersionForApplication() + + /** + * Verify the current user has any of the named roles on the parent Application. + * + * Admin callers pass via the bypass (see ApplicationsController for the + * audited variant — this controller's bypass surfaces only in the logs). + * + * @param string $slug Parent Application slug + * @param array $roles List of role names (`owners`, `editors`, `viewers`) + * + * @return JSONResponse|null Null on allow, 401/403/404 envelope on deny + */ + private function requireRole(string $slug, array $roles): ?JSONResponse + { + $user = $this->userSession->getUser(); + if ($user === null) { + return $this->errorResponse(code: 'unauthenticated', status: Http::STATUS_UNAUTHORIZED); + } + + $application = $this->loadApplication(slug: $slug); + if ($application === null) { + return $this->errorResponse( + code: 'not_found', + detail: 'Application '.$slug.' not found', + status: Http::STATUS_NOT_FOUND + ); + } + + if ($this->groupManager->isInGroup($user->getUID(), self::ADMIN_GROUP) === true) { + $this->logger->info( + 'OpenBuilt: rbac.admin_bypass on ApplicationVersions endpoint', + ['actor' => $user->getUID(), 'slug' => $slug, 'roles' => $roles] + ); + return null; + } + + $authorised = $this->collectAuthorisedPrincipals(application: $application, roles: $roles); + if (in_array($user->getUID(), $authorised['users'], true) === true) { + return null; + } + + if (count(array_intersect($this->getUserGroupIds(user: $user), $authorised['groups'])) > 0) { + return null; + } + + return $this->errorResponse( + code: 'openbuilt.rbac.no_role', + status: Http::STATUS_FORBIDDEN + ); + }//end requireRole() + + /** + * Flatten the named role buckets into user / group principal lists. + * + * Mirrors ApplicationsController::collectAuthorisedGroups but accepts + * a role filter so read endpoints can include viewers while write + * endpoints exclude them. + * + * @param array $application The Application data + * @param array $roles Role names to include + * + * @return array{users: array, groups: array} + */ + private function collectAuthorisedPrincipals(array $application, array $roles): array + { + $permissions = ($application['permissions'] ?? []); + if (is_array($permissions) === false) { + return ['users' => [], 'groups' => []]; + } + + $userSet = []; + $groupSet = []; + foreach ($roles as $role) { + $bucket = ($permissions[$role] ?? []); + if (is_array($bucket) === false) { + continue; + } + + $this->absorbPrincipalBucket(bucket: $bucket, userSet: $userSet, groupSet: $groupSet); + } + + return [ + 'users' => array_keys($userSet), + 'groups' => array_keys($groupSet), + ]; + }//end collectAuthorisedPrincipals() + + /** + * Classify a permission-role bucket into user-UID and group-GID sets. + * + * @param array $bucket The raw bucket (owners/editors/viewers entries) + * @param array $userSet Accumulating UID set (passed by reference) + * @param array $groupSet Accumulating GID set (passed by reference) + * + * @return void + */ + private function absorbPrincipalBucket(array $bucket, array &$userSet, array &$groupSet): void + { + foreach ($bucket as $principal) { + if (is_string($principal) === false || $principal === '') { + continue; + } + + if (str_starts_with($principal, 'user:') === true) { + $uid = substr($principal, 5); + if ($uid !== '') { + $userSet[$uid] = true; + } + + continue; + } + + $gid = $principal; + if (str_starts_with($principal, 'group:') === true) { + $gid = substr($principal, 6); + } + + if ($gid !== '') { + $groupSet[$gid] = true; + } + }//end foreach + }//end absorbPrincipalBucket() + + /** + * Read the current user's group GIDs. + * + * @param IUser $user The Nextcloud user + * + * @return array + */ + private function getUserGroupIds(IUser $user): array + { + $groups = $this->groupManager->getUserGroups($user); + $ids = []; + foreach ($groups as $group) { + $ids[] = $group->getGID(); + } + + return $ids; + }//end getUserGroupIds() + + /** + * Read the JSON / form payload from the current request. + * + * @return array + */ + private function collectPayload(): array + { + $params = $this->request->getParams(); + unset($params['_route']); + return $params; + }//end collectPayload() + + /** + * Build a uniform error envelope. + * + * @param string $code Error code + * @param string|null $detail Optional detail message + * @param int $status HTTP status code + * + * @return JSONResponse + */ + private function errorResponse(string $code, ?string $detail=null, int $status=Http::STATUS_BAD_REQUEST): JSONResponse + { + $body = ['error' => $code]; + if ($detail !== null) { + $body['detail'] = $detail; + } + + return new JSONResponse(data: $body, statusCode: $status); + }//end errorResponse() + + /** + * Coerce an OR result entry to a plain associative array. + * + * @param mixed $object The OR object/result entry + * + * @return array + */ + private function normaliseObject(mixed $object): array + { + if (is_array($object) === true) { + return $object; + } + + if (is_object($object) === true && method_exists($object, 'jsonSerialize') === true) { + $serialised = $object->jsonSerialize(); + if (is_array($serialised) === true) { + return $serialised; + } + } + + if (is_object($object) === true && method_exists($object, 'getObject') === true) { + $inner = $object->getObject(); + if (is_array($inner) === true) { + return $inner; + } + } + + return []; + }//end normaliseObject() +}//end class diff --git a/lib/Listener/ApplicationVersionSnapshotListener.php b/lib/Listener/ApplicationVersionSnapshotListener.php deleted file mode 100644 index 9368ed31..00000000 --- a/lib/Listener/ApplicationVersionSnapshotListener.php +++ /dev/null @@ -1,406 +0,0 @@ - - * @copyright 2026 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://conduction.nl - */ - -declare(strict_types=1); - -namespace OCA\OpenBuilt\Listener; - -use OCA\OpenRegister\Event\ObjectTransitionedEvent; -use OCA\OpenRegister\Service\ObjectService; -use OCP\EventDispatcher\Event; -use OCP\EventDispatcher\IEventListener; -use Psr\Log\LoggerInterface; - -/** - * Snapshots an Application's manifest into ApplicationVersion on publish. - * - * @template-implements IEventListener - */ -class ApplicationVersionSnapshotListener implements IEventListener -{ - private const REGISTER_SLUG = 'openbuilt'; - private const APPLICATION_SCHEMA = 'application'; - private const VERSION_SCHEMA = 'application-version'; - private const ROUTE_SCHEMA = 'built-app-route'; - private const PUBLISH_FROM = 'draft'; - private const PUBLISH_TO = 'published'; - private const DEFAULT_PUBLISHED_BY = 'system'; - private const DEFAULT_SNAPSHOT_NOTES = 'Published via lifecycle transition'; - - /** - * Constructor. - * - * @param LoggerInterface $logger PSR logger for diagnostics - * @param ObjectService $objectService OpenRegister object service (hard dep via info.xml) - * - * @return void - */ - public function __construct( - private readonly LoggerInterface $logger, - private readonly ObjectService $objectService, - ) { - }//end __construct() - - /** - * Handle the ObjectTransitionedEvent. - * - * Filters on Application + draft→published, then delegates to - * `snapshotPublish()`. Failures are logged but never thrown — a - * snapshot failure must not block the underlying publish - * transition (it already succeeded by the time this listener runs). - * - * @param Event $event Dispatched event - * - * @return void - */ - public function handle(Event $event): void - { - if ($this->isPublishTransition(event: $event) === false) { - return; - } - - try { - $this->snapshotPublish(event: $event); - } catch (\Throwable $e) { - // Never bubble up — the publish itself already succeeded; a - // failed snapshot must not roll the transition back. - $this->logger->error( - 'OpenBuilt: ApplicationVersionSnapshotListener failed: '.$e->getMessage(), - ['exception' => $e] - ); - } - }//end handle() - - /** - * Filter the incoming event to only the Application draft→published transition. - * - * @param Event $event Dispatched event. - * - * @return bool True when the event matches the snapshot trigger. - */ - private function isPublishTransition(Event $event): bool - { - if ($event instanceof ObjectTransitionedEvent === false) { - return false; - } - - if ($event->getSchema() !== self::APPLICATION_SCHEMA) { - return false; - } - - if ($event->getFrom() !== self::PUBLISH_FROM) { - return false; - } - - if ($event->getTo() !== self::PUBLISH_TO) { - return false; - } - - return true; - }//end isPublishTransition() - - /** - * Create the ApplicationVersion row and writeback Application.currentVersion. - * - * The caller must have passed the event through {@see isPublishTransition()} - * so the cast to ObjectTransitionedEvent is safe. - * - * @param Event $event The (already filtered) publish event. - * - * @return void - */ - private function snapshotPublish(Event $event): void - { - if ($event instanceof ObjectTransitionedEvent === false) { - // Belt-and-braces guard — should never trip thanks to handle()'s filter. - return; - } - - $applicationData = $event->getObject()->jsonSerialize(); - $applicationUuid = $this->extractUuid(data: $applicationData); - - if ($applicationUuid === null) { - $this->logger->warning( - 'OpenBuilt: ApplicationVersionSnapshotListener could not resolve Application UUID; skipping snapshot.' - ); - return; - } - - // Upsert the slug → applicationUuid route so GET /api/applications/{slug}/manifest - // resolves for user-created apps (the on_transition.upsert_relation fallback). - $this->upsertBuiltAppRoute(applicationData: $applicationData, applicationUuid: $applicationUuid); - - $snapshot = $this->createSnapshot(applicationData: $applicationData, applicationUuid: $applicationUuid, event: $event); - $snapshotUuid = $this->extractUuid(data: $this->normaliseSerialised(object: $snapshot)); - - if ($snapshotUuid === null) { - $this->logger->warning( - 'OpenBuilt: ApplicationVersionSnapshotListener created a snapshot but could not read its UUID;' - .' currentVersion not updated.' - ); - return; - } - - $this->updateApplicationCurrentVersion(applicationData: $applicationData, snapshotUuid: $snapshotUuid); - - $this->logger->info( - 'OpenBuilt: snapshotted Application '.$applicationUuid.' as ApplicationVersion '.$snapshotUuid - .' (version '.($applicationData['version'] ?? '0.0.0').').' - ); - }//end snapshotPublish() - - /** - * Find-or-create the Application's BuiltAppRoute (slug → applicationUuid). - * - * Mirrors SeedHelloWorld's explicit route creation — the - * `on_transition.upsert_relation` action declared on the Application - * schema's `publish` transition (ADR-031 §Exceptions(1) fallback). When - * a route already exists for the slug it is updated to point at this - * Application; otherwise a fresh one is created. Failures are logged but - * not thrown (handled by the caller's try/catch). - * - * @param array $applicationData Serialised Application data. - * @param string $applicationUuid Resolved Application UUID. - * - * @return void - */ - private function upsertBuiltAppRoute(array $applicationData, string $applicationUuid): void - { - $slug = $this->extractSlug(data: $applicationData); - if ($slug === null) { - $this->logger->warning( - 'OpenBuilt: ApplicationVersionSnapshotListener could not resolve Application slug;' - .' BuiltAppRoute not upserted.' - ); - return; - } - - $existing = []; - try { - $existing = $this->objectService->searchObjectsBySlug( - registerSlug: self::REGISTER_SLUG, - schemaSlug: self::ROUTE_SCHEMA, - filters: ['slug' => $slug] - ); - } catch (\Throwable $e) { - // A missing register/schema slug makes searchObjectsBySlug() throw — - // treat as "no route yet" and fall through to the create path. - $this->logger->debug( - 'OpenBuilt: BuiltAppRoute lookup for slug '.$slug.' failed ('.$e->getMessage().'); will create.' - ); - } - - if (is_array($existing) === true && empty($existing) === false) { - $route = $this->normaliseSerialised(object: $existing[0]); - if (($route['applicationUuid'] ?? null) === $applicationUuid) { - // Already correct — nothing to do. - return; - } - - $route['slug'] = $slug; - $route['applicationUuid'] = $applicationUuid; - $this->objectService->saveObject( - object: $route, - register: self::REGISTER_SLUG, - schema: self::ROUTE_SCHEMA - ); - $this->logger->info('OpenBuilt: updated BuiltAppRoute '.$slug.' → Application '.$applicationUuid.'.'); - return; - } - - $this->objectService->saveObject( - object: [ - 'slug' => $slug, - 'applicationUuid' => $applicationUuid, - ], - register: self::REGISTER_SLUG, - schema: self::ROUTE_SCHEMA - ); - $this->logger->info('OpenBuilt: created BuiltAppRoute '.$slug.' → Application '.$applicationUuid.'.'); - }//end upsertBuiltAppRoute() - - /** - * Read the Application slug out of an OR-serialised object array. - * - * Looks in top-level `slug`, then `@self.slug`. - * - * @param array $data Serialised object array. - * - * @return string|null The slug or null if not present/blank. - */ - private function extractSlug(array $data): ?string - { - $candidates = []; - if (isset($data['slug']) === true) { - $candidates[] = $data['slug']; - } - - if (isset($data['@self']) === true && is_array($data['@self']) === true && isset($data['@self']['slug']) === true) { - $candidates[] = $data['@self']['slug']; - } - - foreach ($candidates as $candidate) { - $slug = trim((string) $candidate); - if ($slug !== '') { - return $slug; - } - } - - return null; - }//end extractSlug() - - /** - * Save a new ApplicationVersion sibling row carrying a byte-equal manifest copy. - * - * @param array $applicationData Serialised Application data. - * @param string $applicationUuid Resolved Application UUID. - * @param ObjectTransitionedEvent $event The publish event (for actor id). - * - * @return mixed The OR-returned snapshot entity/array. - */ - private function createSnapshot(array $applicationData, string $applicationUuid, ObjectTransitionedEvent $event): mixed - { - $manifest = ($applicationData['manifest'] ?? []); - $version = ($applicationData['version'] ?? '0.0.0'); - $userId = ($event->getUserId() ?? self::DEFAULT_PUBLISHED_BY); - - // OR's standard scoping (organisation + register + schema) applies on - // saveObject so the snapshot inherits the Application's org context. - return $this->objectService->saveObject( - object: [ - 'applicationUuid' => $applicationUuid, - 'version' => $version, - 'manifest' => $manifest, - 'publishedAt' => gmdate('Y-m-d\TH:i:s\Z'), - 'publishedBy' => $userId, - 'notes' => self::DEFAULT_SNAPSHOT_NOTES, - ], - register: self::REGISTER_SLUG, - schema: self::VERSION_SCHEMA - ); - }//end createSnapshot() - - /** - * Writeback — point Application.currentVersion at the new snapshot and reset status. - * - * Design.md Decision 3: persistent status is `draft` when editable; - * "is published right now" is `currentVersion != null`. - * - * @param array $applicationData Serialised Application data. - * @param string $snapshotUuid UUID of the newly created snapshot. - * - * @return void - */ - private function updateApplicationCurrentVersion(array $applicationData, string $snapshotUuid): void - { - $existing = $applicationData; - unset($existing['@self']); - $existing['currentVersion'] = $snapshotUuid; - $existing['status'] = self::PUBLISH_FROM; - - $this->objectService->saveObject( - object: $existing, - register: self::REGISTER_SLUG, - schema: self::APPLICATION_SCHEMA - ); - }//end updateApplicationCurrentVersion() - - /** - * Read the canonical UUID out of an OR-serialised object array. - * - * Looks in `@self.id`, `@self.uuid`, then top-level `uuid`. - * - * @param array $data Serialised object array. - * - * @return string|null The UUID or null if not present. - */ - private function extractUuid(array $data): ?string - { - $self = []; - if (isset($data['@self']) === true && is_array($data['@self']) === true) { - $self = $data['@self']; - } - - if (isset($self['id']) === true) { - return (string) $self['id']; - } - - if (isset($self['uuid']) === true) { - return (string) $self['uuid']; - } - - if (isset($data['uuid']) === true) { - return (string) $data['uuid']; - } - - return null; - }//end extractUuid() - - /** - * Coerce an OR-returned entity/array to a plain associative array. - * - * @param mixed $object The OR object/result entry. - * - * @return array - */ - private function normaliseSerialised(mixed $object): array - { - if (is_array($object) === true) { - return $object; - } - - if (is_object($object) === true && method_exists($object, 'jsonSerialize') === true) { - $serialised = $object->jsonSerialize(); - if (is_array($serialised) === true) { - return $serialised; - } - } - - return []; - }//end normaliseSerialised() -}//end class diff --git a/lib/Listener/ProductionVersionGuardListener.php b/lib/Listener/ProductionVersionGuardListener.php new file mode 100644 index 00000000..8a77ffb3 --- /dev/null +++ b/lib/Listener/ProductionVersionGuardListener.php @@ -0,0 +1,216 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Listener; + +use OCA\OpenBuilt\Service\ApplicationVersionService; +use OCA\OpenRegister\Event\ObjectCreatingEvent; +use OCA\OpenRegister\Event\ObjectUpdatingEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Pre-save integrity guard for `Application.productionVersion`. + * + * @template-implements IEventListener + */ +class ProductionVersionGuardListener implements IEventListener +{ + /** + * Constructor. + * + * @param LoggerInterface $logger PSR logger for diagnostics + * @param ApplicationVersionService $service The cross-row guard owner + * + * @return void + */ + public function __construct( + private readonly LoggerInterface $logger, + private readonly ApplicationVersionService $service, + ) { + }//end __construct() + + /** + * Handle a save event on an Application row. + * + * Filters on Application schema + presence of `productionVersion`, + * then calls the cross-row guard. On guard failure the event is + * stopped and an error payload attached. + * + * @param Event $event Dispatched event + * + * @return void + */ + public function handle(Event $event): void + { + if (($event instanceof ObjectCreatingEvent) === false + && ($event instanceof ObjectUpdatingEvent) === false + ) { + return; + } + + $entity = $event->getObject(); + $schema = $this->extractSchemaSlug(entity: $entity); + if ($schema !== ApplicationVersionService::APPLICATION_SCHEMA) { + return; + } + + $object = $this->extractObjectData(entity: $entity); + $proposedVersion = (string) ($object['productionVersion'] ?? ''); + if ($proposedVersion === '') { + // Unset productionVersion is always allowed (REQ-OBA-008 makes it optional). + return; + } + + $applicationUuid = $this->extractUuid(entity: $entity, object: $object); + if ($applicationUuid === '') { + // No UUID yet — let OR finish its initial CREATE path; the guard + // re-runs on the subsequent update once OR has stamped a UUID. + return; + } + + try { + $this->service->guardProductionVersionOwnership( + applicationUuid: $applicationUuid, + proposedVersionUuid: $proposedVersion + ); + } catch (Throwable $e) { + $event->stopPropagation(); + if (method_exists($event, 'setErrors') === true) { + $event->setErrors( + [ + 'status' => 422, + 'code' => 'openbuilt.production_version.back_reference_mismatch', + 'message' => $e->getMessage(), + ] + ); + } + + $this->logger->info( + message: 'OpenBuilt: blocked Application save — productionVersion guard rejected the change.', + context: [ + 'applicationUuid' => $applicationUuid, + 'productionVersion' => $proposedVersion, + 'reason' => $e->getMessage(), + ] + ); + }//end try + }//end handle() + + /** + * Read the schema slug from the ObjectEntity (defensive — supports + * both direct `getSchemaSlug()` and the `@self.schema` projection). + * + * @param object $entity The ObjectEntity instance + * + * @return string Schema slug or empty string when unresolved + */ + private function extractSchemaSlug(object $entity): string + { + if (method_exists($entity, 'getSchemaSlug') === true) { + $slug = $entity->getSchemaSlug(); + if (is_string($slug) === true && $slug !== '') { + return $slug; + } + } + + if (method_exists($entity, 'jsonSerialize') === true) { + $serialised = $entity->jsonSerialize(); + if (is_array($serialised) === true && isset($serialised['@self']['schema']) === true) { + return (string) $serialised['@self']['schema']; + } + } + + return ''; + }//end extractSchemaSlug() + + /** + * Read the object payload (post-`@self`) from the ObjectEntity. + * + * @param object $entity The ObjectEntity instance + * + * @return array + */ + private function extractObjectData(object $entity): array + { + if (method_exists($entity, 'getObject') === true) { + $object = $entity->getObject(); + if (is_array($object) === true) { + return $object; + } + } + + if (method_exists($entity, 'jsonSerialize') === true) { + $serialised = $entity->jsonSerialize(); + if (is_array($serialised) === true) { + unset($serialised['@self']); + return $serialised; + } + } + + return []; + }//end extractObjectData() + + /** + * Read the canonical UUID from the entity / object payload. + * + * @param object $entity The ObjectEntity instance + * @param array $object The plain object data + * + * @return string UUID or empty string when not yet assigned + */ + private function extractUuid(object $entity, array $object): string + { + if (method_exists($entity, 'getUuid') === true) { + $uuid = $entity->getUuid(); + if (is_string($uuid) === true && $uuid !== '') { + return $uuid; + } + } + + if (isset($object['id']) === true && is_string($object['id']) === true) { + return $object['id']; + } + + if (isset($object['uuid']) === true && is_string($object['uuid']) === true) { + return $object['uuid']; + } + + return ''; + }//end extractUuid() +}//end class diff --git a/lib/Repair/MigrateToVersionedModel.php b/lib/Repair/MigrateToVersionedModel.php new file mode 100644 index 00000000..bfc2642f --- /dev/null +++ b/lib/Repair/MigrateToVersionedModel.php @@ -0,0 +1,342 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Repair; + +use OCA\OpenBuilt\Service\ApplicationVersionService; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\RegisterService; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Destructive, idempotent green-field migration to the versioned-app model. + */ +class MigrateToVersionedModel implements IRepairStep +{ + /** + * Schema slug introduced by the versioned-app model (post-migration). + */ + private const VERSIONED_SCHEMA = 'applicationVersion'; + + /** + * Constructor. + * + * @param LoggerInterface $logger PSR logger for diagnostics + * @param ObjectService $objectService OpenRegister object service + * @param RegisterService $registerService OpenRegister register service + * @param RegisterMapper $registerMapper Resolves register slugs + * @param SchemaMapper $schemaMapper Resolves schema slugs + * + * @return void + */ + public function __construct( + private readonly LoggerInterface $logger, + private readonly ObjectService $objectService, + private readonly RegisterService $registerService, + private readonly RegisterMapper $registerMapper, + private readonly SchemaMapper $schemaMapper, + ) { + }//end __construct() + + /** + * Get the human-readable name of this repair step. + * + * @return string + */ + public function getName(): string + { + return 'Migrate OpenBuilt to versioned app model (DESTRUCTIVE)'; + }//end getName() + + /** + * Execute the migration. + * + * Logic: + * 1. Short-circuit when the schema is already in versioned shape. + * 2. Enumerate every Application row. + * 3. For each row: drop the per-app register; on success delete the + * Application row; emit one info-line; on register-delete failure + * log the error and skip the Application row. + * + * @param IOutput $output The output channel for progress reporting + * + * @return void + */ + public function run(IOutput $output): void + { + try { + if ($this->isAlreadyVersioned() === true) { + $output->info('Migrated-to-versioned-model: schema already in versioned shape, skipping'); + return; + } + } catch (Throwable $e) { + // If we cannot even read the schema state we cannot safely + // continue — assume the worst and skip rather than blow away + // data that we may not own. + $output->warning( + 'Migrated-to-versioned-model: could not determine schema state ('.$e->getMessage().'); skipping for safety.' + ); + $this->logger->error( + 'OpenBuilt: MigrateToVersionedModel short-circuit detection failed', + ['exception' => $e] + ); + return; + } + + $applications = $this->enumerateApplications(); + if ($applications === []) { + $output->info('Migrated-to-versioned-model: no pre-migration Application rows found.'); + return; + } + + foreach ($applications as $application) { + $this->migrateOne(application: $application, output: $output); + } + }//end run() + + /** + * Detect whether the schema is already in versioned shape. + * + * Short-circuit fires when EITHER: + * - The `applicationVersion` schema exists in the `openbuilt` + * register; OR + * - No pre-migration Application row carries a `currentVersion` + * field (all surviving rows already match the new shape). + * + * @return bool True when the schema is already versioned + * + * @throws Throwable Propagated by callers — the caller decides whether + * to abort or continue + */ + private function isAlreadyVersioned(): bool + { + // Test 1 — does the versioned schema exist? + try { + $this->schemaMapper->find(self::VERSIONED_SCHEMA, _multitenancy: false); + return true; + } catch (Throwable) { + // Not found — fall through to Test 2. + } + + // Test 2 — do any Application rows carry the legacy `currentVersion` field? + try { + $applications = $this->enumerateApplications(); + } catch (Throwable) { + // If the register or schema do not exist yet, we are on a + // fresh install — no pre-migration data to migrate. + return true; + } + + foreach ($applications as $row) { + if (array_key_exists('currentVersion', $row) === true) { + return false; + } + } + + return true; + }//end isAlreadyVersioned() + + /** + * Fetch every Application row in the `openbuilt` register. + * + * @return array> + */ + private function enumerateApplications(): array + { + try { + $registerId = $this->registerMapper->find( + ApplicationVersionService::REGISTER_SLUG, + _multitenancy: false + )->getId(); + $schemaId = $this->schemaMapper->find( + ApplicationVersionService::APPLICATION_SCHEMA, + _multitenancy: false + )->getId(); + } catch (Throwable $e) { + $this->logger->debug( + 'OpenBuilt: MigrateToVersionedModel enumeration found no register/schema: '.$e->getMessage() + ); + return []; + } + + $rows = $this->objectService->findAll( + config: [ + 'filters' => [ + 'register' => $registerId, + 'schema' => $schemaId, + ], + ] + ); + + if (is_array($rows) === false) { + return []; + } + + $normalised = []; + foreach ($rows as $row) { + $normalised[] = $this->normaliseObjectArray(object: $row); + } + + return $normalised; + }//end enumerateApplications() + + /** + * Migrate a single pre-migration Application row. + * + * Drops the per-app register first; only deletes the row when the + * register drop succeeded. On failure, leaves the row in place so + * the operator can retry on the next upgrade after fixing the + * underlying issue (spec REQ-OBGFM-004). + * + * @param array $application Application row data + * @param IOutput $output Output channel for progress + * + * @return void + */ + private function migrateOne(array $application, IOutput $output): void + { + $slug = (string) ($application['slug'] ?? ''); + if ($slug === '') { + $this->logger->warning( + 'OpenBuilt: MigrateToVersionedModel skipped Application without slug', + ['application' => $application] + ); + return; + } + + $perAppRegisterSlug = ApplicationVersionService::REGISTER_SLUG.'-'.$slug; + + try { + $register = $this->registerMapper->find($perAppRegisterSlug, _multitenancy: false); + } catch (Throwable $e) { + // No per-app register to drop — proceed to delete the row. + $register = null; + $this->logger->debug( + 'OpenBuilt: MigrateToVersionedModel: register '.$perAppRegisterSlug.' not found ('.$e->getMessage().'); proceeding to row delete.' + ); + } + + if ($register !== null) { + try { + $this->registerService->delete(register: $register); + } catch (Throwable $e) { + $output->warning( + sprintf( + 'Migrated-to-versioned-model: FAILED to drop register \'%s\'' + .' for Application \'%s\' (%s); Application row NOT deleted.', + $perAppRegisterSlug, + $slug, + $e->getMessage() + ) + ); + $this->logger->error( + 'OpenBuilt: MigrateToVersionedModel: register-delete failed; preserving Application row', + [ + 'slug' => $slug, + 'register' => $perAppRegisterSlug, + 'exception' => $e->getMessage(), + ] + ); + return; + }//end try + }//end if + + $applicationUuid = (string) ($application['id'] ?? $application['uuid'] ?? ''); + if ($applicationUuid === '') { + $this->logger->warning( + 'OpenBuilt: MigrateToVersionedModel: Application \''.$slug.'\' has no UUID; cannot delete row.' + ); + return; + } + + try { + $this->objectService->deleteObject(uuid: $applicationUuid); + } catch (Throwable $e) { + $output->warning( + sprintf( + 'Migrated-to-versioned-model: dropped register \'%s\'' + .' but FAILED to delete Application row \'%s\' (%s).', + $perAppRegisterSlug, + $slug, + $e->getMessage() + ) + ); + $this->logger->error( + 'OpenBuilt: MigrateToVersionedModel: row-delete failed after register dropped', + ['slug' => $slug, 'exception' => $e->getMessage()] + ); + return; + } + + $output->info( + "Migrated-to-versioned-model: dropped Application '".$slug."' and register 'openbuilt-".$slug."'" + ); + }//end migrateOne() + + /** + * Coerce an OR result entry to a plain associative array. + * + * @param mixed $object The OR object/result entry + * + * @return array + */ + private function normaliseObjectArray(mixed $object): array + { + if (is_array($object) === true) { + return $object; + } + + if (is_object($object) === true && method_exists($object, 'jsonSerialize') === true) { + $serialised = $object->jsonSerialize(); + if (is_array($serialised) === true) { + return $serialised; + } + } + + if (is_object($object) === true && method_exists($object, 'getObject') === true) { + $inner = $object->getObject(); + if (is_array($inner) === true) { + return $inner; + } + } + + return []; + }//end normaliseObjectArray() +}//end class diff --git a/lib/Repair/SeedHelloWorld.php b/lib/Repair/SeedHelloWorld.php deleted file mode 100644 index a77dee5b..00000000 --- a/lib/Repair/SeedHelloWorld.php +++ /dev/null @@ -1,377 +0,0 @@ - - * @copyright 2026 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://conduction.nl - */ - -declare(strict_types=1); - -namespace OCA\OpenBuilt\Repair; - -use OCA\OpenRegister\Service\ObjectService; -use OCP\Migration\IOutput; -use OCP\Migration\IRepairStep; -use Psr\Log\LoggerInterface; - -/** - * Repair step that seeds the hello-world virtual app + sample messages. - */ -class SeedHelloWorld implements IRepairStep -{ - private const SEED_SLUG = 'hello-world'; - private const SEED_VERSION = '1.0.0'; - - /** - * Constructor. - * - * @param LoggerInterface $logger Logger for diagnostics - * @param ObjectService $objectService OpenRegister object service (hard dep via info.xml) - * - * @return void - */ - public function __construct( - private LoggerInterface $logger, - private ObjectService $objectService, - ) { - }//end __construct() - - /** - * Get the name of this repair step. - * - * @return string - */ - public function getName(): string - { - return 'Seed the canonical hello-world virtual app and sample messages'; - }//end getName() - - /** - * Run the repair step to seed the hello-world virtual app. - * - * @param IOutput $output The output interface for progress reporting - * - * @return void - */ - public function run(IOutput $output): void - { - $output->info('Seeding hello-world virtual app...'); - - try { - if ($this->seedAlreadyExists() === true) { - $output->info('hello-world Application already exists; skipping seed.'); - return; - } - - $applicationUuid = $this->seedApplicationAndRoute(output: $output); - - if ($applicationUuid !== null) { - $this->seedInitialSnapshot(output: $output, applicationUuid: $applicationUuid); - } - - $this->seedSampleMessages(output: $output); - - $this->logger->info('OpenBuilt: hello-world virtual app seeded successfully'); - } catch (\Throwable $e) { - $output->warning('Could not seed hello-world: '.$e->getMessage()); - $this->logger->error( - 'OpenBuilt: SeedHelloWorld failed', - ['exception' => $e->getMessage()] - ); - }//end try - }//end run() - - /** - * Idempotency guard — true when a hello-world Application is already present. - * - * @return bool - */ - private function seedAlreadyExists(): bool - { - $existing = $this->objectService->findAll( - config: [ - 'filters' => [ - 'register' => 'openbuilt', - 'schema' => 'application', - 'slug' => self::SEED_SLUG, - ], - 'limit' => 1, - ] - ); - - return empty($existing) === false; - }//end seedAlreadyExists() - - /** - * Create the hello-world Application AND the BuiltAppRoute pointing at it. - * - * Returns the application UUID (or null if OR did not return one) so the - * caller can chain the initial snapshot seed. Per design.md OQ-1, OR's - * x-openregister-lifecycle engine does not yet support - * `on_transition.upsert_relation`, so the BuiltAppRoute is created - * explicitly — ADR-031 §Exceptions(1) declarative-first failure mode. - * - * @param IOutput $output Progress reporter. - * - * @return string|null The created Application's UUID, or null on miss. - */ - private function seedApplicationAndRoute(IOutput $output): ?string - { - $seedManifest = $this->buildHelloWorldManifest(); - - // Per design.md OQ-4 of openbuilt-rbac the hello-world demo defaults to - // admin-only after the migration so operators consciously decide to - // broaden access; this matches the "ACTION REQUIRED: re-grant access" - // deployment note. Operators who want the demo broadly visible can - // grant `viewers` explicitly via the Permissions panel. - $application = $this->objectService->saveObject( - object: [ - 'slug' => self::SEED_SLUG, - 'name' => 'Hello World', - 'description' => 'The canonical seed virtual app for OpenBuilt. Exercises index + detail + form page types.', - 'version' => self::SEED_VERSION, - 'status' => 'published', - 'manifest' => $seedManifest, - 'permissions' => [ - 'owners' => ['admin'], - 'editors' => [], - 'viewers' => [], - ], - ], - register: 'openbuilt', - schema: 'application' - ); - - $applicationData = $application->jsonSerialize(); - $applicationUuid = $this->extractUuid(data: $applicationData); - - $output->info('Created hello-world Application (uuid='.($applicationUuid ?? 'unknown').').'); - - if ($applicationUuid === null) { - return null; - } - - $this->objectService->saveObject( - object: [ - 'slug' => self::SEED_SLUG, - 'applicationUuid' => $applicationUuid, - ], - register: 'openbuilt', - schema: 'built-app-route' - ); - $output->info('Created BuiltAppRoute for hello-world.'); - - return $applicationUuid; - }//end seedApplicationAndRoute() - - /** - * Seed one ApplicationVersion snapshot AND point Application.currentVersion at it. - * - * Chain spec #6 openbuilt-versioning — same ADR-031 §Exceptions(1) rationale - * as the BuiltAppRoute upkeep above. The listener does the same on every - * subsequent publish. - * - * @param IOutput $output Progress reporter. - * @param string $applicationUuid The parent Application's UUID. - * - * @return void - */ - private function seedInitialSnapshot(IOutput $output, string $applicationUuid): void - { - $seedManifest = $this->buildHelloWorldManifest(); - - $snapshot = $this->objectService->saveObject( - object: [ - 'applicationUuid' => $applicationUuid, - 'version' => self::SEED_VERSION, - 'manifest' => $seedManifest, - 'publishedAt' => gmdate('Y-m-d\TH:i:s\Z'), - 'publishedBy' => 'system', - 'notes' => 'Seeded by OpenBuilt install — initial published version', - ], - register: 'openbuilt', - schema: 'application-version' - ); - - $snapshotData = $snapshot->jsonSerialize(); - $snapshotUuid = $this->extractUuid(data: $snapshotData); - - if ($snapshotUuid === null) { - return; - } - - // Patch the Application with currentVersion pointing at the snapshot. - $this->objectService->saveObject( - object: [ - 'slug' => self::SEED_SLUG, - 'name' => 'Hello World', - 'description' => 'The canonical seed virtual app for OpenBuilt. Exercises index + detail + form page types.', - 'version' => self::SEED_VERSION, - 'status' => 'published', - 'manifest' => $seedManifest, - 'currentVersion' => $snapshotUuid, - ], - register: 'openbuilt', - schema: 'application' - ); - $output->info('Seeded initial ApplicationVersion '.$snapshotUuid.' (currentVersion set).'); - }//end seedInitialSnapshot() - - /** - * Seed three sample HelloMessage objects. - * - * @param IOutput $output Progress reporter. - * - * @return void - */ - private function seedSampleMessages(IOutput $output): void - { - foreach ($this->buildSampleMessages() as $message) { - $this->objectService->saveObject( - object: $message, - register: 'openbuilt', - schema: 'hello-message' - ); - } - - $output->info('Seeded three sample HelloMessage objects.'); - }//end seedSampleMessages() - - /** - * Read the canonical UUID out of an OR-serialised object array. - * - * @param array $data Serialised object array. - * - * @return string|null The UUID or null if not present. - */ - private function extractUuid(array $data): ?string - { - $self = []; - if (isset($data['@self']) === true && is_array($data['@self']) === true) { - $self = $data['@self']; - } - - if (isset($self['id']) === true) { - return (string) $self['id']; - } - - if (isset($self['uuid']) === true) { - return (string) $self['uuid']; - } - - if (isset($data['uuid']) === true) { - return (string) $data['uuid']; - } - - return null; - }//end extractUuid() - - /** - * Build the canonical hello-world manifest. - * - * Per design.md Seed Data: exercises index + detail + form page types - * against the seeded `hello-message` schema. Labels and titles use - * i18n keys consumed by the consuming app's t() (ADR-024 §6, ADR-007). - * - * @return array - */ - private function buildHelloWorldManifest(): array - { - return [ - 'version' => '1.0.0', - 'dependencies' => ['openregister'], - 'menu' => [ - [ - 'id' => 'Messages', - 'label' => 'openbuilt.helloworld.menu.messages', - 'icon' => 'icon-comment', - 'route' => 'Messages', - 'order' => 1, - ], - ], - 'pages' => [ - [ - 'id' => 'Messages', - 'route' => '/', - 'type' => 'index', - 'title' => 'openbuilt.helloworld.title.messages', - 'config' => [ - 'register' => 'openbuilt', - 'schema' => 'hello-message', - 'columns' => ['title', 'body', '@self.created'], - ], - ], - [ - 'id' => 'MessageDetail', - 'route' => '/messages/:id', - 'type' => 'detail', - 'title' => 'openbuilt.helloworld.title.message', - 'config' => [ - 'register' => 'openbuilt', - 'schema' => 'hello-message', - ], - ], - [ - 'id' => 'MessageCreate', - 'route' => '/messages/new', - 'type' => 'form', - 'title' => 'openbuilt.helloworld.title.create', - 'config' => [ - 'register' => 'openbuilt', - 'schema' => 'hello-message', - 'mode' => 'create', - 'submitEndpoint' => '/index.php/apps/openregister/api/objects/openbuilt/hello-message', - ], - ], - ], - ]; - }//end buildHelloWorldManifest() - - /** - * Build the three sample HelloMessage objects. - * - * Bodies are kept under the 150-character line limit for PHPCS. - * - * @return array> - */ - private function buildSampleMessages(): array - { - return [ - [ - 'title' => 'Welcome to OpenBuilt', - 'body' => 'This message is rendered by your first virtual app — built from a JSON manifest stored in OpenRegister.', - ], - [ - 'title' => 'Edit me', - 'body' => 'Open the OpenBuilt shell, find hello-world, and edit its manifest to change what you see here.', - ], - [ - 'title' => 'Built from a manifest', - 'body' => 'Everything here — menu, pages, columns, form — came from a JSON manifest. No PHP was written for hello-world.', - ], - ]; - }//end buildSampleMessages() -}//end class diff --git a/lib/Service/ApplicationVersionService.php b/lib/Service/ApplicationVersionService.php new file mode 100644 index 00000000..03336d47 --- /dev/null +++ b/lib/Service/ApplicationVersionService.php @@ -0,0 +1,731 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Service; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\RegisterService; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; + +/** + * Imperative business-logic surface for ApplicationVersion (ADR-002). + * + * Note: this service intentionally owns only the surface that ADR-031's + * declarative-vs-imperative table classifies as imperative. Lifecycle + * transitions, route upserts on publish, and the same-row promotesTo + * self-loop check are declared in `lib/Settings/openbuilt_register.json`. + */ +class ApplicationVersionService +{ + /** + * Shared register that hosts both Application and ApplicationVersion. + */ + public const REGISTER_SLUG = 'openbuilt'; + + /** + * Schema slug of the parent Application object. + */ + public const APPLICATION_SCHEMA = 'application'; + + /** + * Schema slug of the versioned-model ApplicationVersion object. + */ + public const APPLICATION_VERSION_SCHEMA = 'applicationVersion'; + + /** + * Hard cap on the `promotesTo` chain walk in {@see guardNoCycle()}. + * Prevents runaway traversal on data corruption (spec REQ-OBV-104). + */ + private const CYCLE_GUARD_HOPS = 100; + + /** + * Initial semver assigned to a freshly-created ApplicationVersion + * (spec REQ-OBV-102). + */ + public const INITIAL_SEMVER = '0.1.0'; + + /** + * Valid strategy values accepted by {@see deleteVersion()}. + * + * @var array + */ + private const VALID_STRATEGIES = [ + self::STRATEGY_DELETE_NOW, + self::STRATEGY_ORPHAN_GRACE, + self::STRATEGY_KEEP_REGISTER, + ]; + + /** + * Strategy: drop the per-version register and the ApplicationVersion row. + */ + public const STRATEGY_DELETE_NOW = 'delete-now'; + + /** + * Strategy: mark the per-version register orphaned; drop only the row. + */ + public const STRATEGY_ORPHAN_GRACE = 'orphan-grace'; + + /** + * Strategy: leave the register intact; drop only the row. + */ + public const STRATEGY_KEEP_REGISTER = 'keep-register'; + + /** + * Constructor. + * + * @param LoggerInterface $logger PSR logger for diagnostics + * @param ObjectService $objectService OpenRegister object service + * @param RegisterService $registerService OpenRegister register-level service + * @param RegisterMapper $registerMapper Resolves register slugs to entities + * + * @return void + */ + public function __construct( + private readonly LoggerInterface $logger, + private readonly ObjectService $objectService, + private readonly RegisterService $registerService, + private readonly RegisterMapper $registerMapper, + ) { + }//end __construct() + + /** + * Produce a canonical JSON string for the given manifest blob. + * + * Recursively sorts associative arrays by key so the resulting + * string is byte-equal for any reordering of input keys. Encoded + * without whitespace and with `JSON_THROW_ON_ERROR` so invalid + * structures surface immediately. List arrays (numeric, ordered) + * are preserved verbatim — order is part of the manifest's + * semantic meaning (pages, menu, columns). + * + * @param array $manifest The manifest blob + * + * @return string Canonical JSON + * + * @throws \JsonException When the structure contains non-encodable values + */ + public function canonicaliseManifest(array $manifest): string + { + return json_encode( + $this->canonicaliseValue(value: $manifest), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + ); + }//end canonicaliseManifest() + + /** + * Return the SHA-256 hex digest of the canonicalised manifest. + * + * @param array $manifest The manifest blob + * + * @return string 64-char lowercase hexadecimal digest + * + * @throws \JsonException When the manifest contains non-encodable values + */ + public function hashManifest(array $manifest): string + { + return hash(algo: 'sha256', data: $this->canonicaliseManifest(manifest: $manifest)); + }//end hashManifest() + + /** + * Patch-bump a semver string (X.Y.Z → X.Y.(Z+1)), dropping any + * pre-release / build-metadata suffix on the way through. + * + * @param string $semver The current semver string + * + * @return string The patch-bumped semver + * + * @throws RuntimeException When the input is not a recognisable semver + */ + public function bumpPatch(string $semver): string + { + $matches = []; + if (preg_match('/^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/', $semver, $matches) !== 1) { + throw new RuntimeException(message: sprintf('Invalid semver string "%s" — cannot bump.', $semver)); + } + + $major = (int) $matches[1]; + $minor = (int) $matches[2]; + $patch = (int) $matches[3]; + + return sprintf('%d.%d.%d', $major, $minor, $patch + 1); + }//end bumpPatch() + + /** + * Apply the semver auto-bump rule (spec REQ-OBV-103) to a pending save. + * + * Given the previously-persisted state (`$current`, may be null on + * create) and the candidate state (`$next` — mutated in place when a + * bump is required), this method: + * + * - Computes the manifest hash of `$next`. + * - Compares with `$current`'s stored `manifestHash` (mapper-internal + * bookkeeping; not part of the public schema). When different, + * `$next.semver` is patch-bumped and `$next.manifestHash` is set + * to the new hash. + * - On a brand-new row (no `$current`), `$next.semver` defaults to + * `0.1.0` when absent and `$next.manifestHash` is initialised. + * + * @param array|null $current The persisted state, or null on create + * @param array $next The candidate next state (mutated in place) + * + * @return array The mutated `$next` array + * + * @throws \JsonException When the manifest cannot be canonicalised + */ + public function onSave(?array $current, array $next): array + { + $manifest = $next['manifest'] ?? null; + if (is_array($manifest) === false) { + // No manifest yet — nothing to hash or bump. + return $next; + } + + $newHash = $this->hashManifest(manifest: $manifest); + + if ($current === null) { + // CREATE path — default the initial semver and stamp the hash. + if (isset($next['semver']) === false || (string) $next['semver'] === '') { + $next['semver'] = self::INITIAL_SEMVER; + } + + $next['manifestHash'] = $newHash; + return $next; + } + + $oldHash = $current['manifestHash'] ?? null; + + if ((string) $oldHash === (string) $newHash) { + // Metadata-only edit — preserve the existing semver / hash. + $next['manifestHash'] = $oldHash; + if (isset($next['semver']) === false || (string) $next['semver'] === '') { + $next['semver'] = (string) ($current['semver'] ?? self::INITIAL_SEMVER); + } + + return $next; + } + + // Manifest content has changed — patch-bump and stamp the new hash. + $next['semver'] = $this->bumpPatch(semver: (string) ($current['semver'] ?? self::INITIAL_SEMVER)); + $next['manifestHash'] = $newHash; + return $next; + }//end onSave() + + /** + * Reject a `promotesTo` assignment that would form a cycle (spec REQ-OBV-104). + * + * Walks `promotesTo` forward from the proposed target up to + * {@see self::CYCLE_GUARD_HOPS} hops. Throws when the current row's + * UUID is encountered along the walk (cycle), when the proposed + * target itself equals the current row's UUID (self-loop), or when + * the hop cap is exceeded (chain corruption). + * + * @param string $currentUuid UUID of the row being saved + * @param string|null $proposedTargetUuid Proposed `promotesTo` value + * + * @return void + * + * @throws RuntimeException When a cycle is detected or the cap is exceeded + */ + public function guardNoCycle(string $currentUuid, ?string $proposedTargetUuid): void + { + if ($proposedTargetUuid === null || $proposedTargetUuid === '') { + return; + } + + if ($proposedTargetUuid === $currentUuid) { + throw new RuntimeException( + message: sprintf('promotesTo cycle: ApplicationVersion %s cannot promote to itself.', $currentUuid) + ); + } + + $cursor = $proposedTargetUuid; + $hops = 0; + while ($cursor !== null && $cursor !== '') { + if ($hops >= self::CYCLE_GUARD_HOPS) { + throw new RuntimeException( + message: sprintf( + 'promotesTo chain exceeded %d hops starting from %s — chain corrupted, aborting cycle check.', + self::CYCLE_GUARD_HOPS, + $proposedTargetUuid + ) + ); + } + + if ($cursor === $currentUuid) { + throw new RuntimeException( + message: sprintf( + 'promotesTo cycle: setting promotesTo on %s would loop back through %s.', + $currentUuid, + $proposedTargetUuid + ) + ); + } + + $hops++; + $cursor = $this->resolveNextPromotesTo(versionUuid: $cursor); + }//end while + }//end guardNoCycle() + + /** + * Verify that `Application.productionVersion`'s back-reference is sound (REQ-OBV-105). + * + * Reads the proposed ApplicationVersion and asserts that its + * `application` relation points back at the parent Application's + * UUID. Throws otherwise — the caller surfaces a 422 to the client. + * + * @param string $applicationUuid UUID of the parent Application being saved + * @param string $proposedVersionUuid UUID proposed as `productionVersion` + * + * @return void + * + * @throws RuntimeException When the back-reference does not point at the parent + */ + public function guardProductionVersionOwnership(string $applicationUuid, string $proposedVersionUuid): void + { + $version = $this->objectService->find( + id: $proposedVersionUuid, + register: self::REGISTER_SLUG, + schema: self::APPLICATION_VERSION_SCHEMA + ); + + if ($version === null) { + throw new RuntimeException( + message: sprintf( + 'productionVersion %s does not exist — cannot be assigned to Application %s.', + $proposedVersionUuid, + $applicationUuid + ) + ); + } + + $data = $this->normaliseObjectArray(object: $version); + $backReference = (string) ($data['application'] ?? ''); + + if ($backReference !== $applicationUuid) { + $displayBack = $backReference; + if ($displayBack === '') { + $displayBack = '(unset)'; + } + + throw new RuntimeException( + message: sprintf( + 'productionVersion %s belongs to Application %s, not %s — back-reference mismatch.', + $proposedVersionUuid, + $displayBack, + $applicationUuid + ) + ); + } + }//end guardProductionVersionOwnership() + + /** + * Delete an ApplicationVersion using the named strategy (spec REQ-OBV-108). + * + * Branching effect chain: + * + * - `delete-now`: drop the per-version register (and every row inside + * it) via {@see RegisterService::delete()}, then delete the + * ApplicationVersion row. + * - `orphan-grace`: mark the per-version register orphaned by writing + * a timestamped flag into its `metadata` array, then delete the + * ApplicationVersion row. A background job (out of scope here) + * prunes registers orphaned for more than 30 days. + * - `keep-register`: leave the register untouched; delete only the + * ApplicationVersion row. + * + * Rejects deletion of an ApplicationVersion currently pointed at by its + * parent Application's `productionVersion`. + * + * @param string $versionUuid UUID of the ApplicationVersion to delete + * @param string $strategy One of the STRATEGY_* constants + * + * @return void + * + * @throws RuntimeException On unknown strategy, missing version, or + * production-version refusal + */ + public function deleteVersion(string $versionUuid, string $strategy): void + { + $this->assertValidStrategy(strategy: $strategy); + + $version = $this->objectService->find( + id: $versionUuid, + register: self::REGISTER_SLUG, + schema: self::APPLICATION_VERSION_SCHEMA + ); + + if ($version === null) { + throw new RuntimeException( + message: sprintf('ApplicationVersion %s does not exist — nothing to delete.', $versionUuid) + ); + } + + $versionData = $this->normaliseObjectArray(object: $version); + $this->assertNotProductionVersion(versionData: $versionData, versionUuid: $versionUuid); + + $registerSlug = (string) ($versionData['register'] ?? ''); + + switch ($strategy) { + case self::STRATEGY_DELETE_NOW: + $this->dropPerVersionRegister(registerSlug: $registerSlug, versionUuid: $versionUuid); + break; + case self::STRATEGY_ORPHAN_GRACE: + $this->flagRegisterOrphaned(registerSlug: $registerSlug, versionUuid: $versionUuid); + break; + case self::STRATEGY_KEEP_REGISTER: + // No-op on the register — admin retains the data. + $this->logger->info( + sprintf( + 'OpenBuilt: keep-register strategy on ApplicationVersion %s — register %s left untouched.', + $versionUuid, + $registerSlug + ) + ); + break; + }//end switch + + $this->objectService->deleteObject(uuid: $versionUuid); + }//end deleteVersion() + + /** + * Reject an unknown deletion strategy. + * + * @param string $strategy Strategy value to validate + * + * @return void + * + * @throws RuntimeException When the strategy is not recognised + */ + private function assertValidStrategy(string $strategy): void + { + if (in_array($strategy, self::VALID_STRATEGIES, true) === false) { + throw new RuntimeException( + message: sprintf( + 'Unknown deletion strategy "%s" — must be one of: %s', + $strategy, + implode(', ', self::VALID_STRATEGIES) + ) + ); + } + }//end assertValidStrategy() + + /** + * Reject deletion when the version is the parent's production version. + * + * @param array $versionData Normalised ApplicationVersion data + * @param string $versionUuid The version's UUID + * + * @return void + * + * @throws RuntimeException When the row is the parent's productionVersion + */ + private function assertNotProductionVersion(array $versionData, string $versionUuid): void + { + $applicationUuid = (string) ($versionData['application'] ?? ''); + if ($applicationUuid === '') { + return; + } + + $application = $this->objectService->find( + id: $applicationUuid, + register: self::REGISTER_SLUG, + schema: self::APPLICATION_SCHEMA + ); + + if ($application === null) { + return; + } + + $applicationData = $this->normaliseObjectArray(object: $application); + $productionVersion = (string) ($applicationData['productionVersion'] ?? ''); + if ($productionVersion === '' || $productionVersion !== $versionUuid) { + return; + } + + throw new RuntimeException( + message: sprintf( + 'Cannot delete ApplicationVersion %s — it is the production version for Application %s.', + $versionUuid, + $applicationUuid + ) + ); + }//end assertNotProductionVersion() + + /** + * Drop a per-version register entirely (delete-now strategy). + * + * @param string $registerSlug The OR register slug to drop + * @param string $versionUuid The owning ApplicationVersion UUID (diagnostics) + * + * @return void + */ + private function dropPerVersionRegister(string $registerSlug, string $versionUuid): void + { + if ($registerSlug === '') { + $this->logger->warning( + sprintf( + 'OpenBuilt: ApplicationVersion %s has no register slug; nothing to drop.', + $versionUuid + ) + ); + return; + } + + try { + $register = $this->registerMapper->find($registerSlug, _multitenancy: false); + } catch (Throwable $e) { + $this->logger->warning( + sprintf( + 'OpenBuilt: register %s not found while deleting ApplicationVersion %s (%s) — continuing.', + $registerSlug, + $versionUuid, + $e->getMessage() + ) + ); + return; + } + + $this->registerService->delete(register: $register); + $this->logger->info( + sprintf( + 'OpenBuilt: dropped per-version register %s for ApplicationVersion %s.', + $registerSlug, + $versionUuid + ) + ); + }//end dropPerVersionRegister() + + /** + * Mark a per-version register as orphaned (orphan-grace strategy). + * + * Writes an `orphanedAt` ISO 8601 timestamp into the Register's + * `metadata` JSON column via RegisterMapper::update(). A background + * job (out of scope for this spec) prunes registers orphaned for + * more than 30 days. + * + * @param string $registerSlug The OR register slug to flag + * @param string $versionUuid The owning ApplicationVersion UUID (diagnostics) + * + * @return void + */ + private function flagRegisterOrphaned(string $registerSlug, string $versionUuid): void + { + if ($registerSlug === '') { + $this->logger->warning( + sprintf( + 'OpenBuilt: ApplicationVersion %s has no register slug; nothing to orphan-flag.', + $versionUuid + ) + ); + return; + } + + try { + $register = $this->registerMapper->find($registerSlug, _multitenancy: false); + } catch (Throwable $e) { + $this->logger->warning( + sprintf( + 'OpenBuilt: register %s not found while orphan-flagging for ApplicationVersion %s (%s).', + $registerSlug, + $versionUuid, + $e->getMessage() + ) + ); + return; + } + + $metadata = []; + if (method_exists($register, 'getMetadata') === true) { + $current = $register->getMetadata(); + if (is_array($current) === true) { + $metadata = $current; + } + } + + $metadata['orphanedAt'] = gmdate(format: 'Y-m-d\TH:i:s\Z'); + + if (method_exists($register, 'setMetadata') === true) { + $register->setMetadata($metadata); + $this->registerMapper->update($register); + $this->logger->info( + sprintf( + 'OpenBuilt: orphan-flagged register %s for ApplicationVersion %s at %s.', + $registerSlug, + $versionUuid, + $metadata['orphanedAt'] + ) + ); + return; + } + + $this->logger->warning( + sprintf( + 'OpenBuilt: Register entity for %s has no setMetadata; falling back to PSR-logged orphan event for %s.', + $registerSlug, + $versionUuid + ) + ); + }//end flagRegisterOrphaned() + + /** + * Read the `promotesTo` UUID of one ApplicationVersion row (helper for cycle walk). + * + * Returns null when the row does not exist or has no `promotesTo`, + * which terminates the walk in {@see guardNoCycle()}. + * + * @param string $versionUuid UUID of the version row to inspect + * + * @return string|null Next UUID in the chain, or null on terminal/missing + */ + private function resolveNextPromotesTo(string $versionUuid): ?string + { + try { + $entity = $this->objectService->find( + id: $versionUuid, + register: self::REGISTER_SLUG, + schema: self::APPLICATION_VERSION_SCHEMA + ); + } catch (Throwable $e) { + $this->logger->debug( + sprintf('OpenBuilt: cycle-check lookup for %s failed (%s) — treating as terminal.', $versionUuid, $e->getMessage()) + ); + return null; + } + + if ($entity === null) { + return null; + } + + $data = $this->normaliseObjectArray(object: $entity); + $next = $data['promotesTo'] ?? null; + if (is_string($next) === true && $next !== '') { + return $next; + } + + return null; + }//end resolveNextPromotesTo() + + /** + * Coerce an OR result entry (ObjectEntity or array) to a plain associative array. + * + * @param mixed $object The OR object/result entry + * + * @return array + */ + private function normaliseObjectArray(mixed $object): array + { + if (is_array($object) === true) { + return $object; + } + + if (is_object($object) === true && method_exists($object, 'jsonSerialize') === true) { + $serialised = $object->jsonSerialize(); + if (is_array($serialised) === true) { + return $serialised; + } + } + + if (is_object($object) === true && method_exists($object, 'getObject') === true) { + $inner = $object->getObject(); + if (is_array($inner) === true) { + return $inner; + } + } + + return []; + }//end normaliseObjectArray() + + /** + * Recursively canonicalise a value for stable JSON serialisation. + * + * Associative arrays are sorted by key; sequential (list) arrays + * are preserved in order; scalars pass through unchanged. + * + * @param mixed $value The value to canonicalise + * + * @return mixed The canonicalised value + */ + private function canonicaliseValue(mixed $value): mixed + { + if (is_array($value) === false) { + return $value; + } + + // Detect list vs assoc-array. array_is_list() is PHP 8.1+, available per composer.json. + if (array_is_list($value) === true) { + return array_map(fn ($item): mixed => $this->canonicaliseValue(value: $item), $value); + } + + ksort($value); + $out = []; + foreach ($value as $key => $entry) { + $out[$key] = $this->canonicaliseValue(value: $entry); + } + + return $out; + }//end canonicaliseValue() + + /** + * Describe a Register entity for diagnostic strings. + * + * Helper used by the parent Application guard listener and tests when + * they need a human-readable identifier for a Register; returns an + * empty string for a null input so callers can concatenate safely. + * + * @param Register|null $register The register entity to introspect + * + * @return string The register's slug, or empty string when unavailable + * + * @internal Exposed only to internal callers; not part of the public API. + */ + public function describeRegister(?Register $register): string + { + if ($register === null) { + return ''; + } + + return (string) $register->getSlug(); + }//end describeRegister() +}//end class diff --git a/lib/Settings/openbuilt_register.json b/lib/Settings/openbuilt_register.json index 67e5421f..abe0180d 100644 --- a/lib/Settings/openbuilt_register.json +++ b/lib/Settings/openbuilt_register.json @@ -3,13 +3,13 @@ "info": { "title": "OpenBuilt Register", "description": "Citizen-developer app builder for Nextcloud — compose apps from registers, connectors, workflows, and documents without code.", - "version": "0.2.0" + "version": "0.3.0" }, "x-openregister": { "type": "application", "app": "openbuilt", "openregister": "^v0.2.10", - "description": "OpenBuilt register namespace. Hosts the Application + BuiltAppRoute control schemas, the ApplicationTemplate catalogue (spec #8), and the seed `hello-message` schema for the canonical hello-world virtual app." + "description": "OpenBuilt register namespace. Hosts the Application + ApplicationVersion + BuiltAppRoute control schemas, the ApplicationTemplate catalogue (spec #8), and the seed `hello-message` schema for the canonical hello-world virtual app. Per ADR-002 the Application schema is the logical app (slug/name/permissions + productionVersion relation); ApplicationVersion carries the manifest, register reference, semver, and lifecycle." }, "paths": {}, "components": { @@ -17,16 +17,13 @@ "Application": { "slug": "application", "icon": "AppsBoxOutline", - "version": "0.1.0", + "version": "0.2.0", "title": "Application", - "description": "A virtual app built with OpenBuilt. Holds the manifest blob (per ADR-024) plus metadata and lifecycle. Rendered at runtime by mounting CnAppRoot with the manifest.", + "description": "A virtual app built with OpenBuilt. Per ADR-002 the Application carries identity (slug, name, description, permissions) plus a productionVersion relation to the ApplicationVersion currently exposed at the canonical URL. Manifest, semver, and lifecycle now live on ApplicationVersion.", "type": "object", "required": [ "slug", - "name", - "manifest", - "version", - "status" + "name" ], "properties": { "slug": { @@ -44,35 +41,14 @@ "type": "string", "description": "Optional long-form description of the virtual app." }, - "manifest": { - "type": "object", - "description": "JSON manifest blob conforming to @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json (v1.4.0+). Per ADR-024, this is consumed by useAppManifest + CnAppRoot at runtime.", - "required": [ - "version", - "menu", - "pages" - ], - "additionalProperties": true - }, - "version": { - "type": "string", - "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$", - "description": "Semver of this Application's manifest content. Bumped when the manifest changes substantively." - }, - "status": { - "type": "string", - "enum": [ - "draft", - "published", - "archived" - ], - "default": "draft", - "description": "Lifecycle state. Driven by x-openregister-lifecycle below — not by direct writes." - }, - "currentVersion": { + "productionVersion": { "type": "string", "format": "uuid", - "description": "UUID of the most recent ApplicationVersion snapshot. Maintained by the snapshot-on-publish lifecycle action (see chain spec #6 openbuilt-versioning). Optional — never-published Applications carry no currentVersion." + "description": "Relation → ApplicationVersion currently exposed at the canonical /apps/openbuilt/{slug} URL. Set explicitly by the admin (creation wizard, detail-page version switcher), not derived from chain terminality. The referenced ApplicationVersion's `application` relation MUST point back at this Application — enforced server-side by ApplicationVersionService::guardProductionVersionOwnership() (ADR-031 §Exceptions(1) cross-row guard).", + "x-openregister-relation": { + "target": "openbuilt/applicationVersion", + "type": "many-to-one" + } }, "permissions": { "type": "object", @@ -102,94 +78,6 @@ }, "additionalProperties": false } - }, - "x-openregister-lifecycle": { - "field": "status", - "initial": "draft", - "states": { - "draft": { - "description": "Editable, not exposed via the manifest endpoint, not reachable at /builder/{slug}." - }, - "published": { - "description": "Exposed via GET /api/applications/{slug}/manifest, reachable at /builder/{slug}/*. The on_transition action upserts the corresponding BuiltAppRoute for fast slug → application lookup." - }, - "archived": { - "description": "No longer reachable. Preserved for restore. The on_transition action removes the BuiltAppRoute." - } - }, - "transitions": [ - { - "name": "publish", - "from": "draft", - "to": "published", - "description": "Make the virtual app reachable at /builder/{slug}. Also snapshots the current manifest into an ApplicationVersion sibling and bumps currentVersion (chain spec #6 openbuilt-versioning).", - "on_transition": { - "upsert_relation": { - "schema": "openbuilt/built-app-route", - "match": { - "slug": "@self.slug" - }, - "payload": { - "slug": "@self.slug", - "applicationUuid": "@self.uuid" - } - }, - "create_relation": { - "schema": "openbuilt/application-version", - "payload": { - "applicationUuid": "@self.uuid", - "version": "@self.version", - "manifest": "@self.manifest", - "publishedAt": "@now", - "publishedBy": "@actor", - "notes": "Published via lifecycle transition" - }, - "writeback": { - "self.currentVersion": "@result.uuid", - "self.status": "draft" - } - } - } - }, - { - "name": "archive", - "from": "published", - "to": "archived", - "description": "Hide the virtual app without deleting it.", - "on_transition": { - "delete_relation": { - "schema": "openbuilt/built-app-route", - "match": { - "slug": "@self.slug" - } - } - } - }, - { - "name": "reopen", - "from": "archived", - "to": "draft", - "description": "Restore an archived virtual app to draft for further editing." - }, - { - "name": "republish", - "from": "archived", - "to": "published", - "description": "Restore an archived virtual app directly to published.", - "on_transition": { - "upsert_relation": { - "schema": "openbuilt/built-app-route", - "match": { - "slug": "@self.slug" - }, - "payload": { - "slug": "@self.slug", - "applicationUuid": "@self.uuid" - } - } - } - } - ] } }, "ApplicationTemplate": { @@ -326,48 +214,135 @@ } }, "ApplicationVersion": { - "slug": "application-version", - "icon": "HistoryIcon", + "slug": "applicationVersion", + "icon": "SourceBranchOutline", "version": "0.1.0", "title": "Application Version", - "description": "Append-only snapshot of an Application's manifest at the moment it was published. Created by the snapshot-on-publish lifecycle action (chain spec #6 openbuilt-versioning). History is never destructive — rollback creates a NEW snapshot instead of removing existing rows (design.md Decision 3).", + "description": "A deployable runtime for an Application (ADR-002). Each ApplicationVersion owns its own per-version OpenRegister register so production and test data are physically isolated. The admin-defined linear promotion chain reads via the optional `promotesTo` relation. Lifecycle (draft → published → archived) is per-version; the `draft → published` transition upserts the BuiltAppRoute slug index.", "type": "object", "required": [ - "applicationUuid", - "version", + "name", + "slug", "manifest", - "publishedAt", - "publishedBy" + "register", + "semver", + "status", + "application" ], "properties": { - "applicationUuid": { + "name": { "type": "string", - "format": "uuid", - "description": "UUID of the parent Application this snapshot belongs to." + "description": "Human-readable display label set by the admin (e.g. `Production`, `Staging`, `Development`)." }, - "version": { + "slug": { "type": "string", - "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$", - "description": "Semver version string copied from the Application's `version` field at snapshot time." + "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", + "minLength": 2, + "maxLength": 48, + "description": "URL-safe kebab-case slug for this version (e.g. `production`, `staging`, `development`)." }, "manifest": { "type": "object", - "description": "Full deep-copy of the Application's manifest blob at snapshot time. Per design.md Decision 1 this is a full blob, not a JSON Patch delta.", + "description": "JSON manifest blob conforming to @conduction/nextcloud-vue/src/schemas/app-manifest.schema.json (v1.4.0+). Per ADR-024, this is consumed by useAppManifest + CnAppRoot at runtime. Per ADR-002 the manifest now lives on ApplicationVersion (was on Application).", "additionalProperties": true }, - "publishedAt": { + "register": { "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp recording when the snapshot was taken (i.e. when the publish transition fired)." + "pattern": "^openbuilt-[a-z0-9][a-z0-9-]*[a-z0-9]$", + "description": "Per-version OpenRegister register name carrying this version's schemas and objects. Convention: `openbuilt-{appSlug}-{versionSlug}` (e.g. `openbuilt-hello-world-production`). Actual provisioning is owned by the creation-wizard capability (sibling spec)." }, - "publishedBy": { + "semver": { "type": "string", - "description": "Nextcloud user id of the actor who triggered the publish. `system` for snapshots created by repair steps." + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$", + "default": "0.1.0", + "description": "Semantic version of this ApplicationVersion. Initial value `0.1.0`. Patch-bumped automatically by ApplicationVersionService::onSave() when the manifest content changes (SHA-256 of canonicalised manifest differs from the previously-stored hash). Metadata-only edits do NOT bump semver. Minor/major bumps remain manual." }, - "notes": { + "status": { + "type": "string", + "enum": [ + "draft", + "published", + "archived" + ], + "default": "draft", + "description": "Per-version lifecycle state. Driven by x-openregister-lifecycle below." + }, + "application": { "type": "string", - "description": "Optional free-text changelog entry. v1 leaves this populated by the lifecycle action with a default string — a future spec may add a notes-prompt UX." + "format": "uuid", + "description": "Relation → parent Application (required). Real first-class OR relation, not a UUID string (ADR-002 §Decision).", + "x-openregister-relation": { + "target": "openbuilt/application", + "type": "many-to-one" + } + }, + "promotesTo": { + "type": "string", + "format": "uuid", + "description": "Optional relation → next ApplicationVersion in the linear promotion chain. Terminal versions have `promotesTo: null`. Cycle detection is enforced server-side by ApplicationVersionService::guardNoCycle() (ADR-031 §Exceptions(1) cross-row).", + "x-openregister-relation": { + "target": "openbuilt/applicationVersion", + "type": "many-to-one" + } } + }, + "x-openregister-validation": { + "rules": [ + { + "id": "promotesTo-no-self-loop", + "description": "Same-row self-loop guard: an ApplicationVersion cannot promote to itself. Broader cross-row cycle detection lives in ApplicationVersionService::guardNoCycle().", + "when": "promotesTo != null", + "assert": "promotesTo != self.uuid", + "message": "promotesTo cannot equal the version's own UUID (self-loop)." + } + ] + }, + "x-openregister-lifecycle": { + "field": "status", + "initial": "draft", + "states": { + "draft": { + "description": "Editable working version, not exposed at the canonical `/apps/openbuilt/{slug}` URL unless this version is the parent Application's productionVersion." + }, + "published": { + "description": "Reachable through routing — eligible to be the parent Application's productionVersion. The on_transition action upserts the corresponding BuiltAppRoute keyed by the parent Application's slug." + }, + "archived": { + "description": "No longer reachable. Preserved for restore via the `reopen` transition." + } + }, + "transitions": [ + { + "name": "publish", + "from": "draft", + "to": "published", + "description": "Mark this version as ready to be the parent Application's productionVersion. Relocated from Application schema per ADR-002 — published-ness is per-version now.", + "on_transition": { + "upsert_relation": { + "schema": "openbuilt/built-app-route", + "match": { + "slug": "@self.application.slug" + }, + "payload": { + "slug": "@self.application.slug", + "applicationUuid": "@self.application.uuid" + } + } + } + }, + { + "name": "archive", + "from": "published", + "to": "archived", + "description": "Retire a published version. Operators are responsible for swapping the parent Application's productionVersion off this row first." + }, + { + "name": "reopen", + "from": "archived", + "to": "draft", + "description": "Restore an archived ApplicationVersion to draft for further editing." + } + ] } }, "exportJob": { diff --git a/openspec/architecture/adr-001-app-assets-via-openregister-files.md b/openspec/architecture/adr-001-app-assets-via-openregister-files.md new file mode 100644 index 00000000..d3eda836 --- /dev/null +++ b/openspec/architecture/adr-001-app-assets-via-openregister-files.md @@ -0,0 +1,59 @@ +# ADR-001: App assets stored as OpenRegister files-attached-to-object + +**Status**: accepted + +**Date**: 2026-05-15 + +## Context + +OpenBuilt virtual apps need to carry asset payloads alongside their manifest: + +- A nav-bar icon (and dark-mode variant) so each published app is recognisable in the Nextcloud top bar (see chain spec [`openbuilt-nextcloud-nav`](../changes/)). +- Plausibly in the near future: logos for the app shell, banner images on landing pages, document templates used by Docudesk integrations, sample/seed files for the apps generated from a virtual app, and user-uploaded files inside the apps once exported. + +There are three obvious options for where those bytes live: + +1. **Inline in the manifest** — store the SVG/PNG bytes (base64 or raw) as a JSON string on the `application` register record. Self-contained, but bloats the manifest, fights the JSON-schema validator on size, and gives us no per-file metadata (MIME type, version, owner, audit trail). +2. **A separate file store managed by openbuilt** — disk path under `data/openbuilt/`, or a parallel NC Files folder. Keeps blobs out of the register but forces us to invent a second persistence/permission/export mechanism just for app assets. +3. **OpenRegister files-attached-to-object** — OR already supports binding files to a register record, with its own RBAC, versioning, and download endpoints. We get a single persistence model. + +OpenRegister files-attached-to-object is already shipping; multiple Conduction apps (e.g. Decidesk, Docudesk) use it for object-level attachments. The pattern is the established Conduction default for "files that belong to a record". + +## Decision + +**All assets that belong to an OpenBuilt virtual app are stored as files attached to the `application` register record via OpenRegister's files-attached-to-object functionality.** This includes (non-exhaustively) the light/dark nav icons, future per-app logos/banners, document templates, and any user-uploaded blobs surfaced as app features. + +Manifest fields reference each asset by file ref (name or UUID), not by inline bytes and not by a path under `/img/`. Example shape for the icon (finalised in the nav-entry spec): + +```jsonc +"icon": { "ref": "app-icon.svg" }, +"iconDark": { "ref": "app-icon-dark.svg" } +``` + +OpenBuilt exposes the asset bytes to the rest of Nextcloud (and to anonymous nav-icon fetches) through a thin OpenBuilt-side endpoint that reads the attached file from OR — never by replicating the bytes into openbuilt's own filesystem. + +The export pipeline includes the attached file blobs in the generated app bundle and seeds them into the generated app's own register on install, so exported apps continue to follow the same pattern with zero special-casing. + +## Consequences + +**Positive:** +- One persistence model for everything attached to an app — no parallel file store, no parallel RBAC. +- Export/clone/transfer of an app drags its assets along automatically because they live on the record itself. +- Future asset features (banners, document templates, user uploads inside generated apps) plug in for free; we never have to "decide where this kind of file goes" again. +- Audit / version / permission semantics for assets are whatever OR already gives us — no re-implementation, no drift. + +**Negative / trade-offs:** +- Adds a hard dependency on OR's files-attached-to-object endpoint being installed and reachable. Mitigated: OpenBuilt already requires OR (chain spec #1 application-register). +- Nav-icon rendering needs a small openbuilt-side endpoint that fans out to OR; we don't get to serve the SVG straight from `/img/`. Mitigated: same controller can cache and apply CSP/MIME headers correctly per request. +- A migration step will be needed if/when we ever ship a built-in default icon — defaults live in `/img/`, user-picked icons live in OR. The endpoint must transparently fall back between the two. + +## Alternatives considered + +- **Inline SVG in manifest** — rejected: blows up manifest size, no version/audit, fragile for binary formats (PNG/JPEG for banners later). +- **Standalone openbuilt file store** — rejected: forces us to reinvent RBAC, export, and lifecycle for a single use case; diverges from the rest of the Conduction fleet which standardises on OR-attached files. +- **Cloning Nextcloud's app-store icon model (`/img/app.svg` on disk)** — rejected: virtual apps are *records*, not Nextcloud apps; they don't have a disk footprint until exported, so an on-disk asset model doesn't fit the runtime. + +## Related + +- Chain spec `openbuilt-nextcloud-nav` (icon picker UX + nav-entry registration; first consumer of this ADR) +- Chain spec `openbuilt-exporter` (must bundle OR-attached files into the export tarball) diff --git a/openspec/architecture/adr-002-versioned-app-deployment-model.md b/openspec/architecture/adr-002-versioned-app-deployment-model.md new file mode 100644 index 00000000..4ec7888d --- /dev/null +++ b/openspec/architecture/adr-002-versioned-app-deployment-model.md @@ -0,0 +1,110 @@ +# ADR-002: Versioned app deployment model (admin-defined version chain) + +**Status**: accepted + +**Date**: 2026-05-15 + +## Context + +OpenBuilt's current versioning model (chain spec #6 `openbuilt-versioning`) treats every publish as a new `ApplicationVersion` snapshot row and points `Application.currentVersion` at the latest one. Two problems with this: + +1. **No safe playground for admins.** An admin who wants to change a live app has to edit the manifest in place. The change is visible to end users immediately, and there is no way to "try the change first". Citizen-developer apps need a place to experiment without breaking production. +2. **Production data and test data share one register.** A virtual app's data lives in `openbuilt-{slug}`. If the admin adds a new schema for an experiment, that schema (and any seeded test data) immediately surfaces in production. There is no isolation between "ship-quality" data and "I'm tinkering" data. + +We also accumulate a `currentVersion` UUID on the Application record that gets updated by a writeback listener after every publish — a denormalised cache that exists because we don't have a clean way to ask "which version is live right now?". The user's instinct was that the field is redundant; the deeper cause is that the model itself conflates "the version" with "the app". + +A constraint we set early: **admins should define their own versions and CI/CD flow**. We do not pick the tier names. Some teams want a single "production"; others want `dev → staging → production`; a forms-app admin might just want `draft → live`. The model has to accommodate all of these without a schema change. + +## Decision + +Split the runtime model into two related OR objects, with the relation between them being a real first-class OR relation (not a UUID string): + +- **`Application`** — the *logical* app. Owns slug, name, description, RBAC permissions, app-level assets (icons per ADR-001), and a relation `productionVersion → ApplicationVersion` that names which version end users see at the canonical URL. +- **`ApplicationVersion`** — a *deployable runtime*. Owns the manifest, a per-version register name, an admin-defined display `name` + URL-safe `slug` (e.g. `"Staging" / "staging"`), a per-version semver, a relation `application → Application` back to its parent, and a single optional relation `promotesTo → ApplicationVersion` declaring its one downstream target. + +Each ApplicationVersion's *history* (manifest edits, schema edits) is captured by OR's audit-trail / object time-travel. We never spawn extra version rows just to record changes — rollback is OR time-travel on the version row. + +**Admin-defined linear chain — not a fixed enum:** + +The set of versions per app is whatever the admin defines. v1 ships **linear chains only**: each ApplicationVersion has at most one `promotesTo`. The chain reads naturally — `development → staging → production` — and the promotion UX is trivially "promote to: ". Branching DAGs (fan-out, fan-in) and CI/CD-style triggers (cron, event-driven auto-promotion) are explicitly out of scope for v1; they are roadmap items. + +The terminal version of the chain (no `promotesTo`) is typically the production one, but "production-ness" is set explicitly on the Application via `productionVersion`. This survives chain reshaping — if an admin inserts a new "hotfix" version between staging and production, the production pointer stays intact. + +**Creation wizard with presets:** + +At app-creation time, the wizard offers four presets after the basic name/slug/description step: + +- **Single** — one version (default name `production`). Smallest footprint; admin can add more later. +- **Dev + Prod** — two versions, `development → production`. The common "I want a playground" shape. +- **Dev + Staging + Prod** — three versions, `development → staging → production`. The classic shape. +- **Custom** — admin types in N version names in order; backend stitches the chain. + +The wizard is the only place a chain is created in one shot; once an app exists, versions are added one at a time via the detail page. + +**Per-version register for data isolation:** + +Each ApplicationVersion owns its own OR register, named `openbuilt-{slug}-{versionSlug}` (e.g. `openbuilt-helloworld-production`, `openbuilt-helloworld-staging`). Schemas and objects live in that register. Beta tinkering — including schema-shape changes and seeded test rows — can never contaminate production data because the registers are physically separate. The shared `openbuilt` register continues to hold *system* schemas (Application, ApplicationVersion, ApplicationTemplate, …) per the hybrid-register memory. + +**Routing — URL-param version switching, admin-only beyond production:** + +- `/apps/openbuilt/{slug}` always serves the version pointed at by `Application.productionVersion`. +- `/apps/openbuilt/{slug}?version=` serves the named version's manifest. Access to anything other than the production version requires the caller to have an editor/owner role on the Application (RBAC enforced server-side, not just hidden in the UI). +- The app detail page exposes a **version switcher** that flips the URL parameter; admins can bookmark a version-specific URL, end users at the canonical URL never see anything but production. + +**Promotion — single downstream target, admin-chosen data semantics:** + +A version's "Promote" action targets exactly its `promotesTo` neighbour (or is disabled when there is none). The promotion dialog asks the admin to choose one of: + +- **Start target with source data** — copy rows from the source register into the target register (applying the source's schema set). Useful when "the test data IS the new shape of prod data". +- **Migrate target's existing data** — keep the target register's existing rows, apply the source's schema (running any schema-migration mappings the admin has declared). Useful for genuine app upgrades where production data must survive. +- **Empty start** — install the source's schema set into the target register without copying any rows. Useful for redesigns where production data is intentionally being reset. + +The promotion itself is an atomic update of the target ApplicationVersion's manifest + schema set; the audit trail records the change. No historical rows are spawned — rollback is OR time-travel on the target version row. + +**Promotion is manual for v1.** No cron, no event triggers. Auto-promotion is a roadmap item. + +**Semver per version:** + +Each ApplicationVersion carries its own semver. Drafts inside a version patch-bump on each save (`1.1.0-beta.1 → 1.1.0-beta.2`). Promoting *to* production drops the prerelease (`1.1.0-beta.5 → 1.1.0`). The next cycle on the upstream version starts from `.0-beta.1`. Major bumps remain manual. The `version` field on Application is **derived** on read — always reports the production version's semver. + +**Application.currentVersion goes away:** + +Replaced by the explicit `productionVersion` relation on Application. The snapshot writeback listener is retired. Queries that today ask "what's the latest snapshot?" become "what is `productionVersion.manifest`?", which is a relation hop, not a denormalised cache. + +## Consequences + +**Positive:** +- Admins get a real playground and can name it whatever fits their team's vocabulary. +- The model accommodates teams that want one version, two, three, or seven — no schema migration ever required to add a tier. +- Production data is structurally isolated from test data — no per-row tagging, no filter-or-else bugs. +- "Which version is live?" stops being a denormalised cache and becomes a relation hop. +- OR object time-travel covers the audit/history use case for free. +- Promotion semantics become explicit (admin picks data treatment) instead of implicit. + +**Negative / trade-offs:** +- N registers per app (one per version) means more OR records to provision. Mitigated: registers are cheap; the creation wizard provisions all of them up front. +- Promotion is no longer "click publish" — it's a dialog with three data choices. Mitigated by sensible defaults per source/target pair (e.g. dev → staging defaults to "Start target with source data"; staging → production defaults to "Migrate target's existing data"). +- URL routing now has a query-parameter dimension that must be respected by every link, deep-link emitter, and integration. The default (no param) is `productionVersion`, so external links stay correct. +- A migration is required: existing virtual apps need their `openbuilt-{slug}` register renamed to `openbuilt-{slug}-production`, and an Application + ApplicationVersion(name="production") pair must be backfilled from the existing flat Application row. +- Linear-only chains rule out branching workflows in v1. Documented limitation; a future ADR can extend the model to a DAG when there is concrete demand. + +## Alternatives considered + +- **Fixed `stable | beta | development` tier enum** — rejected on user direction. The admin's vocabulary varies; the model should not. +- **Single register with `version` tag on every object** — rejected: schema differences across versions cannot coexist on one OR schema; tagging also means every read query has to filter, multiplying the risk of accidental data leakage. +- **Keep one historical row per publish (current model)** — rejected: solves "I want to see what was published when" but does not solve "I want a place to test changes safely". The user identified the playground gap, not the audit gap. +- **DAG with fan-in/fan-out from day one** — rejected for v1. The UX becomes a graph editor and the promotion dialog has to disambiguate multiple downstreams; the visible demand is linear chains and we can extend later without breaking the linear case. +- **CI/CD-style auto-promotion (cron, event triggers)** — explicitly deferred. Adds scheduler integration + approval flows; not blocked by the data model, so it can land later without re-architecting. + +## Related + +- Supersedes the version-snapshot semantics from chain spec [`openbuilt-version-snapshots`](../specs/openbuilt-version-snapshots/spec.md) — that spec stays archived but its writeback model is retired by this ADR. +- Replaces user feedback on the `currentVersion` field (the field disappears; "which version is live" becomes the `productionVersion` relation). +- Cascades to: + - Detail-page overview spec (`openbuilt-app-detail-overview`) — hero shows the version switcher; KPIs/activity graph scope to the selected version; structural widgets scope to the selected version's register. + - Exporter spec (`openbuilt-exporter`) — export picks which version to bundle. + - Schema designer routing — `/builder/{slug}/schemas` becomes version-aware (`?version=` or path segment). +- Builds on [[adr-001-app-assets-via-openregister-files]] — app-level assets live on the Application record, not on the version, so icons survive promotions and apply across all versions. +- Roadmap dependencies (out of scope for v1, but the model accommodates): + - Promotion automation (cron / event triggers) — adds rules on the `promotesTo` edge without changing the data model. + - Branching DAG promotion — extends `promotesTo` from a single relation to an array; the linear case continues to work. diff --git a/openspec/changes/openbuilt-versioning-model/.openspec.yaml b/openspec/changes/openbuilt-versioning-model/.openspec.yaml new file mode 100644 index 00000000..9f708669 --- /dev/null +++ b/openspec/changes/openbuilt-versioning-model/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-15 diff --git a/openspec/changes/openbuilt-versioning-model/design.md b/openspec/changes/openbuilt-versioning-model/design.md new file mode 100644 index 00000000..e401859c --- /dev/null +++ b/openspec/changes/openbuilt-versioning-model/design.md @@ -0,0 +1,304 @@ +## Context + +OpenBuilt's current model (chain spec `openbuilt-application-register` + `openbuilt-version-snapshots`) +stores manifest, version, status, and `currentVersion` on a single `Application` OR row, and +relies on a PHP listener (`ApplicationVersionSnapshotListener`) to writeback `currentVersion` +after each publish. ADR-002 retires that model in favour of two related objects: `Application` +(logical, slug/name/permissions/`productionVersion`) and `ApplicationVersion` (deployable, +manifest/register/semver/status/`promotesTo`). This change is the foundation — schema split, +listener removal, green-field migration. Promotion, routing, creation-wizard and detail-page +specs depend on it. + +## Goals / Non-Goals + +**Goals:** + +- Land the two-object schema in `lib/Settings/openbuilt_register.json` exactly as ADR-002 + specifies (no field renames, no shape divergence). +- Retire the snapshot writeback listener and the `currentVersion` field — one less + denormalised cache, one less imperative event handler. +- Ship a destructive, idempotent green-field migration that wipes pre-versioned data and + lets the creation wizard re-seed via the new model. Existing installs hold only test + data; ADR-002 accepts the data loss. +- Auto-bump `ApplicationVersion.semver` on **manifest content changes only**, via a stable + SHA-256 hash diff. Metadata-only edits (name, description, register, permissions) do not + bump. +- Prevent cycles on the `promotesTo` linear chain. +- Provide a backend deletion endpoint accepting a `strategy` query param + (`delete-now | orphan-grace | keep-register`) — the UI dialog is delivered by a sibling + spec. + +**Non-Goals (covered by sibling specs):** + +- Promotion flow (data-copy / migrate / empty-start) — `openbuilt-version-promotion` +- `?version=` URL routing and admin-only gate — `openbuilt-version-routing` +- App-creation wizard (provisioning Application + N ApplicationVersions + N registers + + Hello World seed at install time) — `openbuilt-app-creation-wizard` +- Detail-page version switcher UI — `openbuilt-app-detail-overview` +- Distinct-actor audit-trail aggregation — separate openregister-side change +- DAG / branching `promotesTo` arrays — ADR-002 roadmap item, not in v1 +- CI/CD auto-promotion (cron, event triggers) — ADR-002 roadmap item, not in v1 + +## Decisions + +### Decision 1 — Two-object schema split (Application + ApplicationVersion) + +Implemented as two top-level entries under `components.schemas` in +`lib/Settings/openbuilt_register.json`: + +- `Application` (modified): `slug`, `name`, `description`, `permissions`, + `productionVersion` (relation → ApplicationVersion). Removed: `manifest`, `version`, + `status`, `currentVersion`. +- `ApplicationVersion` (new): `name`, `slug`, `manifest`, `register` (string — + per-version OR register name; convention `openbuilt-{appSlug}-{versionSlug}`), `semver`, + `status` (`draft | published | archived`), `application` (relation → Application), + `promotesTo` (optional relation → ApplicationVersion). + +Relations are real first-class OR relations (per ADR-002 §Decision), not raw UUID strings. + +**Alternatives considered:** Keep manifest on Application and add a sibling `versions[]` +array — rejected because ADR-002 already settled on full object separation for data +isolation (per-version registers) and admin-defined chain. Single register with a +`version` tag — rejected in ADR-002. + +### Decision 2 — Retire `currentVersion` + delete the writeback listener + +`Application.currentVersion` is removed from the schema. The +`lib/Listener/ApplicationVersionSnapshotListener.php` file is deleted, along with its +`register()` call in `lib/AppInfo/Application.php`. The `on_transition` action in +`x-openregister-lifecycle` that performs `self.currentVersion = @result.uuid` is removed +from the schema. + +"Which version is live" is now answered by `Application.productionVersion` (an explicit +relation set by the admin), not by a denormalised cache updated by a listener. Per +ADR-002, this is the canonical fix. + +### Decision 3 — Semver auto-bump via manifest hash-diff (imperative, ADR-031 §Exceptions) + +Initial semver for a freshly-created ApplicationVersion is `0.1.0` (plain semver, no +prerelease tag). On every save, `ApplicationVersionService` canonicalises the +`manifest` object (stable JSON encode with sorted keys), SHA-256-hashes it, and compares +to the stored hash. If different, patch component is incremented and the new hash is +persisted. The hash lives in a private mapper-internal field `manifestHash` — not exposed +in the public schema — because it is implementation detail of the diff, not a +user-visible derived value. + +**Why imperative (ADR-031 §Exceptions(2) — stateful diff outside calc vocab):** OR's +calculation vocabulary handles deterministic field-derivation (e.g. `sum`, `concat`, +`format`), not stateful diffing that needs the previous-saved hash. Trying to express +"compare canonicalised new state to last-stored hash, then conditionally increment a +semver component" in declarative metadata would require a new calc primitive in OR. Doing +this in `ApplicationVersionService::onSave()` is the right escape hatch. + +**Alternatives considered:** Always bump on save (rejected — noisy; metadata edits trigger +phantom version bumps). Hash the entire row (rejected — metadata edits would bump). +Bump on lifecycle transition only (rejected — drafts get no semver history; admins lose +the patch trail of in-flight changes). + +### Decision 4 — Manifest hash storage (imperative private field) + +`manifestHash` is stored on the ApplicationVersion row in a mapper-internal column (or as +a `_self`-namespaced metadata key, depending on what the OR floor exposes). It is **not** +declared in the public schema; clients never read or write it directly. The service +maintains it on the save path. + +**Why imperative:** the hash is invisible bookkeeping for the bump logic, not a derived +value end-users would consume. Promoting it to a public schema field would invite +clients to depend on it. + +### Decision 5 — `promotesTo` cycle prevention (imperative, ADR-031 §Exceptions(1) cross-row) + +`promotesTo` is a single optional relation forming a linear chain. Setting +`A.promotesTo = B` when `B`'s transitive `promotesTo` chain reaches back to `A` must be +rejected. + +**Why imperative:** `x-openregister-validation` on the single-row save context cannot +traverse other ApplicationVersion rows. Cycle detection requires walking the chain +forward from the proposed target. This is a textbook ADR-031 §Exceptions(1) +cross-row-validation case. Implementation: `ApplicationVersionService::guardNoCycle()` is +invoked from the controller's pre-save path; walks `promotesTo` forward from the new +target, fails fast if the current row's UUID is encountered, hard cap of 100 hops to +prevent runaway traversal on data corruption. + +**Declarative attempt first (for the record):** the implementer SHOULD try +`x-openregister-validation` with a same-row check (`promotesTo !== self.uuid` — catches +self-loops); the imperative cross-row guard is the broader safety net. + +**Alternatives considered:** Trust admins to not create cycles (rejected — silent infinite +loops on chain walks would brick promotion UX). Encode a `chainDepth` field +(rejected — second denormalised cache, exactly the anti-pattern ADR-002 removes). + +### Decision 6 — `BuiltAppRoute` upsert relocates to ApplicationVersion (declarative) + +The existing `on_transition` action that upserts `BuiltAppRoute(slug, applicationUuid)` +when an Application goes `draft → published` (from chain spec +`openbuilt-application-register` REQ-OBA-004) moves from `Application`'s lifecycle to +`ApplicationVersion`'s lifecycle. Published-ness is per-version now. The route record's +`applicationUuid` continues to point at the parent Application (the routing spec layers +`?version=` on top of the route resolution). + +**Why declarative:** same shape as before — a single-row state transition firing an upsert +of a sibling record by deterministic key. Pure ADR-031 happy-path. Just relocated. + +### Decision 7 — `productionVersion` integrity guard (imperative, ADR-031 §Exceptions(1) cross-row) + +`Application.productionVersion` MUST point at an ApplicationVersion whose `application` +relation points back at this Application. Implementation: `ApplicationVersionService:: +guardProductionVersionOwnership()` (or a sibling method on an `ApplicationService` if it +exists; this spec adds one only if needed). Invoked from the Application pre-save path. +Rejects the save with 422 on mismatch. + +**Why imperative:** same cross-row constraint as cycle detection. OR's per-row validation +can't reach the target ApplicationVersion to verify back-reference. + +### Decision 8 — Green-field migration via `MigrateToVersionedModel` repair step + +`lib/Repair/MigrateToVersionedModel.php` runs as a Nextcloud `Repair\IRepairStep` on +every app install / upgrade. Logic: + +1. Detect versioned shape — short-circuit `return` if the `applicationVersion` schema + already exists in the `openbuilt` register OR if no pre-migration `Application` row + has a `currentVersion` field. Idempotency requirement: re-running on a clean install + is a no-op. +2. Enumerate every `Application` row in the `openbuilt` register via + `ObjectService::findAll('openbuilt/application')`. +3. For each row, derive the per-app register name (current convention is + `openbuilt-{slug}`). +4. Call OR's register-delete API to drop the per-app register entirely (this also drops + every object inside it). +5. Delete the Application row itself. +6. Log one line per deletion via the `$output->info()` channel: + `Migrated-to-versioned-model: dropped Application '' and register + 'openbuilt-'`. + +The step is registered in `appinfo/info.xml` under ``. +ADR-002 accepts the data loss (existing installs hold only test data; the new wizard +re-seeds Hello World). + +**Alternatives considered:** Migrate-in-place (copy the manifest to a fresh +ApplicationVersion(name="production", slug="production"), rename the register from +`openbuilt-{slug}` to `openbuilt-{slug}-production`, set `Application.productionVersion`) +— **rejected** per the locked decision: ADR-002 explicitly chose green-field because the +implementation cost and edge-case surface (register-rename idempotency, partial-failure +recovery, missing `BuiltAppRoute` patches, schema-drift across versions) is not worth +preserving test data. The greenfield migration is two safe DB operations per row. + +### Decision 9 — Version-deletion endpoint with strategy param + +`DELETE /apps/openbuilt/api/applications/{slug}/versions/{versionSlug}?strategy=…` +accepts one of three strategy values: + +- `delete-now` — drop the per-version register (and all rows inside it) immediately, then + delete the `ApplicationVersion` row. +- `orphan-grace` — mark the per-version register as orphaned (write a metadata flag on + the register row, e.g. `_self.orphanedAt = ` — exact mechanism depends on + what OR exposes), delete the `ApplicationVersion` row, leave the register data intact. + A background job (not in this spec) prunes registers orphaned for more than 30 days. +- `keep-register` — delete the `ApplicationVersion` row only; leave the register + untouched for manual recovery. + +The endpoint refuses to delete the version pointed at by `Application.productionVersion` +(422). The strategy-execution branching lives in +`ApplicationVersionService::deleteVersion($versionUuid, $strategy)`; the controller is a +thin pass-through. + +**Why imperative:** three branching side-effect chains (cascade-delete vs flag vs +no-op-data) with cross-row reads. Out of scope for declarative lifecycle metadata. + +**The dialog UI itself is out of scope** — owned by the version-promotion or +creation-wizard sibling spec. The backend ships the contract; the UI lands separately. + +### Seed Data section + +Per ADR-001 (org-wide), every register-shipping change documents what seed data the +repair step writes. + +**This spec writes no seed data.** The previous seed (`lib/Repair/SeedHelloWorld.php`, +which created the canonical `hello-world` virtual app) is wiped by the green-field +migration along with every other pre-migration Application row. The new wizard +(`openbuilt-app-creation-wizard`) owns Hello World seeding under the new model — +specifically, it provisions an `Application(slug=hello-world)` + one or more +`ApplicationVersion` rows + their per-version registers at install time, then runs the +hello-world manifest into the production version's register. + +This spec's `MigrateToVersionedModel` repair step is destructive only; the +`SeedHelloWorld` repair step is either reduced to a no-op or deleted entirely (decided +in tasks.md task #11, recommendation: **delete the file** — the wizard takes over). + +### Declarative-vs-imperative decision section + +Per ADR-031, every business-logic site is classified. + +| Concern | Declarative attempt | Final decision | Rationale | +| --- | --- | --- | --- | +| Maintain ApplicationVersion semver on manifest changes | Calc field on `semver` | **Imperative** (`ApplicationVersionService::onSave()`) | ADR-031 §Exceptions(2): stateful diff (current vs last-stored hash) is outside OR's calc vocabulary, which handles deterministic per-row derivation only. | +| Manifest hash storage | Calc field on `manifestHash` | **Imperative** (mapper-internal field) | Hash is invisible bookkeeping for the bump logic, not a user-visible derived value. Not part of the public schema. | +| Prevent cycles in the `promotesTo` chain | `x-openregister-validation` self-check | **Imperative** (`ApplicationVersionService::guardNoCycle()`) | ADR-031 §Exceptions(1): cross-row traversal required (walk forward through `promotesTo` chain). OR's row-scoped validation cannot reach other rows. Implementer SHOULD still ship the same-row self-loop validation declaratively as a cheap first filter. | +| `productionVersion` back-reference integrity | `x-openregister-validation` | **Imperative** (`ApplicationVersionService::guardProductionVersionOwnership()`) | ADR-031 §Exceptions(1): same cross-row constraint — must read the target ApplicationVersion row to verify it points back. | +| Lifecycle: `on_transition` upserts `BuiltAppRoute` on publish | `x-openregister-lifecycle.on_transition` | **Declarative** (relocated from Application to ApplicationVersion) | Identical shape to existing happy-path declarative action; just lives on the new schema. | +| Version-deletion strategy branching | Single declarative `on_delete` action | **Imperative** (`ApplicationVersionService::deleteVersion($strategy)`) | Three branching effect chains conditional on a query param — outside the `on_delete` vocabulary, which assumes a single deterministic effect. | + +## Risks / Trade-offs + +- **Risk: Green-field migration runs on a non-test install and destroys real data.** → + Mitigation: ADR-002 records the decision that current installs hold only test data; we + document the destructive behaviour at the top of `MigrateToVersionedModel.php`'s + docblock; the repair step is idempotent so re-runs on already-migrated installs are no-ops + (the short-circuit guard fires); we ship a single log line per deletion so the migration + is observable in `occ` upgrade output. **If a deployment is found that has real data + before this change ships**, the deploy team must export it before applying — this is + flagged in the release notes for this change. +- **Risk: Cycle-prevention guard misses an edge case (e.g. concurrent writes creating a + cycle).** → Mitigation: hard cap of 100 hops on the chain walk to prevent runaway + traversal; service-level guard runs on every save; if a cycle slips through during a + race window, chain-walking consumers (promotion UI) cap their own traversal to the same + 100 hops and surface a "chain corrupted" error rather than infinite-loop. +- **Risk: Manifest canonicalisation differs between PHP and JS clients, causing + spurious hash mismatches and version bumps.** → Mitigation: canonicalisation lives + entirely server-side — JS clients send raw JSON, server canonicalises with sorted keys + and recursive normalisation before hashing. Server is the only producer of + `manifestHash`. +- **Risk: Deleting `lib/Repair/SeedHelloWorld.php` removes a seed before the wizard + spec lands.** → Mitigation: this spec lands inside the same chain delivery wave as the + wizard spec; the chain is pre-launch (the green-field migration's existence proves + there's no production traffic to lose). If the wizard ships later, the `MigrateToVersionedModel` + repair step still leaves a clean shell — the admin can manually create the first app + via the existing detail-page once the wizard arrives. +- **Trade-off: `productionVersion` is set explicitly, not derived from chain terminality.** + → Per ADR-002 §Decision, this is intentional: chain-terminality is mutable (admins can + insert versions later), but production-ness must survive chain reshaping. The integrity + guard catches divergence. +- **Trade-off: No automatic backfill of an Application + ApplicationVersion pair from + the old model.** → Per ADR-002, we accept the data loss; the migration is destructive + by design. + +## Migration Plan + +1. Apply `proposal.md` schema deltas to `lib/Settings/openbuilt_register.json` (split + Application, add ApplicationVersion). +2. Add `lib/Repair/MigrateToVersionedModel.php`. Register in `appinfo/info.xml` as a + post-migration step. Step is **destructive** but idempotent (short-circuits when + versioned schema is present). +3. Delete `lib/Listener/ApplicationVersionSnapshotListener.php` and its `register()` call + in `lib/AppInfo/Application.php`. +4. Add `lib/Service/ApplicationVersionService.php` and + `lib/Controller/ApplicationVersionsController.php`. Register endpoints in + `appinfo/routes.php`. +5. Delete `lib/Repair/SeedHelloWorld.php` (default decision per task #11). Adjust its + `` registration in `appinfo/info.xml`. +6. Run repair on a dev install. Confirm the log line `Migrated-to-versioned-model: + dropped Application '' and register 'openbuilt-'` fires for every test + row. Confirm the second run is a no-op (short-circuit). + +**Rollback:** the green-field migration is destructive and one-way. Rollback means +reverting the schema patch and re-importing the previous schema; data already wiped by +the migration is **not** recoverable. ADR-002 records that we accept this; the chain +delivery wave is pre-launch. + +## Open Questions + +None — the locked decisions in the prompt cover every architectural axis. Implementation +details (exact OR API for register-delete, exact mechanism for the orphan-mark flag) +will surface during apply and are tracked in tasks.md. If OR's register-delete API or +relation-validation surface area shifts before this change merges, the relevant tasks +note the contingency. diff --git a/openspec/changes/openbuilt-versioning-model/proposal.md b/openspec/changes/openbuilt-versioning-model/proposal.md new file mode 100644 index 00000000..eeb3ded7 --- /dev/null +++ b/openspec/changes/openbuilt-versioning-model/proposal.md @@ -0,0 +1,130 @@ +--- +kind: code +depends_on: [] +--- + +## Why + +OpenBuilt today conflates "the app" and "the version" on a single `Application` row: the +manifest lives on Application, a writeback listener maintains a denormalised +`currentVersion` UUID, and there is no safe playground for admins to try changes without +exposing them to end users. ADR-002 resolves this by splitting the runtime model into +`Application` (logical) + `ApplicationVersion` (deployable runtime) joined by real OR +relations, with each version owning its own per-version register so production data is +structurally isolated from test data. This spec is the **foundation** of the chain — it +defines the schema, retires the writeback listener, and ships the green-field migration +step. Three sibling specs (`openbuilt-version-promotion`, `openbuilt-version-routing`, +`openbuilt-app-creation-wizard`) and the detail-page overview spec all build on this +model and cannot start until it lands. + +## What Changes + +- **BREAKING** Split the `Application` schema into two related OR objects: + - `Application` keeps `slug`, `name`, `description`, `permissions`, plus a new + `productionVersion` relation pointer to an `ApplicationVersion`. Loses `manifest`, + `version`, `status`, and `currentVersion` (those move to or disappear with the new + model). + - **NEW** `ApplicationVersion` carries `name` (admin display string), `slug` + (kebab-case), `manifest` (the JSON blob — moves here from Application), `register` + (per-version OR register name, convention `openbuilt-{appSlug}-{versionSlug}`), + `semver`, `status` (`draft | published | archived` — moved from Application; + per-version lifecycle now), `application` (relation → Application), and `promotesTo` + (optional relation → next ApplicationVersion in the linear chain). +- **BREAKING** Remove `Application.currentVersion`. Delete + `lib/Listener/ApplicationVersionSnapshotListener.php` and its `register()` registration + in `lib/AppInfo/Application.php`. Drop the `on_transition` snapshot-writeback action + from the schema's `x-openregister-lifecycle` (the action that performed + `self.currentVersion = @result.uuid`). +- **NEW** `ApplicationVersionService` — owns semver auto-bump on manifest hash-diff + (initial `0.1.0`; patch-bump on manifest content change only; metadata-only edits do + not bump); owns the cross-row `promotesTo` cycle-prevention guard; owns the + version-deletion strategy logic (`delete-now | orphan-grace | keep-register`). +- **NEW** `ApplicationVersionsController` — CRUD endpoints over `ApplicationVersion` plus + the delete-with-strategy endpoint (`DELETE …?strategy=delete-now|orphan-grace| + keep-register`). Registered in `appinfo/routes.php`. +- **NEW** `MigrateToVersionedModel` repair step — destructive **green-field migration**: + for every pre-migration `Application` row, drops its per-app register + (`openbuilt-{slug}`) and deletes the Application row, logging one line per deleted app + (`Migrated-to-versioned-model: dropped Application '' and register + 'openbuilt-'`). Idempotent: short-circuits when the schema is already in + versioned shape (detected by absence of `currentVersion` on existing rows OR presence + of the `applicationVersion` schema in the register). Safe to re-run on every install + and upgrade. Registered in `appinfo/info.xml`. +- **MOVED** The existing `on_transition` lifecycle action that upserts the + `BuiltAppRoute` index on `published` (from chain spec `openbuilt-application-register`) + relocates from `Application.x-openregister-lifecycle` to + `ApplicationVersion.x-openregister-lifecycle` — published-ness is now a per-version + concept. +- **NEW** Cycle-prevention guard on `promotesTo`: an `ApplicationVersion` save that would + introduce a cycle in the `promotesTo` chain is rejected with a 4xx response. Declarative + validation (`x-openregister-validation`) is tried first; the cross-row traversal makes + this an ADR-031 §Exceptions fallback to an imperative pre-save guard inside + `ApplicationVersionService`. +- **NEW** `productionVersion` integrity guard on `Application` save: the relation MUST + point at an `ApplicationVersion` whose `application` relation points back at this + Application. A mismatched pointer is rejected with a 4xx response. +- **MAYBE-REMOVED** `lib/Repair/SeedHelloWorld.php` — verified for residual + `currentVersion` writes; under the new model the hello-world seed becomes the + responsibility of the creation wizard (`openbuilt-app-creation-wizard`) at install + time, so this repair step is reduced to a no-op or removed entirely (see tasks.md task + #11 for the decision). + +## Capabilities + +### New Capabilities + +- `application-versions`: CRUD over the new `ApplicationVersion` OR object — semver + auto-bump on manifest change, manifest-hash storage, cycle-prevention on `promotesTo`, + version-deletion with three strategy options. Owns `ApplicationVersionService`, + `ApplicationVersionsController`, and the `ApplicationVersion` schema declaration in + `lib/Settings/openbuilt_register.json`. +- `green-field-migration`: One-shot destructive migration that wipes every pre-migration + virtual app + its per-app register so the new versioned model starts from a clean + slate. Owns `lib/Repair/MigrateToVersionedModel.php` and its registration in + `appinfo/info.xml`. Safe-idempotent (short-circuits on versioned-shape schema). + +### Modified Capabilities + +- `openbuilt-application-register`: Schema patch — drop `manifest`, `version`, `status`, + `currentVersion` from Application; add `productionVersion` relation; relocate the + `BuiltAppRoute` upsert lifecycle action to ApplicationVersion. Add the + `productionVersion` integrity guard. +- `openbuilt-version-snapshots`: Snapshot/writeback semantics are **retired**. Remove + REQ-OBV-006 (currentVersion reference — the field disappears) and the listener fallback + language from REQ-OBV-002 (the listener is deleted). The audit-history use case is now + served by OR's object time-travel (per ADR-002 Decision §Audit trail), not by spawning + ApplicationVersion rows on each publish. Rollback semantics (REQ-OBV-003) become OR + time-travel on the ApplicationVersion row, not append-only snapshots — re-document + accordingly. + +## Impact + +- **New PHP**: + - `lib/Service/ApplicationVersionService.php` (semver auto-bump, manifest-hash diff, + cycle guard, deletion-strategy logic) + - `lib/Controller/ApplicationVersionsController.php` (CRUD + delete-with-strategy) + - `lib/Repair/MigrateToVersionedModel.php` (destructive green-field migration) +- **Deleted PHP**: + - `lib/Listener/ApplicationVersionSnapshotListener.php` + - Possibly `lib/Repair/SeedHelloWorld.php` (decided in tasks.md task #11) +- **Modified PHP**: + - `lib/AppInfo/Application.php` — remove the snapshot listener registration from + `register()` + - `appinfo/routes.php` — register the new endpoints + - `appinfo/info.xml` — register `MigrateToVersionedModel` repair step +- **Modified JSON**: + - `lib/Settings/openbuilt_register.json` — split `Application` schema, add new + `ApplicationVersion` schema, drop `currentVersion`, drop the snapshot `on_transition` + action, relocate the `BuiltAppRoute` upsert action +- **OpenRegister dependency** — uses the existing `^v0.2.10` floor declared in + `lib/Settings/openbuilt_register.json` (relations, lifecycle, register-delete API). +- **Out of scope** (covered by sibling specs in the chain): + - Promotion dialog + backend — `openbuilt-version-promotion` + - `?version=` URL routing + admin gate — `openbuilt-version-routing` + - Creation wizard provisioning Application + N ApplicationVersions + per-version + registers + Hello World seed — `openbuilt-app-creation-wizard` + - Detail-page version switcher UI — `openbuilt-app-detail-overview` + - Distinct-actor audit-trail aggregation — a separate openregister-side change +- **No backward compatibility** for existing virtual apps — the green-field migration + deletes them. ADR-002 records the explicit decision (existing installs hold only test + data; the new wizard re-seeds Hello World at install time). diff --git a/openspec/changes/openbuilt-versioning-model/specs/application-versions/spec.md b/openspec/changes/openbuilt-versioning-model/specs/application-versions/spec.md new file mode 100644 index 00000000..dba3d74c --- /dev/null +++ b/openspec/changes/openbuilt-versioning-model/specs/application-versions/spec.md @@ -0,0 +1,301 @@ +## ADDED Requirements + +### Requirement: REQ-OBV-101 ApplicationVersion schema declared in OpenRegister + +The system SHALL declare an `ApplicationVersion` schema in +`lib/Settings/openbuilt_register.json` under the `openbuilt` register namespace +(Schema.org analogue: `SoftwareApplication`). The schema SHALL define properties: + +- `name` (string, required) — human-readable display label set by the admin + (e.g. `"Production"`, `"Staging"`, `"Development"`). +- `slug` (string, required, kebab-case pattern `^[a-z0-9][a-z0-9-]*[a-z0-9]$`, + min 2, max 48) — URL-safe form (e.g. `"production"`, `"staging"`, `"development"`). +- `manifest` (object, required) — the JSON manifest blob, validated against + `@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json` v1.4.0+. +- `register` (string, required) — name of the per-version OR register that holds + this version's schemas and objects. Convention: + `openbuilt-{appSlug}-{versionSlug}` (e.g. `openbuilt-hello-world-production`). + Actual register provisioning is out of scope for this spec and owned by the + creation-wizard capability. +- `semver` (string, required, pattern + `^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$`) — + this version's semantic version. Initial value `0.1.0`. +- `status` (string, enum `draft | published | archived`, default `draft`) — lifecycle + state, per-version. +- `application` (relation → Application, required) — back-reference to the parent + Application. +- `promotesTo` (relation → ApplicationVersion, optional) — single optional downstream + in the linear promotion chain. Terminal versions have `promotesTo: null`. + +The schema SHALL be imported into OpenRegister at app install / post-migration time +via `ConfigurationService::importFromApp()` in the existing repair step. + +#### Scenario: Schema is available after install + +- **WHEN** the OpenBuilt repair step runs on a fresh install +- **THEN** OpenRegister exposes the `openbuilt/applicationVersion` schema with the + declared properties +- **AND** the schema appears in OR's standard schema listing for the `openbuilt` + register namespace + +#### Scenario: ApplicationVersion row is created via OR REST + +- **WHEN** a client POSTs a payload (carrying `name`, `slug`, `manifest`, `register`, + `semver`, `status`, and an `application` relation) to OR's REST endpoint for the + `openbuilt/applicationVersion` namespace +- **THEN** OR persists the object, returns 201, and the returned object carries an + OR-assigned `uuid` and the submitted fields + +### Requirement: REQ-OBV-102 Initial semver is 0.1.0 on creation + +The system SHALL set `semver` to the plain string `0.1.0` on every newly created +`ApplicationVersion` row that does not supply a `semver` value at creation time. No +prerelease tag, no build metadata. The default applies whether the row is created by +the creation wizard, by the API directly, or by any other consumer. + +#### Scenario: Fresh ApplicationVersion defaults to 0.1.0 + +- **GIVEN** a client creates a new ApplicationVersion without supplying `semver` +- **WHEN** the create succeeds +- **THEN** the persisted row's `semver` is the literal string `0.1.0` + +#### Scenario: Explicit semver at creation is honoured + +- **WHEN** a client creates an ApplicationVersion with `semver: 2.5.0` +- **THEN** the persisted row's `semver` is `2.5.0` (the auto-bump does not override + an explicitly-supplied value at creation) + +### Requirement: REQ-OBV-103 Manifest content change auto-bumps the patch component + +The system SHALL maintain `ApplicationVersion.semver` by patch-bumping it whenever +the saved `manifest` content differs from the previously-saved `manifest` content. +The comparison SHALL be a stable SHA-256 hash over the canonicalised JSON of +`manifest` (recursively sorted keys, no whitespace, normalised number/string +encoding). The previous hash SHALL be persisted on the row in a mapper-internal +`manifestHash` field. Saves that change ONLY metadata (`name`, `description`, +`register`, `permissions`, or any non-`manifest` property) SHALL NOT bump `semver`. +The bump increments the patch component (e.g. `0.1.0 → 0.1.1`, `1.4.7 → 1.4.8`). +Minor and major bumps remain manual (explicit `semver` value on the save payload). + +The hash-diff logic SHALL live in `ApplicationVersionService::onSave()`, called on +the existing OR save path (per ADR-031 §Exceptions(2) — stateful diff is outside +declarative calc vocabulary). + +#### Scenario: Manifest content change bumps patch + +- **GIVEN** an ApplicationVersion with `semver: 0.1.0` and a stored `manifestHash` +- **WHEN** a client saves the same row with a manifest whose canonical-JSON hash + differs from the stored hash +- **THEN** the persisted `semver` is `0.1.1` +- **AND** the persisted `manifestHash` matches the new manifest's canonical hash + +#### Scenario: Metadata-only change does not bump + +- **GIVEN** an ApplicationVersion with `semver: 0.2.3` and an unchanged manifest +- **WHEN** a client saves the row with only `name` and `description` modified (the + `manifest` blob is byte-identical to the previously-saved one) +- **THEN** the persisted `semver` remains `0.2.3` +- **AND** the persisted `manifestHash` is unchanged + +#### Scenario: Whitespace-only manifest change does not bump + +- **GIVEN** an ApplicationVersion with `semver: 0.1.5` +- **WHEN** a client saves the row with a manifest that re-orders keys but has + identical semantic content (the canonicalised JSON is byte-equal) +- **THEN** the persisted `semver` remains `0.1.5` + +### Requirement: REQ-OBV-104 Cycle prevention on the promotesTo chain + +The system SHALL reject any `ApplicationVersion` save where setting `promotesTo` +would create a cycle in the linear promotion chain. The check SHALL walk +`promotesTo` forward from the proposed target up to a hard cap of 100 hops; if the +current row's `uuid` is encountered along the walk, the save is rejected with a +422 response naming the conflict. Self-loops (`promotesTo` equal to the current +row's `uuid`) SHALL be rejected by an `x-openregister-validation` same-row check. +Broader cycle detection SHALL run as +`ApplicationVersionService::guardNoCycle()` (per ADR-031 §Exceptions(1) — +cross-row validation). + +#### Scenario: Self-loop is rejected + +- **WHEN** a client saves an ApplicationVersion with `promotesTo` pointing at its + own `uuid` +- **THEN** the save fails with 422 citing the self-reference +- **AND** no row is updated + +#### Scenario: Indirect cycle is rejected + +- **GIVEN** three ApplicationVersion rows A, B, C with `A → B → C` (chain via + `promotesTo`) +- **WHEN** a client saves C with `promotesTo = A` +- **THEN** the save fails with 422 citing the cycle through B +- **AND** no row is updated + +#### Scenario: Valid linear extension succeeds + +- **GIVEN** three ApplicationVersion rows A, B, C where A and B already form + `A → B` and C has no `promotesTo` +- **WHEN** a client saves B with `promotesTo = C` +- **THEN** the save succeeds (`A → B → C`) + +### Requirement: REQ-OBV-105 Production version is set explicitly on Application + +The `Application.productionVersion` relation pointer SHALL be set explicitly by the +admin (e.g. via the creation wizard's preset or the detail-page version switcher, +both out of scope for this spec). It SHALL NOT be derived from chain terminality. +This survives chain reshaping — inserting or removing versions in the chain SHALL +NOT cause `productionVersion` to silently change. + +On every Application save (or `productionVersion` change), the system SHALL verify +that the referenced ApplicationVersion's `application` relation points back at this +Application. Mismatches SHALL be rejected with a 422 response. Implementation: +`ApplicationVersionService::guardProductionVersionOwnership()`, invoked from the +Application pre-save path (per ADR-031 §Exceptions(1) — cross-row). + +#### Scenario: Setting productionVersion to a valid version succeeds + +- **GIVEN** an Application X and an ApplicationVersion V whose `application` points + at X +- **WHEN** a client sets `X.productionVersion = V` +- **THEN** the save succeeds + +#### Scenario: Setting productionVersion to a foreign version is rejected + +- **GIVEN** an Application X and an ApplicationVersion V whose `application` points + at a different Application Y +- **WHEN** a client sets `X.productionVersion = V` +- **THEN** the save fails with 422 citing the back-reference mismatch +- **AND** X's `productionVersion` is unchanged + +#### Scenario: Inserting an intermediate version does not change productionVersion + +- **GIVEN** an Application X with `productionVersion = V_prod` and a chain + `V_dev → V_prod` +- **WHEN** the admin inserts a new ApplicationVersion `V_stage` with + `V_dev.promotesTo = V_stage` and `V_stage.promotesTo = V_prod` +- **THEN** `X.productionVersion` is still `V_prod` + +### Requirement: REQ-OBV-106 Lifecycle on ApplicationVersion drives BuiltAppRoute upsert + +The `ApplicationVersion` schema SHALL declare its state machine via +`x-openregister-lifecycle` with states (`draft`, `published`, `archived`) and +transitions (`draft → published`, `published → archived`, `archived → draft`). The +`on_transition` action on the `draft → published` edge SHALL upsert a +`BuiltAppRoute` row keyed by the parent Application's `slug` and pointing at the +parent Application's `uuid`. This action is **relocated** from the Application +schema (chain spec `openbuilt-application-register` REQ-OBA-004) — published-ness +is per-version under the new model. + +#### Scenario: Publishing an ApplicationVersion upserts BuiltAppRoute + +- **GIVEN** an Application `` with at least one ApplicationVersion in + `draft` +- **WHEN** an authorised user transitions that ApplicationVersion from `draft` to + `published` +- **THEN** a `BuiltAppRoute` row exists with `slug: ` and `applicationUuid` + matching the parent Application's `uuid` + +#### Scenario: Disallowed transition is rejected + +- **WHEN** a client attempts a transition not declared on the lifecycle (e.g. + `draft → archived` directly) +- **THEN** the system returns a 4xx error +- **AND** the version's `status` is unchanged +- **AND** no audit entry is recorded + +### Requirement: REQ-OBV-107 ApplicationVersion CRUD endpoints + +The system SHALL expose `ApplicationVersionsController` at +`/index.php/apps/openbuilt/api/applications/{slug}/versions` with the following +methods: + +- `GET /` — list ApplicationVersions for the named Application (filtered by the + parent Application's `slug`). +- `GET /{versionSlug}` — fetch one ApplicationVersion by `slug`. +- `POST /` — create a new ApplicationVersion. +- `PUT /{versionSlug}` — update one ApplicationVersion. Triggers the semver + auto-bump (REQ-OBV-103) and cycle guard (REQ-OBV-104). +- `DELETE /{versionSlug}?strategy=` — + delete one ApplicationVersion using the named strategy (see REQ-OBV-108). + +All endpoints SHALL carry `#[NoAdminRequired]` and SHALL respect the parent +Application's `permissions` RBAC block (owners/editors for write, viewers for +read). All endpoints SHALL be registered in `appinfo/routes.php`. + +#### Scenario: List endpoint returns versions for one app + +- **WHEN** an authenticated user with viewer access GETs + `/api/applications/hello-world/versions` +- **THEN** the response is `200 application/json` with an array of ApplicationVersion + rows whose `application` relation points at the `hello-world` Application + +#### Scenario: Cross-app slug returns no versions + +- **WHEN** an authenticated user GETs `/api/applications//versions` for a + slug that has no Application +- **THEN** the response is `404` + +#### Scenario: Create returns 201 with auto-defaulted semver + +- **WHEN** an authenticated owner POSTs a valid ApplicationVersion payload omitting + `semver` +- **THEN** the response is `201` and the returned row has `semver: "0.1.0"` + +### Requirement: REQ-OBV-108 Version-deletion endpoint accepts a strategy + +The system SHALL accept the `?strategy=` query parameter on `DELETE` with three +values: + +- `delete-now` — drop the per-version register named in + `ApplicationVersion.register` (and every object inside it) immediately, then + delete the ApplicationVersion row. +- `orphan-grace` — mark the per-version register as orphaned (set an orphan flag + on the register row, e.g. `_self.orphanedAt = ` or an equivalent + mechanism exposed by the OR floor), delete the ApplicationVersion row, leave the + register data intact. A background job (out of scope for this spec) prunes + orphaned registers after 30 days. +- `keep-register` — delete the ApplicationVersion row only; leave the register + untouched for manual recovery. + +The endpoint SHALL reject deletion of the ApplicationVersion currently pointed at +by `Application.productionVersion` with a 422 response naming the constraint. +Strategy-branching logic lives in +`ApplicationVersionService::deleteVersion($versionUuid, $strategy)`. + +#### Scenario: delete-now drops the register and the version row + +- **GIVEN** an ApplicationVersion `V` whose `register` is `openbuilt--staging` + and at least one object inside that register +- **WHEN** an authenticated owner sends `DELETE …/versions/staging?strategy=delete-now` +- **THEN** the per-version register `openbuilt--staging` no longer exists +- **AND** the ApplicationVersion row `V` no longer exists +- **AND** the response is `204` + +#### Scenario: orphan-grace flags the register and drops the version row + +- **WHEN** an authenticated owner sends + `DELETE …/versions/staging?strategy=orphan-grace` +- **THEN** the per-version register row carries an orphan-mark flag with a + timestamp +- **AND** the ApplicationVersion row no longer exists +- **AND** the register's objects remain intact + +#### Scenario: keep-register drops only the version row + +- **WHEN** an authenticated owner sends + `DELETE …/versions/staging?strategy=keep-register` +- **THEN** the per-version register and its objects remain untouched +- **AND** the ApplicationVersion row no longer exists + +#### Scenario: Deleting the production version is rejected + +- **GIVEN** an Application X with `productionVersion = V_prod` +- **WHEN** a client sends `DELETE …/versions/?strategy=delete-now` +- **THEN** the response is `422` citing the production-version constraint +- **AND** neither `V_prod` nor its register is modified + +#### Scenario: Missing or unknown strategy is rejected + +- **WHEN** a client sends `DELETE …/versions/staging` without a `strategy` query + param (or with an unknown value) +- **THEN** the response is `400` citing the missing/invalid strategy diff --git a/openspec/changes/openbuilt-versioning-model/specs/green-field-migration/spec.md b/openspec/changes/openbuilt-versioning-model/specs/green-field-migration/spec.md new file mode 100644 index 00000000..1e7cbdde --- /dev/null +++ b/openspec/changes/openbuilt-versioning-model/specs/green-field-migration/spec.md @@ -0,0 +1,106 @@ +## ADDED Requirements + +### Requirement: REQ-OBGFM-001 Destructive migration repair step + +The system SHALL ship a Nextcloud `\\OCP\\Migration\\IRepairStep` implementation at +`lib/Repair/MigrateToVersionedModel.php`. The repair step SHALL be registered in +`appinfo/info.xml` under `` so that it runs on every +install and every upgrade. The step SHALL perform a destructive green-field +migration: for every pre-migration `Application` row in the `openbuilt` register, it +SHALL drop the corresponding per-app register (named `openbuilt-{slug}`) entirely +(removing every object inside it) and then delete the `Application` row itself. + +The destructive behaviour is intentional. ADR-002 records that existing OpenBuilt +installs hold only test data and that the new versioning model re-seeds Hello World +at install time via the creation-wizard capability (out of scope for this spec). + +#### Scenario: Migration drops a single pre-migration Application + +- **GIVEN** a pre-migration install with one Application row (`slug: `, + `currentVersion: 00000000-0000-0000-0000-000000000000`) and its per-app register + `openbuilt-` +- **WHEN** the OpenBuilt app's post-migration repair step runs +- **THEN** the Application row no longer exists in the `openbuilt` register +- **AND** the per-app register `openbuilt-` no longer exists +- **AND** every object that lived in that register is gone + +#### Scenario: Migration drops multiple pre-migration Applications + +- **GIVEN** a pre-migration install with three Application rows and their three + per-app registers +- **WHEN** the OpenBuilt app's post-migration repair step runs +- **THEN** all three Application rows are gone +- **AND** all three per-app registers are gone + +### Requirement: REQ-OBGFM-002 Migration is idempotent via versioned-shape short-circuit + +The repair step SHALL be safe to re-run. On every invocation, it SHALL first detect +whether the OpenBuilt schema is already in versioned shape and SHALL short-circuit +to a no-op when it is. The detection SHALL fire on either of: + +- The `applicationVersion` schema exists in the `openbuilt` register, OR +- No pre-migration `Application` row in the `openbuilt` register carries a + `currentVersion` field (i.e. all surviving rows already match the new shape). + +A short-circuit run SHALL produce no log output beyond a single info line indicating +the no-op (e.g. `Migrated-to-versioned-model: schema already in versioned shape, +skipping`). + +#### Scenario: Already-versioned install is a no-op + +- **GIVEN** an install whose `openbuilt` register exposes the `applicationVersion` + schema and contains zero pre-migration Application rows +- **WHEN** the repair step runs +- **THEN** no register is dropped +- **AND** no Application row is deleted +- **AND** the step logs a single short-circuit info line + +#### Scenario: Repair step is re-runnable + +- **GIVEN** the repair step has already run once successfully on a pre-migration + install and dropped two Applications + registers +- **WHEN** the repair step runs again (e.g. on the next OCC upgrade) +- **THEN** the run is a no-op via the short-circuit guard +- **AND** the surviving data is unchanged + +### Requirement: REQ-OBGFM-003 One log line per deleted Application + +The repair step SHALL emit exactly one `$output->info()` log line per deleted +Application, with the literal format: + +``` +Migrated-to-versioned-model: dropped Application '' and register 'openbuilt-' +``` + +where `` is the deleted Application's `slug` value. The line SHALL surface in +standard OCC upgrade output so the migration is observable during deployment. + +#### Scenario: Each deletion is logged individually + +- **GIVEN** a pre-migration install with three Applications whose slugs are + ``, ``, `` +- **WHEN** the repair step runs +- **THEN** the output contains exactly one line + `Migrated-to-versioned-model: dropped Application '' and register + 'openbuilt-'` +- **AND** one line for `` +- **AND** one line for `` + +### Requirement: REQ-OBGFM-004 Per-app register deletion uses OR's register-delete API + +The repair step SHALL drop each per-app register via OpenRegister's +register-delete API (consume the existing OR abstraction per ADR-022 — do not +delete OR-backed tables directly). The deletion SHALL be transactional from the +caller's perspective: if OR's API returns failure for a given register, the +repair step SHALL log the failure, SHALL NOT delete the corresponding Application +row, and SHALL continue with the next Application in the enumeration. The +operator is expected to inspect the OCC log and retry on the next upgrade. + +#### Scenario: Register-delete failure is logged and the Application row is preserved + +- **GIVEN** a pre-migration install where dropping the register `openbuilt-` + fails (e.g. OR returns 500) +- **WHEN** the repair step runs +- **THEN** the failure is logged with the slug and an error message +- **AND** the Application row for `` is NOT deleted +- **AND** the repair step continues with the next pre-migration Application diff --git a/openspec/changes/openbuilt-versioning-model/specs/openbuilt-application-register/spec.md b/openspec/changes/openbuilt-versioning-model/specs/openbuilt-application-register/spec.md new file mode 100644 index 00000000..51cd293b --- /dev/null +++ b/openspec/changes/openbuilt-versioning-model/specs/openbuilt-application-register/spec.md @@ -0,0 +1,143 @@ +## MODIFIED Requirements + +### Requirement: REQ-OBA-001 Application schema registered in OpenRegister + +The system SHALL declare an `Application` schema in +`lib/Settings/openbuilt_register.json` under the `openbuilt` register namespace. +Under the versioned model (ADR-002) the Application schema SHALL define the following +top-level properties: `uuid` (string, UUID-format), `slug` (string, kebab-case +pattern), `name` (string, required), `description` (string, optional), `permissions` +(object, optional — RBAC block per REQ-OBA-006), and `productionVersion` (relation +→ ApplicationVersion, optional — names which ApplicationVersion end users see at the +canonical URL). + +The Application schema SHALL NOT define `manifest`, `version`, `status`, or +`currentVersion` — those properties move to the new `ApplicationVersion` schema or +disappear entirely (`currentVersion` is retired per ADR-002 §Decision). The schema +SHALL be imported into OpenRegister at app install / post-migration time via the +existing repair step. + +#### Scenario: Schema is available after install + +- **WHEN** the OpenBuilt app is installed and its repair step runs +- **THEN** OpenRegister exposes the `openbuilt` register containing the + `Application` schema with the versioned-model property set above +- **AND** the schema's properties match the declaration in + `lib/Settings/openbuilt_register.json` + +#### Scenario: Application object is created via OR REST + +- **WHEN** a client POSTs a payload to OR's REST endpoint for the + `openbuilt/application` namespace with valid `slug`, `name`, and `permissions` +- **THEN** OR persists the object, returns 201, and the returned object carries an + OR-assigned `uuid` and the submitted fields +- **AND** the returned object has no `manifest`, `version`, `status`, or + `currentVersion` field + +### Requirement: REQ-OBA-003 Declarative lifecycle drives state transitions + +Under the versioned model, the `Application` schema SHALL NOT declare a +`status`-based state machine in `x-openregister-lifecycle` — lifecycle is per-version +now. The Application's previous `draft | published | archived` lifecycle SHALL +relocate to the `ApplicationVersion` schema (see capability +`application-versions`, REQ-OBV-106). + +The Application schema MAY retain `x-openregister-lifecycle` only for any cross-row +hooks (e.g. integrity guards) — it SHALL NOT carry a `states` block or `transitions` +in v1 of this change. No `ApplicationLifecycleService` SHALL be written. + +#### Scenario: Application has no status state machine + +- **WHEN** the OpenBuilt repair step runs and imports the Application schema +- **THEN** the imported schema does not expose a `status` enum +- **AND** the imported schema's `x-openregister-lifecycle` carries no `states` or + `transitions` block + +### Requirement: REQ-OBA-004 BuiltAppRoute index for slug lookup + +The system SHALL declare a `BuiltAppRoute` schema in +`lib/Settings/openbuilt_register.json` with properties `slug` (string, required, +kebab-case pattern) and `applicationUuid` (string, UUID-format, required). The `slug` +property SHALL be unique within an organisation. The `BuiltAppRoute` row SHALL be +created or updated by the `on_transition` action that fires when an +`ApplicationVersion` transitions from `draft` to `published` (the action is declared +on `ApplicationVersion`'s lifecycle — see `application-versions`/REQ-OBV-106, not on +`Application`'s). The `applicationUuid` field on the route record points at the +parent Application (i.e. `ApplicationVersion.application.uuid`). + +#### Scenario: Publishing the first version creates a BuiltAppRoute + +- **WHEN** an Application with `slug: hello-world` has its first ApplicationVersion + transition from `draft` to `published` +- **THEN** a `BuiltAppRoute` object exists with `slug: hello-world` and + `applicationUuid` matching the Application's `uuid` + +#### Scenario: Slug uniqueness is enforced per organisation + +- **WHEN** an admin attempts to create a second Application with `slug: hello-world` + in the same organisation +- **THEN** OR returns a 4xx error citing the slug conflict +- **AND** no second `BuiltAppRoute` is created + +## ADDED Requirements + +### Requirement: REQ-OBA-008 Application carries a productionVersion relation + +The `Application` schema SHALL be extended with a `productionVersion` property of +type relation (OR's first-class relation type — not a raw UUID string per ADR-002 +§Decision). The relation SHALL point at exactly one `ApplicationVersion` row. +The property SHALL be optional (an Application that has not yet had its production +version chosen — e.g. immediately after creation, before the creation wizard +finishes — carries no `productionVersion`). + +When populated, `productionVersion` SHALL satisfy the integrity guard in +`application-versions`/REQ-OBV-105: the referenced ApplicationVersion's +`application` relation MUST point back at this Application. Mismatched pointers +SHALL be rejected with a 422 response. + +#### Scenario: Schema declares productionVersion as an optional relation + +- **WHEN** the OpenBuilt repair step runs and imports the Application schema +- **THEN** the imported schema exposes `productionVersion` as a relation property + referencing `applicationVersion` +- **AND** the property is omittable + +#### Scenario: Pointing at a foreign ApplicationVersion is rejected + +- **GIVEN** an Application X and an ApplicationVersion V whose `application` + relation points at a different Application Y +- **WHEN** a client saves `X.productionVersion = V` +- **THEN** the response is `422` citing the back-reference mismatch +- **AND** X's `productionVersion` is unchanged + +## REMOVED Requirements + +### Requirement: REQ-OBA-006 Application schema carries a currentVersion reference + +**Reason**: The `currentVersion` field is retired under the versioned model +(ADR-002). "Which version is live" is now an explicit relation pointer +(`Application.productionVersion` — see REQ-OBA-008), not a denormalised cache +populated by a writeback listener. + +**Migration**: The green-field migration (capability `green-field-migration`) +wipes pre-migration Application rows entirely; no carry-forward of +`currentVersion` is required. Consumers that previously read +`Application.currentVersion` MUST switch to reading +`Application.productionVersion` (a relation to an `ApplicationVersion`, not a UUID +string) and dereference it to obtain the live manifest. + +### Requirement: REQ-OBA-007 Draft-to-published transition declares a snapshot action + +**Reason**: The snapshot-on-publish writeback model is retired under ADR-002. +History is captured by OR object time-travel on the `ApplicationVersion` row +itself — no sibling `ApplicationVersion` rows are spawned on each publish. The +declarative `on_transition` snapshot action and its +`ApplicationVersionSnapshotListener` PHP fallback are both removed. + +**Migration**: The `lib/Listener/ApplicationVersionSnapshotListener.php` file is +deleted along with its `register()` registration in +`lib/AppInfo/Application.php`. The `on_transition` action that performed +`create_relation(ApplicationVersion)` + `self.currentVersion = @result.uuid` is +removed from the Application schema's `x-openregister-lifecycle`. Consumers that +previously relied on the spawn-on-publish audit trail SHOULD switch to OR's +object time-travel on the `ApplicationVersion` row. diff --git a/openspec/changes/openbuilt-versioning-model/specs/openbuilt-version-snapshots/spec.md b/openspec/changes/openbuilt-versioning-model/specs/openbuilt-version-snapshots/spec.md new file mode 100644 index 00000000..569d1462 --- /dev/null +++ b/openspec/changes/openbuilt-versioning-model/specs/openbuilt-version-snapshots/spec.md @@ -0,0 +1,145 @@ +## MODIFIED Requirements + +### Requirement: REQ-OBV-002 Snapshot is created on draft-to-published transition + +The system SHALL NOT spawn sibling `ApplicationVersion` rows on +`draft → published` transitions. Snapshot-on-publish writeback is retired under +ADR-002; the versioned model treats every `ApplicationVersion` row as a long-lived +first-class object, not an append-only snapshot. History on a version is captured +by OR's object-time-travel on the `ApplicationVersion` row itself. + +The system SHALL NOT subscribe any PHP listener +(`ApplicationVersionSnapshotListener` or any successor) to OR's +`ObjectLifecycleTransitionedEvent` for the purpose of creating sibling +ApplicationVersion rows. The `Application.x-openregister-lifecycle` block SHALL NOT +declare a `create_relation(ApplicationVersion)` action on any transition. + +The publish transition lives on `ApplicationVersion` itself (per +`application-versions`/REQ-OBV-106) — moving an existing `ApplicationVersion` from +`draft` to `published` MUST upsert the `BuiltAppRoute` slug index, and nothing +else. + +#### Scenario: Publishing does NOT create a sibling ApplicationVersion + +- **GIVEN** an Application X with one `ApplicationVersion` V in `draft` +- **WHEN** V transitions from `draft` to `published` +- **THEN** only V exists in the ApplicationVersion collection for X +- **AND** no sibling ApplicationVersion row is created +- **AND** OR's audit trail records the lifecycle transition on V (not a new row) + +#### Scenario: No snapshot listener subscribed + +- **WHEN** the OpenBuilt app boots +- **THEN** no `ApplicationVersionSnapshotListener` (or successor) is registered as + an event listener for `ObjectLifecycleTransitionedEvent` + +### Requirement: REQ-OBV-003 Rollback restores a previous snapshot as the draft manifest + +The system SHALL support rolling back any `ApplicationVersion` to a prior point in +its OR object-history via OR's time-travel API on the version row itself — +restoring a previous state of an `ApplicationVersion` MUST NOT be implemented by +copying from a sibling snapshot row (the append-only snapshot model is retired +under ADR-002). + +The rollback action SHALL restore the chosen historical state of the row's +`manifest` (and any other fields captured by OR's time-travel), SHALL leave the +version's `status` at whatever the historical state recorded, and SHALL trigger +the manifest-hash semver bump (per `application-versions`/REQ-OBV-103) only when +the restored `manifest` differs from the immediately-prior saved state. + +#### Scenario: Rollback uses OR object-time-travel on the version row + +- **GIVEN** an ApplicationVersion V with three historical states recorded by OR + object-history (states t0, t1, t2) +- **WHEN** an authorised user rolls V back to state t1 +- **THEN** OR's time-travel API is called on V to restore t1 +- **AND** no sibling `ApplicationVersion` row is created +- **AND** V's `manifest` matches t1's `manifest` + +### Requirement: REQ-OBV-005 Diff endpoint returns two manifest blobs in one call + +The system SHALL expose +`GET /index.php/apps/openbuilt/api/applications/{slug}/versions/diff?from={fromRef}&to={toRef}` + +The diff endpoint changes shape under the versioned model: diffing two +`ApplicationVersion` rows is the canonical case; comparing two historical states +of one ApplicationVersion (time-travel diff on a single row) is the second +supported case. + +The endpoint URL parameters work as follows: +where `{fromRef}` and `{toRef}` are either: + +- An ApplicationVersion `slug` (e.g. `staging`) — diff is against the current saved + state of that version's manifest. +- The literal `current:` (e.g. `current:staging`) — equivalent to + the bare slug above; reserved syntax for forward compatibility. +- A version-history reference `history::` — diff is against + the named OR object-history revision of that version. + +The endpoint SHALL return a JSON body `{ from: { manifest, semver, savedAt }, to: +{ manifest, semver, savedAt } }`. The endpoint SHALL carry `#[NoAdminRequired]` and +respect the parent Application's `permissions` RBAC block (viewers may diff). +Missing references SHALL return `404`. + +#### Scenario: Diff two ApplicationVersions by slug + +- **WHEN** an authorised viewer GETs the diff endpoint with `from=development` and + `to=production` for an Application `` +- **THEN** the response is `200 application/json` +- **AND** the body contains the development version's manifest under `from` and the + production version's manifest under `to` + +#### Scenario: Diff two historical revisions of one version + +- **WHEN** an authorised viewer GETs the diff endpoint with + `from=history:staging:r5` and `to=history:staging:r9` +- **THEN** the response is `200` +- **AND** `from.manifest` is the manifest captured at revision r5 of the staging + version; `to.manifest` is the manifest at revision r9 + +#### Scenario: Missing version returns 404 + +- **WHEN** an authorised viewer GETs the diff endpoint with `from=` for a + version slug that does not exist +- **THEN** the response is `404` with a JSON error body +- **AND** no partial data is leaked + +## REMOVED Requirements + +### Requirement: REQ-OBV-006 Current version reference is maintained on the Application + +**Reason**: The `Application.currentVersion` field is retired under ADR-002. "Which +version is live" is now an explicit relation pointer (`Application.productionVersion`) +maintained by the admin, not a denormalised cache maintained by a writeback listener. + +**Migration**: The green-field migration (capability `green-field-migration`) wipes +all pre-migration Application rows; no carry-forward is required. Consumers that +previously read `Application.currentVersion` MUST switch to reading +`Application.productionVersion` (a relation to an `ApplicationVersion`, not a UUID +string) and dereference it to obtain the live manifest. The +`ApplicationVersionSnapshotListener` is deleted in this change. + +### Requirement: REQ-OBV-004 Version history is retained without retention cap + +**Reason**: Snapshot rows no longer exist — version history is now OR object-history +on the `ApplicationVersion` row itself. Retention policy is therefore inherited from +OR's object-history retention (which currently has no cap either), not from a +spec-local requirement. + +**Migration**: No application-level retention logic to remove. OpenBuilt callers +that previously enumerated `ApplicationVersion` rows for history MUST switch to +querying OR's object-history API on a single `ApplicationVersion` row instead. + +### Requirement: REQ-OBV-001 ApplicationVersion schema declared in OpenRegister + +**Reason**: The legacy ApplicationVersion schema (snapshot-row shape with +`applicationUuid` string, `publishedAt`, `publishedBy`) is superseded by the +versioned-model ApplicationVersion schema declared in +`application-versions`/REQ-OBV-101 (long-lived row with `register`, `semver`, +`promotesTo`, real `application` relation, per-version `status` lifecycle). + +**Migration**: The legacy snapshot rows do not exist under the new model — there +is no row-shape conversion to perform. The new schema is declared from scratch in +`lib/Settings/openbuilt_register.json` as part of the green-field migration; the +capability boundary moves from `openbuilt-version-snapshots` to +`application-versions`. diff --git a/openspec/changes/openbuilt-versioning-model/tasks.md b/openspec/changes/openbuilt-versioning-model/tasks.md new file mode 100644 index 00000000..f4a93e1a --- /dev/null +++ b/openspec/changes/openbuilt-versioning-model/tasks.md @@ -0,0 +1,232 @@ +## 1. Schema split in lib/Settings/openbuilt_register.json + +- [ ] 1.1 Open `lib/Settings/openbuilt_register.json` and locate the existing + `Application` schema entry under `components.schemas`. +- [ ] 1.2 Remove the `manifest`, `version`, `status`, and `currentVersion` properties + from `Application`. Remove `manifest`, `version`, `status` from `required`. +- [ ] 1.3 Add `productionVersion` to `Application.properties` as an OR relation + pointing at `applicationVersion` (use OR's first-class relation type per + ADR-002, not a raw UUID-string). +- [ ] 1.4 Remove the `x-openregister-lifecycle` `states` and `transitions` block + from `Application` (lifecycle moves to ApplicationVersion). Remove any + `on_transition` action that assigns `self.currentVersion = @result.uuid` + and any `create_relation(ApplicationVersion)` action. +- [ ] 1.5 Remove the `on_transition` action that upserts `BuiltAppRoute` from + `Application.x-openregister-lifecycle` (this action moves to ApplicationVersion + per spec REQ-OBV-106; the upsert key remains the parent Application's `slug`). +- [ ] 1.6 Add a new top-level entry under `components.schemas` for + `ApplicationVersion` with these properties (per spec REQ-OBV-101): `name` + (string, required), `slug` (string, required, kebab-case pattern, min 2, max + 48), `manifest` (object, required, JSON-schema-ref to + `@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json` v1.4.0+), + `register` (string, required, pattern matching + `^openbuilt-[a-z0-9-]+-[a-z0-9-]+$`), `semver` (string, required, semver + pattern, default `0.1.0`), `status` (string, enum + `draft | published | archived`, default `draft`), `application` (relation + → Application, required), `promotesTo` (relation → ApplicationVersion, + optional). +- [ ] 1.7 Add `x-openregister-lifecycle` to `ApplicationVersion` with states + (`draft | published | archived`) and transitions (`draft → published`, + `published → archived`, `archived → draft`). On the `draft → published` + edge, declare the upsert-BuiltAppRoute action using the parent Application's + `slug` (resolve via the `application` relation). +- [ ] 1.8 Declare an `x-openregister-validation` block on `ApplicationVersion` + that rejects same-row self-loops (`promotesTo !== self.uuid`) as the cheap + first-filter cycle check (broader cross-row check lives in + `ApplicationVersionService`). +- [ ] 1.9 Bump the schema file's `info.version` (e.g. `0.2.0 → 0.3.0`) to + reflect the breaking shape change. +- [ ] 1.10 Validate the schema file is well-formed JSON + (`php -r "json_decode(file_get_contents('lib/Settings/openbuilt_register.json'), false, 512, JSON_THROW_ON_ERROR);"`). + +## 2. Retire the ApplicationVersionSnapshotListener + +- [ ] 2.1 Delete the file + `lib/Listener/ApplicationVersionSnapshotListener.php` outright (do not leave + a stub — see spec REMOVED REQ-OBA-007). +- [ ] 2.2 Open `lib/AppInfo/Application.php` and remove the + `$context->registerEventListener(ObjectLifecycleTransitionedEvent::class, + ApplicationVersionSnapshotListener::class)` call (or equivalent) from the + `register()` method. +- [ ] 2.3 Remove the corresponding `use` import for the listener class. +- [ ] 2.4 Update / delete the PHPUnit test file + `tests/Unit/Listener/ApplicationVersionSnapshotListenerTest.php` (if it + exists). Adjust `phpunit.xml` `` config if it referenced the + file directly. + +## 3. ApplicationVersionService — semver, cycle, deletion logic + +- [ ] 3.1 Create `lib/Service/ApplicationVersionService.php` (constructor takes + `IRootFolder`, `LoggerInterface`, OR's `ObjectService` / + `ConfigurationService` per ADR-022 — no app-local DB access). +- [ ] 3.2 Implement `canonicaliseManifest(array $manifest): string` — recursive + key sort, JSON_THROW_ON_ERROR, no whitespace. +- [ ] 3.3 Implement `hashManifest(array $manifest): string` returning the SHA-256 + hex digest of the canonicalised manifest. +- [ ] 3.4 Implement `bumpPatch(string $semver): string` (e.g. + `0.1.0 → 0.1.1`, `2.5.7 → 2.5.8`) with a 4xx error path on malformed input. +- [ ] 3.5 Implement `onSave(ApplicationVersion $current, ApplicationVersion $next): + void` that compares hashes and patch-bumps `next.semver` + updates + `next.manifestHash` only on `manifest` content change. Metadata-only + changes leave `semver` and `manifestHash` untouched (spec REQ-OBV-103). +- [ ] 3.6 Implement `guardNoCycle(string $currentUuid, ?string $proposedTargetUuid): + void` walking `promotesTo` forward from the proposed target up to 100 hops; + throw a 422 validation exception if `$currentUuid` is encountered (spec + REQ-OBV-104). Throw a 4xx if the 100-hop cap is exceeded (chain corruption). +- [ ] 3.7 Implement `guardProductionVersionOwnership(Application $app, + ApplicationVersion $proposed): void` verifying the back-reference (spec + REQ-OBV-105 / REQ-OBA-008). +- [ ] 3.8 Implement `deleteVersion(string $versionUuid, string $strategy): void` + branching on `delete-now | orphan-grace | keep-register` (spec REQ-OBV-108). + Reject the call when the version is the parent Application's + `productionVersion`. Use OR's register-delete API for `delete-now`; set + an orphan-mark flag on the register row for `orphan-grace`; no-op on the + register for `keep-register`. +- [ ] 3.9 Add `manifestHash` as a private mapper-internal field on the + `ApplicationVersion` row (per design Decision 4 — not in the public schema). + Mechanism depends on what the OR floor exposes (e.g. a `_self` namespaced + key or a mapper-only column); pick the lightest-touch option that survives + schema-import round-trips. +- [ ] 3.10 Unit tests for the service: canonicalisation determinism, + patch-bump arithmetic, cycle detection on linear / branching / 100-hop + chains, production-version guard, deletion strategies (each branch and the + production-version refusal). + +## 4. ApplicationVersionsController — CRUD + delete-with-strategy + +- [ ] 4.1 Create `lib/Controller/ApplicationVersionsController.php` extending + `ApiController`. Inject `ApplicationVersionService`, OR's `ObjectService`, + and `IUserSession` / `IGroupManager` for RBAC. +- [ ] 4.2 Implement `index($slug)` — list ApplicationVersions for the named + Application (spec REQ-OBV-107). +- [ ] 4.3 Implement `show($slug, $versionSlug)` — fetch one. +- [ ] 4.4 Implement `create($slug, $payload)` — POST creating an ApplicationVersion; + default `semver` to `0.1.0` when omitted (spec REQ-OBV-102). +- [ ] 4.5 Implement `update($slug, $versionSlug, $payload)` — PUT; runs + `ApplicationVersionService::onSave()` (semver auto-bump) and + `guardNoCycle()` pre-save. +- [ ] 4.6 Implement `destroy($slug, $versionSlug, $strategy)` — DELETE accepting + `?strategy=` query param; rejects missing / unknown strategy with 400; + delegates to `ApplicationVersionService::deleteVersion()`. +- [ ] 4.7 Annotate every method with `#[NoAdminRequired]`. Enforce + Application-level RBAC (owners/editors for write, viewers for read) per + spec REQ-OBV-107. +- [ ] 4.8 Register routes in `appinfo/routes.php`: + - `GET /api/applications/{slug}/versions` → `index` + - `GET /api/applications/{slug}/versions/{versionSlug}` → `show` + - `POST /api/applications/{slug}/versions` → `create` + - `PUT /api/applications/{slug}/versions/{versionSlug}` → `update` + - `DELETE /api/applications/{slug}/versions/{versionSlug}` → `destroy` +- [ ] 4.9 Newman / Postman test collection for the five endpoints + the deletion + strategies + the production-version refusal. + +## 5. Green-field migration repair step + +- [ ] 5.1 Create `lib/Repair/MigrateToVersionedModel.php` implementing + `\\OCP\\Migration\\IRepairStep`. Constructor takes OR's `ObjectService` / + `RegisterService` and `LoggerInterface`. +- [ ] 5.2 Implement `getName(): string` returning a clear name (e.g. + `"Migrate OpenBuilt to versioned app model (DESTRUCTIVE)"`). +- [ ] 5.3 Implement the short-circuit detection in `run()`: query the `openbuilt` + register schemas; if `applicationVersion` schema exists OR no Application + rows carry a `currentVersion` field, log + `Migrated-to-versioned-model: schema already in versioned shape, skipping` + and return (spec REQ-OBGFM-002). +- [ ] 5.4 Implement the enumeration: fetch every Application row in the + `openbuilt` register via `ObjectService::findAll('openbuilt/application')`. +- [ ] 5.5 For each row: derive the per-app register name (`openbuilt-{slug}`); + call OR's register-delete API; on failure log the error with the slug and + continue to the next row WITHOUT deleting the Application (spec + REQ-OBGFM-004). On success, delete the Application row. +- [ ] 5.6 On every successful row deletion, emit + `$output->info("Migrated-to-versioned-model: dropped Application '' + and register 'openbuilt-'")` (spec REQ-OBGFM-003). +- [ ] 5.7 Top-of-file docblock with `@destructive` marker and a SAFETY note: + "This step deletes every pre-migration Application row and its per-app + register. ADR-002 records the explicit decision to accept this data loss; + existing installs hold only test data." +- [ ] 5.8 Register the step in `appinfo/info.xml` under + ``. +- [ ] 5.9 PHPUnit test: pre-migration fixture with three Application rows + three + registers — confirm three deletions + three log lines + zero leftover rows. + Second test: idempotency — running on already-migrated state is a no-op + with the single short-circuit log line. +- [ ] 5.10 PHPUnit test for partial failure: simulate OR register-delete + returning failure for one of three rows — confirm the failing row is NOT + deleted from the Application table, the other two ARE deleted, and the + failure is logged. + +## 6. Lifecycle-action relocation (BuiltAppRoute upsert) + +- [ ] 6.1 In `lib/Settings/openbuilt_register.json`, on + `ApplicationVersion.x-openregister-lifecycle.transitions[draft→published] + .actions`, declare an upsert of `BuiltAppRoute` keyed by the parent + Application's `slug` (resolved via `application` relation) with + `applicationUuid` set to that parent's `uuid`. +- [ ] 6.2 Verify by Playwright / Newman: creating an Application + first + ApplicationVersion, then publishing the version, results in one + `BuiltAppRoute` row with the right slug → applicationUuid mapping. + +## 7. Application schema integrity (productionVersion guard) + +- [ ] 7.1 Wire `ApplicationVersionService::guardProductionVersionOwnership()` into + the Application pre-save path. Mechanism options (pick the lightest): + - Subscribe an `ObjectSavingEvent` listener for the Application schema + and call the guard. + - Wire it into an `ApplicationService::save()` shim if one exists. +- [ ] 7.2 PHPUnit: setting `Application.productionVersion` to a foreign + ApplicationVersion is rejected with 422; setting it to a back-referencing + ApplicationVersion succeeds. + +## 8. Retire / no-op the SeedHelloWorld repair step + +- [ ] 8.1 Open `lib/Repair/SeedHelloWorld.php` and confirm whether it writes + `currentVersion` (it likely does — see spec REMOVED REQ-OBA-006). +- [ ] 8.2 **Decision**: delete the file entirely. Hello-world seeding under the + new model is owned by the creation-wizard spec + (`openbuilt-app-creation-wizard`); leaving a stale seed step here would + conflict with the green-field migration on every upgrade. +- [ ] 8.3 Remove the `` registration of `SeedHelloWorld` from + `appinfo/info.xml`. +- [ ] 8.4 Delete the related PHPUnit test + (`tests/Unit/Repair/SeedHelloWorldTest.php`) if it exists. +- [ ] 8.5 Confirm in a manual dev-install run that no Hello World data appears + post-install (the wizard spec will re-introduce it on a later wave). + +## 9. End-to-end verification + +- [ ] 9.1 Apply the change in a fresh dev container; confirm the + `MigrateToVersionedModel` repair step logs the short-circuit line + (no pre-migration data on a fresh install). +- [ ] 9.2 Create one Application + one ApplicationVersion via OR REST; confirm + the row has `semver: "0.1.0"` and an internal `manifestHash`. +- [ ] 9.3 PUT the ApplicationVersion with a `manifest` content change — confirm + `semver` is now `0.1.1` and `manifestHash` updated. +- [ ] 9.4 PUT the ApplicationVersion with only `name` changed — confirm `semver` + and `manifestHash` are unchanged. +- [ ] 9.5 Create a second ApplicationVersion and form a chain + (`.promotesTo = `); attempt a cycle (`.promotesTo = `) and + confirm 422. +- [ ] 9.6 Set `Application.productionVersion = ` (where `.application` is + this Application); confirm success. Try setting it to a foreign + ApplicationVersion and confirm 422. +- [ ] 9.7 Publish ``; confirm a `BuiltAppRoute` row is upserted with the + parent slug. +- [ ] 9.8 Attempt to DELETE `` (which is `productionVersion`); confirm 422. +- [ ] 9.9 DELETE `` with `?strategy=delete-now`; confirm both the version row + and the per-version register `openbuilt--` are gone. +- [ ] 9.10 Re-run the repair step (`occ maintenance:repair`); confirm it + short-circuits without modifying any data. + +## 10. Quality gates + +- [ ] 10.1 Run `composer check:strict` (PHPCS, PHPMD, Psalm, PHPStan); fix every + finding (no pre-existing issues left unaddressed — memory rule + `fix-all-issues-encountered`). +- [ ] 10.2 Run the full PHPUnit suite (`composer test`); confirm all pass. +- [ ] 10.3 Re-run `openspec validate openbuilt-versioning-model --strict`; + confirm clean. +- [ ] 10.4 Open PR against `development` (memory rule + `feature-branches-from-dev`); reference ADR-002, this change id, and the + four sibling spec ids in the description so reviewers can trace the chain + delivery wave. diff --git a/psalm.xml b/psalm.xml index a8946bc9..d06a8b9b 100644 --- a/psalm.xml +++ b/psalm.xml @@ -69,7 +69,11 @@ + + + + diff --git a/tests/Unit/Listener/ApplicationVersionSnapshotListenerTest.php b/tests/Unit/Listener/ApplicationVersionSnapshotListenerTest.php deleted file mode 100644 index f23b2bf2..00000000 --- a/tests/Unit/Listener/ApplicationVersionSnapshotListenerTest.php +++ /dev/null @@ -1,479 +0,0 @@ - - * @copyright 2026 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://conduction.nl - */ - -declare(strict_types=1); - -namespace OCA\OpenBuilt\Tests\Unit\Listener; - -use OCA\OpenBuilt\Listener\ApplicationVersionSnapshotListener; -use OCA\OpenRegister\Db\ObjectEntity; -use OCA\OpenRegister\Event\ObjectTransitionedEvent; -use OCA\OpenRegister\Service\ObjectService; -use OCP\EventDispatcher\Event; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; - -/** - * Tests for the publish-transition listener. - * - * NOTE: the listener accepts the real ObjectTransitionedEvent type-hint, but - * we cannot construct it directly here (it requires an ObjectEntity which - * lives in OR). Instead we fabricate a runtime-equivalent anonymous subclass - * via PHPUnit's mock builder against the real class definition; PHP 8's - * named-args call site only inspects `instanceof`, not equality. - */ -class ApplicationVersionSnapshotListenerTest extends TestCase -{ - - /** - * Mock logger. - * - * @var LoggerInterface&MockObject - */ - private LoggerInterface&MockObject $logger; - - /** - * Mock OR ObjectService. - * - * @var ObjectService&MockObject - */ - private ObjectService&MockObject $objectService; - - /** - * Set up shared fixtures. - * - * @return void - */ - protected function setUp(): void - { - parent::setUp(); - - // ObjectTransitionedEvent is part of OpenRegister's lifecycle feature; it - // is absent from OR's `main` (the ref the CI workflow currently installs). - // Skip rather than fatal-error on the missing class until the lifecycle - // feature lands on the OR release this app is tested against. - if (class_exists(ObjectTransitionedEvent::class) === false) { - $this->markTestSkipped('OpenRegister does not provide ObjectTransitionedEvent (lifecycle feature not in the installed OR release).'); - } - - $this->logger = $this->createMock(LoggerInterface::class); - $this->objectService = $this->makeObjectServiceMock(); - - // Default: no BuiltAppRoute exists yet → the listener creates one. - $this->objectService->method('searchObjectsBySlug')->willReturn([]); - }//end setUp() - - /** - * Build an ObjectService test double exposing the surface the listener uses. - * - * `searchObjectsBySlug()` was added to ObjectService after the OpenRegister - * release this app is built+tested against, so it is added via - * `MockBuilder::addMethods()` when the real class does not declare it (and - * mocked normally when it does) — the listener already wraps the call in a - * try/catch that treats a missing method as "no route yet". - * - * @return ObjectService&MockObject - */ - private function makeObjectServiceMock(): ObjectService&MockObject - { - $builder = $this->getMockBuilder(ObjectService::class)->disableOriginalConstructor(); - if (method_exists(ObjectService::class, 'searchObjectsBySlug') === true) { - $builder->onlyMethods(['saveObject', 'searchObjectsBySlug']); - } else { - $builder->onlyMethods(['saveObject'])->addMethods(['searchObjectsBySlug']); - } - - return $builder->getMock(); - }//end makeObjectServiceMock() - - /** - * Build a fake ObjectTransitionedEvent. - * - * We mock the real OR class (loaded via the openregister submodule on - * the include path) but stub the accessors the listener calls. - * - * @param string $schema Schema slug. - * @param string $from Transition `from` state. - * @param string $to Transition `to` state. - * @param array $serialisedObject What the inner ObjectEntity - * jsonSerialize() returns. - * @param string|null $userId Actor uid (null for system). - * - * @return MockObject The configured event stub. - */ - private function makeEvent( - string $schema, - string $from, - string $to, - array $serialisedObject, - ?string $userId='alice' - ): MockObject { - $entity = $this->createMock(ObjectEntity::class); - $entity->method('jsonSerialize')->willReturn($serialisedObject); - - $event = $this->getMockBuilder(ObjectTransitionedEvent::class) - ->disableOriginalConstructor() - ->onlyMethods(['getSchema', 'getFrom', 'getTo', 'getObject', 'getUserId']) - ->getMock(); - $event->method('getSchema')->willReturn($schema); - $event->method('getFrom')->willReturn($from); - $event->method('getTo')->willReturn($to); - $event->method('getObject')->willReturn($entity); - $event->method('getUserId')->willReturn($userId); - - return $event; - }//end makeEvent() - - /** - * Build a fake ObjectEntity-like value that jsonSerialize()s to the given array. - * - * @param array $payload The serialised payload. - * - * @return MockObject - */ - private function makeReturnedEntity(array $payload): MockObject - { - $entity = $this->createMock(ObjectEntity::class); - $entity->method('jsonSerialize')->willReturn($payload); - return $entity; - }//end makeReturnedEntity() - - /** - * Resolve the `object`/`schema` named-arg pair out of a captured saveObject() call. - * - * @param array $args Captured variadic args. - * - * @return array{0: mixed, 1: mixed} [payload, schema] - */ - private function unpackSave(array $args): array - { - // ObjectService::saveObject(array|ObjectEntity $object, ?array $extend, mixed $register, mixed $schema): - // a named-arg call site (object/register/schema) yields positional args [object, [], register, schema]. - $payload = ($args['object'] ?? ($args[0] ?? null)); - $schema = ($args['schema'] ?? ($args[3] ?? null)); - return [$payload, $schema]; - }//end unpackSave() - - /** - * Non-Application transitions (e.g. a transition on a different schema) - * must be a no-op — no OR write, no log. - * - * @return void - */ - public function testHandleIgnoresNonApplicationSchema(): void - { - $event = $this->makeEvent( - schema: 'hello-message', - from: 'draft', - to: 'published', - serialisedObject: ['@self' => ['id' => 'irrelevant']] - ); - - // The OR save must never fire for a non-Application transition. - $this->objectService->expects(self::never())->method('saveObject'); - - $listener = new ApplicationVersionSnapshotListener( - logger: $this->logger, - objectService: $this->objectService - ); - $listener->handle($event); - }//end testHandleIgnoresNonApplicationSchema() - - /** - * Non-publish transitions on the Application schema (e.g. published → - * archived) must be a no-op. - * - * @return void - */ - public function testHandleIgnoresNonPublishTransition(): void - { - $event = $this->makeEvent( - schema: 'application', - from: 'published', - to: 'archived', - serialisedObject: ['@self' => ['id' => 'app-1']] - ); - - $this->objectService->expects(self::never())->method('saveObject'); - - $listener = new ApplicationVersionSnapshotListener( - logger: $this->logger, - objectService: $this->objectService - ); - $listener->handle($event); - }//end testHandleIgnoresNonPublishTransition() - - /** - * Happy path: draft→published on Application upserts the BuiltAppRoute, - * creates the ApplicationVersion AND patches Application.currentVersion. - * - * @return void - */ - public function testHandleUpsertsRouteCreatesSnapshotAndPatchesCurrentVersionOnPublish(): void - { - $manifest = [ - 'version' => '1.0.0', - 'menu' => [], - 'pages' => [['id' => 'p1', 'route' => '/', 'type' => 'index']], - ]; - - $applicationData = [ - '@self' => ['id' => 'app-uuid-1'], - 'slug' => 'hello-world', - 'version' => '1.0.0', - 'manifest' => $manifest, - 'status' => 'published', - ]; - - $event = $this->makeEvent( - schema: 'application', - from: 'draft', - to: 'published', - serialisedObject: $applicationData, - userId: 'alice' - ); - - // Three saveObject calls: BuiltAppRoute upsert, ApplicationVersion - // create, then the Application currentVersion writeback. - $snapshotEntity = $this->makeReturnedEntity(['@self' => ['id' => 'snap-uuid-1']]); - - $captured = []; - $this->objectService->expects(self::exactly(3)) - ->method('saveObject') - ->willReturnCallback(function (...$args) use (&$captured, $snapshotEntity) { - $captured[] = $args; - [$payload, $schema] = $this->unpackSave($args); - if ($schema === 'application-version') { - return $snapshotEntity; - } - return $this->makeReturnedEntity($payload ?? []); - }); - - $listener = new ApplicationVersionSnapshotListener( - logger: $this->logger, - objectService: $this->objectService - ); - $listener->handle($event); - - self::assertCount(3, $captured, 'Expected exactly 3 saveObject() calls (route + snapshot + writeback)'); - - // Index calls by schema so the assertions don't depend on call order. - $bySchema = []; - foreach ($captured as $args) { - [$payload, $schema] = $this->unpackSave($args); - $bySchema[$schema] = $payload; - } - - // BuiltAppRoute upsert — slug → applicationUuid. - self::assertArrayHasKey('built-app-route', $bySchema); - self::assertSame('hello-world', $bySchema['built-app-route']['slug']); - self::assertSame('app-uuid-1', $bySchema['built-app-route']['applicationUuid']); - - // Snapshot — carries applicationUuid + manifest + actor uid. - self::assertArrayHasKey('application-version', $bySchema); - self::assertSame('app-uuid-1', $bySchema['application-version']['applicationUuid']); - self::assertSame($manifest, $bySchema['application-version']['manifest']); - self::assertSame('1.0.0', $bySchema['application-version']['version']); - self::assertSame('alice', $bySchema['application-version']['publishedBy']); - self::assertArrayHasKey('publishedAt', $bySchema['application-version']); - - // Writeback — Application gets currentVersion + status reset. - self::assertArrayHasKey('application', $bySchema); - self::assertSame('snap-uuid-1', $bySchema['application']['currentVersion']); - // Per design.md Decision 3 the status is reset to draft so the next edit cycle starts clean. - self::assertSame('draft', $bySchema['application']['status']); - }//end testHandleUpsertsRouteCreatesSnapshotAndPatchesCurrentVersionOnPublish() - - /** - * When a BuiltAppRoute already points at this Application the listener - * leaves it untouched — only the snapshot + writeback fire. - * - * @return void - */ - public function testHandleSkipsRouteUpsertWhenAlreadyCorrect(): void - { - $applicationData = [ - '@self' => ['id' => 'app-uuid-9'], - 'slug' => 'already-routed', - 'version' => '2.0.0', - 'manifest' => ['version' => '2.0.0', 'pages' => []], - ]; - - // Re-stub searchObjectsBySlug for this test: the route exists and is correct. - $this->objectService = $this->makeObjectServiceMock(); - $this->objectService->method('searchObjectsBySlug')->willReturn([ - ['@self' => ['id' => 'route-uuid-9'], 'slug' => 'already-routed', 'applicationUuid' => 'app-uuid-9'], - ]); - - $event = $this->makeEvent( - schema: 'application', - from: 'draft', - to: 'published', - serialisedObject: $applicationData - ); - - $schemas = []; - $this->objectService->expects(self::exactly(2)) - ->method('saveObject') - ->willReturnCallback(function (...$args) use (&$schemas) { - [, $schema] = $this->unpackSave($args); - $schemas[] = $schema; - if ($schema === 'application-version') { - return $this->makeReturnedEntity(['@self' => ['id' => 'snap-uuid-9']]); - } - return $this->makeReturnedEntity(['@self' => ['id' => 'app-uuid-9']]); - }); - - $listener = new ApplicationVersionSnapshotListener( - logger: $this->logger, - objectService: $this->objectService - ); - $listener->handle($event); - - self::assertNotContains('built-app-route', $schemas, 'route already correct → no route saveObject'); - self::assertContains('application-version', $schemas); - self::assertContains('application', $schemas); - }//end testHandleSkipsRouteUpsertWhenAlreadyCorrect() - - /** - * Missing ObjectService responses must not crash — the listener swallows - * throwables and logs an error (publish itself already succeeded). - * - * @return void - */ - public function testHandleLogsAndDoesNotThrowWhenOrServiceFails(): void - { - $event = $this->makeEvent( - schema: 'application', - from: 'draft', - to: 'published', - serialisedObject: [ - '@self' => ['id' => 'app-uuid-2'], - 'slug' => 'broken', - 'manifest' => ['v' => 1], - 'version' => '1.0.0', - ] - ); - - // OR throws on the first saveObject — the listener must catch + log. - $this->objectService->method('saveObject') - ->willThrowException(new \RuntimeException('OR is down')); - - // Error path: the listener's catch-all logs an error and returns. - $this->logger->expects(self::atLeastOnce()) - ->method('error') - ->with(self::stringContains('ApplicationVersionSnapshotListener failed')); - - $listener = new ApplicationVersionSnapshotListener( - logger: $this->logger, - objectService: $this->objectService - ); - - // The handle() must not propagate the runtime exception — reaching this - // line at all is the assertion; the logger->error() expectation above is - // verified at teardown. - $listener->handle($event); - }//end testHandleLogsAndDoesNotThrowWhenOrServiceFails() - - /** - * Repeat-publish: first publish creates the route + a snapshot; the - * second publish finds the route already correct (skip) and appends a - * second snapshot row (append-only history per design.md Decision 3). - * The contract pinned here is "each publish appends its own snapshot" — - * duplicate-detection is OR's job, not the listener's. - * - * @return void - */ - public function testHandleProducesIndependentSnapshotsOnRepeatPublish(): void - { - $manifest = ['version' => '1.0.0', 'pages' => []]; - $serialisedObject = [ - '@self' => ['id' => 'app-uuid-3'], - 'slug' => 'idempotent', - 'version' => '1.0.0', - 'manifest' => $manifest, - ]; - - $event = $this->makeEvent( - schema: 'application', - from: 'draft', - to: 'published', - serialisedObject: $serialisedObject - ); - - // 1st publish: no route yet. 2nd publish: route exists and is correct. - $this->objectService = $this->makeObjectServiceMock(); - $this->objectService->method('searchObjectsBySlug')->willReturnOnConsecutiveCalls( - [], - [['@self' => ['id' => 'route-3'], 'slug' => 'idempotent', 'applicationUuid' => 'app-uuid-3']], - ); - - $schemas = []; - $this->objectService->method('saveObject') - ->willReturnCallback(function (...$args) use (&$schemas) { - [, $schema] = $this->unpackSave($args); - $schemas[] = $schema; - if ($schema === 'application-version') { - return $this->makeReturnedEntity(['@self' => ['id' => 'snap-'.count($schemas)]]); - } - return $this->makeReturnedEntity(['@self' => ['id' => 'app-uuid-3']]); - }); - - $listener = new ApplicationVersionSnapshotListener( - logger: $this->logger, - objectService: $this->objectService - ); - $listener->handle($event); - $listener->handle($event); - - // publish 1 = route + snapshot + writeback (3); publish 2 = snapshot + writeback (2). - self::assertSame(5, count($schemas)); - self::assertSame(2, count(array_filter($schemas, static fn ($s) => $s === 'application-version'))); - self::assertSame(1, count(array_filter($schemas, static fn ($s) => $s === 'built-app-route'))); - }//end testHandleProducesIndependentSnapshotsOnRepeatPublish() - - /** - * Sanity: a base Event (not the ObjectTransitionedEvent subclass) must be - * filtered out by the instanceof guard. - * - * @return void - */ - public function testHandleIgnoresGenericEvent(): void - { - $this->objectService->expects(self::never())->method('saveObject'); - - $listener = new ApplicationVersionSnapshotListener( - logger: $this->logger, - objectService: $this->objectService - ); - $listener->handle(new Event()); - }//end testHandleIgnoresGenericEvent() -}//end class diff --git a/tests/Unit/Listener/ProductionVersionGuardListenerTest.php b/tests/Unit/Listener/ProductionVersionGuardListenerTest.php new file mode 100644 index 00000000..6c6e1b80 --- /dev/null +++ b/tests/Unit/Listener/ProductionVersionGuardListenerTest.php @@ -0,0 +1,152 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Tests\Unit\Listener; + +use OCA\OpenBuilt\Listener\ProductionVersionGuardListener; +use OCA\OpenBuilt\Service\ApplicationVersionService; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Event\ObjectUpdatingEvent; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Tests for ProductionVersionGuardListener. + */ +class ProductionVersionGuardListenerTest extends TestCase +{ + /** + * Mock logger. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface&MockObject $logger; + + /** + * Mock service. + * + * @var ApplicationVersionService&MockObject + */ + private ApplicationVersionService&MockObject $service; + + /** + * Listener under test. + */ + private ProductionVersionGuardListener $listener; + + /** + * Set up mocks + SUT. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->logger = $this->createMock(LoggerInterface::class); + $this->service = $this->createMock(ApplicationVersionService::class); + + $this->listener = new ProductionVersionGuardListener( + logger: $this->logger, + service: $this->service, + ); + }//end setUp() + + /** + * Guard skips events for non-Application schemas (no service call). + * + * @return void + */ + public function testIgnoresNonApplicationSchema(): void + { + $entity = $this->createMock(ObjectEntity::class); + $entity->method('jsonSerialize')->willReturn([ + '@self' => ['schema' => 'applicationVersion'], + 'productionVersion' => 'uuid-v', + ]); + $entity->method('getObject')->willReturn(['productionVersion' => 'uuid-v']); + + $event = new ObjectUpdatingEvent($entity); + + $this->service->expects(self::never())->method('guardProductionVersionOwnership'); + + $this->listener->handle($event); + self::assertFalse($event->isPropagationStopped()); + }//end testIgnoresNonApplicationSchema() + + /** + * Guard skips when productionVersion is unset. + * + * @return void + */ + public function testSkipsWhenProductionVersionAbsent(): void + { + $entity = $this->createMock(ObjectEntity::class); + $entity->method('jsonSerialize')->willReturn([ + '@self' => ['schema' => 'application'], + ]); + $entity->method('getObject')->willReturn(['slug' => 'foo']); + + $event = new ObjectUpdatingEvent($entity); + + $this->service->expects(self::never())->method('guardProductionVersionOwnership'); + + $this->listener->handle($event); + self::assertFalse($event->isPropagationStopped()); + }//end testSkipsWhenProductionVersionAbsent() + + /** + * Guard stops propagation + attaches an error when the service throws. + * + * @return void + */ + public function testStopsPropagationOnGuardFailure(): void + { + $entity = $this->getMockBuilder(ObjectEntity::class) + ->disableOriginalConstructor() + ->onlyMethods(['jsonSerialize', 'getObject']) + ->addMethods(['getUuid']) + ->getMock(); + $entity->method('jsonSerialize')->willReturn([ + '@self' => ['schema' => 'application'], + ]); + $entity->method('getObject')->willReturn(['productionVersion' => 'uuid-other']); + $entity->method('getUuid')->willReturn('uuid-this-app'); + + $this->service->expects(self::once()) + ->method('guardProductionVersionOwnership') + ->with(applicationUuid: 'uuid-this-app', proposedVersionUuid: 'uuid-other') + ->willThrowException(new RuntimeException(message: 'back-reference mismatch')); + + $event = new ObjectUpdatingEvent($entity); + + $this->listener->handle($event); + + self::assertTrue($event->isPropagationStopped()); + self::assertSame(422, $event->getErrors()['status'] ?? null); + }//end testStopsPropagationOnGuardFailure() +}//end class diff --git a/tests/Unit/Repair/MigrateToVersionedModelTest.php b/tests/Unit/Repair/MigrateToVersionedModelTest.php new file mode 100644 index 00000000..9bcbe4ba --- /dev/null +++ b/tests/Unit/Repair/MigrateToVersionedModelTest.php @@ -0,0 +1,280 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Tests\Unit\Repair; + +use OCA\OpenBuilt\Repair\MigrateToVersionedModel; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\RegisterService; +use OCP\Migration\IOutput; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Tests for MigrateToVersionedModel. + */ +class MigrateToVersionedModelTest extends TestCase +{ + /** + * Mock logger. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface&MockObject $logger; + + /** + * Mock OR ObjectService. + * + * @var ObjectService&MockObject + */ + private ObjectService&MockObject $objectService; + + /** + * Mock OR RegisterService. + * + * @var RegisterService&MockObject + */ + private RegisterService&MockObject $registerService; + + /** + * Mock OR RegisterMapper. + * + * @var RegisterMapper&MockObject + */ + private RegisterMapper&MockObject $registerMapper; + + /** + * Mock OR SchemaMapper. + * + * @var SchemaMapper&MockObject + */ + private SchemaMapper&MockObject $schemaMapper; + + /** + * Mock NC IOutput. + * + * @var IOutput&MockObject + */ + private IOutput&MockObject $output; + + /** + * Set up mocks. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->logger = $this->createMock(LoggerInterface::class); + $this->objectService = $this->createMock(ObjectService::class); + $this->registerService = $this->createMock(RegisterService::class); + $this->registerMapper = $this->createMock(RegisterMapper::class); + $this->schemaMapper = $this->createMock(SchemaMapper::class); + $this->output = $this->createMock(IOutput::class); + }//end setUp() + + /** + * Build the SUT with the shared mocks. + * + * @return MigrateToVersionedModel + */ + private function step(): MigrateToVersionedModel + { + return new MigrateToVersionedModel( + logger: $this->logger, + objectService: $this->objectService, + registerService: $this->registerService, + registerMapper: $this->registerMapper, + schemaMapper: $this->schemaMapper, + ); + }//end step() + + /** + * Short-circuit: versioned schema already present → no deletions. + * + * @return void + */ + public function testShortCircuitsWhenVersionedSchemaPresent(): void + { + // schemaMapper->find('applicationVersion', ...) succeeds (no throw) on first call. + $this->schemaMapper->method('find')->willReturn($this->createMock(Schema::class)); + + $this->objectService->expects(self::never())->method('findAll'); + $this->objectService->expects(self::never())->method('deleteObject'); + $this->registerService->expects(self::never())->method('delete'); + + $this->output->expects(self::once()) + ->method('info') + ->with(self::stringContains('schema already in versioned shape')); + + $this->step()->run($this->output); + }//end testShortCircuitsWhenVersionedSchemaPresent() + + /** + * Three pre-migration Applications → three deletions + three log lines. + * + * @return void + */ + public function testDeletesEveryPreMigrationApplication(): void + { + // Versioned schema is absent. + $this->schemaMapper->method('find') + ->willReturnCallback(function (string|int $slug) { + if ($slug === 'applicationVersion') { + throw new RuntimeException(message: 'not found'); + } + + return $this->getMockBuilder(Schema::class) + ->disableOriginalConstructor() + ->addMethods(['getId']) + ->getMock(); + }); + + $register = $this->getMockBuilder(Register::class) + ->disableOriginalConstructor() + ->addMethods(['getId']) + ->getMock(); + $register->method('getId')->willReturn(1); + $this->registerMapper->method('find')->willReturn($register); + + $appRows = [ + $this->mockEntity(['id' => 'uuid-a', 'slug' => 'app-a', 'currentVersion' => 'old-cv-a']), + $this->mockEntity(['id' => 'uuid-b', 'slug' => 'app-b', 'currentVersion' => 'old-cv-b']), + $this->mockEntity(['id' => 'uuid-c', 'slug' => 'app-c', 'currentVersion' => 'old-cv-c']), + ]; + $this->objectService->method('findAll')->willReturn($appRows); + + $this->registerService->expects(self::exactly(3))->method('delete'); + $this->objectService->expects(self::exactly(3))->method('deleteObject'); + + $infoMessages = []; + $this->output->method('info')->willReturnCallback(static function (string $msg) use (&$infoMessages): void { + $infoMessages[] = $msg; + }); + + $this->step()->run($this->output); + + $matching = array_filter( + $infoMessages, + static fn (string $line): bool => str_contains($line, 'Migrated-to-versioned-model: dropped Application') + ); + + self::assertCount(3, $matching); + self::assertNotEmpty(array_filter($matching, static fn (string $line): bool => str_contains($line, "'app-a'"))); + self::assertNotEmpty(array_filter($matching, static fn (string $line): bool => str_contains($line, "'app-b'"))); + self::assertNotEmpty(array_filter($matching, static fn (string $line): bool => str_contains($line, "'app-c'"))); + }//end testDeletesEveryPreMigrationApplication() + + /** + * Partial-failure: register-delete fails for slug B → A and C deleted, + * B's row is preserved, B failure is logged via $output->warning. + * + * @return void + */ + public function testPartialFailurePreservesRowOnFailedRegisterDelete(): void + { + $this->schemaMapper->method('find') + ->willReturnCallback(function (string|int $slug) { + if ($slug === 'applicationVersion') { + throw new RuntimeException(message: 'not found'); + } + + return $this->getMockBuilder(Schema::class) + ->disableOriginalConstructor() + ->addMethods(['getId']) + ->getMock(); + }); + + $register = $this->getMockBuilder(Register::class) + ->disableOriginalConstructor() + ->addMethods(['getId']) + ->getMock(); + $register->method('getId')->willReturn(1); + + // registerMapper->find returns a mock for every slug. + $this->registerMapper->method('find')->willReturn($register); + + $appRows = [ + $this->mockEntity(['id' => 'uuid-a', 'slug' => 'app-a', 'currentVersion' => 'cv-a']), + $this->mockEntity(['id' => 'uuid-b', 'slug' => 'app-b', 'currentVersion' => 'cv-b']), + $this->mockEntity(['id' => 'uuid-c', 'slug' => 'app-c', 'currentVersion' => 'cv-c']), + ]; + $this->objectService->method('findAll')->willReturn($appRows); + + $this->registerService->method('delete')->willReturnCallback( + function (Register $r) use ($register): Register { + // First call OK; second call fails; third OK. + static $callCount = 0; + $callCount++; + if ($callCount === 2) { + throw new RuntimeException(message: 'simulated OR failure'); + } + + return $register; + } + ); + + $deletedUuids = []; + $this->objectService->method('deleteObject')->willReturnCallback( + static function (string $uuid) use (&$deletedUuids): bool { + $deletedUuids[] = $uuid; + return true; + } + ); + + $warningCalls = 0; + $this->output->method('warning')->willReturnCallback(static function () use (&$warningCalls): void { + $warningCalls++; + }); + + $this->step()->run($this->output); + + self::assertSame(['uuid-a', 'uuid-c'], $deletedUuids, 'Only the rows whose register-delete succeeded are removed.'); + self::assertGreaterThanOrEqual(1, $warningCalls, 'Failure for app-b is surfaced via $output->warning.'); + }//end testPartialFailurePreservesRowOnFailedRegisterDelete() + + /** + * Build a stand-in ObjectEntity mock that exposes `jsonSerialize` returning the given payload. + * + * @param array $payload The serialised payload + * + * @return ObjectEntity&MockObject + */ + private function mockEntity(array $payload): ObjectEntity&MockObject + { + $entity = $this->createMock(ObjectEntity::class); + $entity->method('jsonSerialize')->willReturn($payload); + return $entity; + }//end mockEntity() +}//end class diff --git a/tests/Unit/Service/ApplicationVersionServiceTest.php b/tests/Unit/Service/ApplicationVersionServiceTest.php new file mode 100644 index 00000000..5789dea2 --- /dev/null +++ b/tests/Unit/Service/ApplicationVersionServiceTest.php @@ -0,0 +1,464 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Tests\Unit\Service; + +use OCA\OpenBuilt\Service\ApplicationVersionService; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\RegisterService; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Tests for ApplicationVersionService. + */ +class ApplicationVersionServiceTest extends TestCase +{ + /** + * Mock logger. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface&MockObject $logger; + + /** + * Mock object service. + * + * @var ObjectService&MockObject + */ + private ObjectService&MockObject $objectService; + + /** + * Mock register service. + * + * @var RegisterService&MockObject + */ + private RegisterService&MockObject $registerService; + + /** + * Mock register mapper. + * + * @var RegisterMapper&MockObject + */ + private RegisterMapper&MockObject $registerMapper; + + /** + * Service under test. + */ + private ApplicationVersionService $service; + + /** + * Set up shared mocks + the SUT. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->logger = $this->createMock(LoggerInterface::class); + $this->objectService = $this->createMock(ObjectService::class); + $this->registerService = $this->createMock(RegisterService::class); + $this->registerMapper = $this->createMock(RegisterMapper::class); + + $this->service = new ApplicationVersionService( + logger: $this->logger, + objectService: $this->objectService, + registerService: $this->registerService, + registerMapper: $this->registerMapper, + ); + }//end setUp() + + /** + * Canonicalisation must be deterministic: identical semantic content + * encoded with re-ordered keys produces the same canonical string. + * + * @return void + */ + public function testCanonicaliseManifestIsKeyOrderIndependent(): void + { + $manifest1 = ['version' => '1.0.0', 'menu' => [], 'pages' => [['type' => 'index', 'id' => 'Home']]]; + $manifest2 = ['pages' => [['id' => 'Home', 'type' => 'index']], 'menu' => [], 'version' => '1.0.0']; + + self::assertSame( + $this->service->canonicaliseManifest(manifest: $manifest1), + $this->service->canonicaliseManifest(manifest: $manifest2) + ); + }//end testCanonicaliseManifestIsKeyOrderIndependent() + + /** + * SHA-256 hash is stable across runs and unique per content. + * + * @return void + */ + public function testHashManifestIsStableAndDistinct(): void + { + $hashA = $this->service->hashManifest(manifest: ['version' => '1.0.0']); + $hashB = $this->service->hashManifest(manifest: ['version' => '2.0.0']); + + self::assertSame($hashA, $this->service->hashManifest(manifest: ['version' => '1.0.0'])); + self::assertNotSame($hashA, $hashB); + self::assertSame(64, strlen($hashA)); + }//end testHashManifestIsStableAndDistinct() + + /** + * Patch bump arithmetic, including suffix stripping and error path. + * + * @return void + */ + public function testBumpPatchHappyPathsAndError(): void + { + self::assertSame('0.1.1', $this->service->bumpPatch(semver: '0.1.0')); + self::assertSame('2.5.8', $this->service->bumpPatch(semver: '2.5.7')); + self::assertSame('1.0.1', $this->service->bumpPatch(semver: '1.0.0-beta.5+build.7')); + + $this->expectException(RuntimeException::class); + $this->service->bumpPatch(semver: 'not-a-semver'); + }//end testBumpPatchHappyPathsAndError() + + /** + * onSave on a CREATE defaults semver to 0.1.0 and stamps manifestHash. + * + * @return void + */ + public function testOnSaveCreateDefaultsSemverAndStampsHash(): void + { + $next = ['manifest' => ['version' => '1.0.0', 'menu' => [], 'pages' => []]]; + + $result = $this->service->onSave(current: null, next: $next); + + self::assertSame(ApplicationVersionService::INITIAL_SEMVER, $result['semver']); + self::assertArrayHasKey('manifestHash', $result); + self::assertSame(64, strlen($result['manifestHash'])); + }//end testOnSaveCreateDefaultsSemverAndStampsHash() + + /** + * Metadata-only edit leaves semver + manifestHash untouched. + * + * @return void + */ + public function testOnSaveMetadataOnlyEditDoesNotBump(): void + { + $manifest = ['version' => '1.0.0', 'menu' => [], 'pages' => []]; + $hash = $this->service->hashManifest(manifest: $manifest); + + $current = ['semver' => '0.2.3', 'manifestHash' => $hash, 'manifest' => $manifest]; + $next = ['semver' => '0.2.3', 'manifest' => $manifest, 'name' => 'New display name']; + + $result = $this->service->onSave(current: $current, next: $next); + + self::assertSame('0.2.3', $result['semver']); + self::assertSame($hash, $result['manifestHash']); + }//end testOnSaveMetadataOnlyEditDoesNotBump() + + /** + * Manifest content change patch-bumps semver. + * + * @return void + */ + public function testOnSaveManifestChangeBumpsPatch(): void + { + $oldManifest = ['version' => '1.0.0', 'menu' => [], 'pages' => []]; + $newManifest = ['version' => '1.0.0', 'menu' => [], 'pages' => [['id' => 'Home', 'type' => 'index']]]; + $oldHash = $this->service->hashManifest(manifest: $oldManifest); + + $current = ['semver' => '0.1.5', 'manifestHash' => $oldHash, 'manifest' => $oldManifest]; + $next = ['semver' => '0.1.5', 'manifest' => $newManifest]; + + $result = $this->service->onSave(current: $current, next: $next); + + self::assertSame('0.1.6', $result['semver']); + self::assertNotSame($oldHash, $result['manifestHash']); + self::assertSame($this->service->hashManifest(manifest: $newManifest), $result['manifestHash']); + }//end testOnSaveManifestChangeBumpsPatch() + + /** + * Cycle guard rejects an explicit self-loop. + * + * @return void + */ + public function testGuardNoCycleRejectsSelfLoop(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/promotesTo/'); + $this->service->guardNoCycle(currentUuid: 'uuid-a', proposedTargetUuid: 'uuid-a'); + }//end testGuardNoCycleRejectsSelfLoop() + + /** + * Cycle guard accepts a null target (terminal version). + * + * @return void + */ + public function testGuardNoCycleAcceptsNullTarget(): void + { + $this->service->guardNoCycle(currentUuid: 'uuid-a', proposedTargetUuid: null); + // No throw — test passes if we reach this point. + self::assertTrue(true); + }//end testGuardNoCycleAcceptsNullTarget() + + /** + * Cycle guard catches an indirect cycle via promotesTo chain. + * + * Chain: A → B → C; saving C with promotesTo=A would close the loop. + * + * @return void + */ + public function testGuardNoCycleDetectsIndirectCycle(): void + { + // C.promotesTo = A. Walk: A → A.promotesTo (B), B → B.promotesTo (C). + // Once cursor lands on C (== currentUuid), the guard throws. + $aEntity = $this->mockVersion(promotesTo: 'uuid-b'); + $bEntity = $this->mockVersion(promotesTo: 'uuid-c'); + + $this->objectService->method('find') + ->willReturnCallback(static function (string|int $id) use ($aEntity, $bEntity): ?ObjectEntity { + return match ($id) { + 'uuid-a' => $aEntity, + 'uuid-b' => $bEntity, + default => null, + }; + }); + + $this->expectException(RuntimeException::class); + $this->service->guardNoCycle(currentUuid: 'uuid-c', proposedTargetUuid: 'uuid-a'); + }//end testGuardNoCycleDetectsIndirectCycle() + + /** + * Cycle guard accepts a valid linear chain extension. + * + * Chain: A → B; saving B.promotesTo = C (which has no promotesTo) is fine. + * + * @return void + */ + public function testGuardNoCycleAcceptsLinearExtension(): void + { + $cEntity = $this->mockVersion(promotesTo: null); + + $this->objectService->method('find') + ->willReturnCallback(static fn (string|int $id): ?ObjectEntity => $id === 'uuid-c' ? $cEntity : null); + + $this->service->guardNoCycle(currentUuid: 'uuid-b', proposedTargetUuid: 'uuid-c'); + self::assertTrue(true); + }//end testGuardNoCycleAcceptsLinearExtension() + + /** + * productionVersion guard rejects a foreign ApplicationVersion. + * + * @return void + */ + public function testGuardProductionVersionOwnershipRejectsForeignVersion(): void + { + $foreignVersion = $this->mockVersion(application: 'uuid-other-app'); + $this->objectService->method('find')->willReturn($foreignVersion); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/back-reference mismatch/'); + $this->service->guardProductionVersionOwnership( + applicationUuid: 'uuid-this-app', + proposedVersionUuid: 'uuid-v' + ); + }//end testGuardProductionVersionOwnershipRejectsForeignVersion() + + /** + * productionVersion guard accepts a back-referencing version. + * + * @return void + */ + public function testGuardProductionVersionOwnershipAcceptsValidVersion(): void + { + $validVersion = $this->mockVersion(application: 'uuid-this-app'); + $this->objectService->method('find')->willReturn($validVersion); + + $this->service->guardProductionVersionOwnership( + applicationUuid: 'uuid-this-app', + proposedVersionUuid: 'uuid-v' + ); + self::assertTrue(true); + }//end testGuardProductionVersionOwnershipAcceptsValidVersion() + + /** + * deleteVersion(unknown strategy) throws (controller maps to 400). + * + * @return void + */ + public function testDeleteVersionRejectsUnknownStrategy(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/Unknown deletion strategy/'); + $this->service->deleteVersion(versionUuid: 'uuid-v', strategy: 'bogus'); + }//end testDeleteVersionRejectsUnknownStrategy() + + /** + * deleteVersion refuses to delete an Application's production version. + * + * @return void + */ + public function testDeleteVersionRefusesProductionVersion(): void + { + $version = $this->mockVersion(application: 'uuid-app', register: 'openbuilt-foo-prod', uuid: 'uuid-v'); + $application = $this->mockApplication(productionVersion: 'uuid-v'); + + $this->objectService->method('find') + ->willReturnCallback(static function (string|int $id) use ($version, $application): ?ObjectEntity { + return match ($id) { + 'uuid-v' => $version, + 'uuid-app' => $application, + default => null, + }; + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/production version/'); + $this->service->deleteVersion( + versionUuid: 'uuid-v', + strategy: ApplicationVersionService::STRATEGY_DELETE_NOW + ); + }//end testDeleteVersionRefusesProductionVersion() + + /** + * delete-now drops the per-version register via RegisterService. + * + * @return void + */ + public function testDeleteVersionDeleteNowDropsRegister(): void + { + $version = $this->mockVersion(application: 'uuid-app', register: 'openbuilt-foo-staging', uuid: 'uuid-v'); + $application = $this->mockApplication(productionVersion: 'uuid-prod'); + + $this->objectService->method('find') + ->willReturnCallback(static function (string|int $id) use ($version, $application): ?ObjectEntity { + return match ($id) { + 'uuid-v' => $version, + 'uuid-app' => $application, + default => null, + }; + }); + + $register = $this->getMockBuilder(Register::class) + ->disableOriginalConstructor() + ->getMock(); + $this->registerMapper->method('find')->with('openbuilt-foo-staging')->willReturn($register); + + $this->registerService->expects(self::once())->method('delete')->with($register); + $this->objectService->expects(self::once())->method('deleteObject')->with('uuid-v'); + + $this->service->deleteVersion( + versionUuid: 'uuid-v', + strategy: ApplicationVersionService::STRATEGY_DELETE_NOW + ); + }//end testDeleteVersionDeleteNowDropsRegister() + + /** + * keep-register leaves the register untouched (no delete call). + * + * @return void + */ + public function testDeleteVersionKeepRegisterDoesNotDropRegister(): void + { + $version = $this->mockVersion(application: 'uuid-app', register: 'openbuilt-foo-staging', uuid: 'uuid-v'); + $application = $this->mockApplication(productionVersion: 'uuid-prod'); + + $this->objectService->method('find') + ->willReturnCallback(static function (string|int $id) use ($version, $application): ?ObjectEntity { + return match ($id) { + 'uuid-v' => $version, + 'uuid-app' => $application, + default => null, + }; + }); + + $this->registerService->expects(self::never())->method('delete'); + $this->objectService->expects(self::once())->method('deleteObject')->with('uuid-v'); + + $this->service->deleteVersion( + versionUuid: 'uuid-v', + strategy: ApplicationVersionService::STRATEGY_KEEP_REGISTER + ); + }//end testDeleteVersionKeepRegisterDoesNotDropRegister() + + /** + * Build a mock ObjectEntity standing in for an ApplicationVersion row. + * + * @param string|null $promotesTo Optional promotesTo target UUID + * @param string|null $application Optional parent Application UUID + * @param string|null $register Optional per-version register slug + * @param string|null $uuid Optional own UUID + * + * @return ObjectEntity&MockObject + */ + private function mockVersion( + ?string $promotesTo=null, + ?string $application=null, + ?string $register=null, + ?string $uuid=null + ): ObjectEntity&MockObject { + $entity = $this->createMock(ObjectEntity::class); + $payload = []; + if ($promotesTo !== null) { + $payload['promotesTo'] = $promotesTo; + } + + if ($application !== null) { + $payload['application'] = $application; + } + + if ($register !== null) { + $payload['register'] = $register; + } + + if ($uuid !== null) { + $payload['id'] = $uuid; + } + + $entity->method('jsonSerialize')->willReturn($payload); + return $entity; + }//end mockVersion() + + /** + * Build a mock ObjectEntity standing in for an Application row. + * + * @param string|null $productionVersion Optional productionVersion UUID + * + * @return ObjectEntity&MockObject + */ + private function mockApplication(?string $productionVersion=null): ObjectEntity&MockObject + { + $entity = $this->createMock(ObjectEntity::class); + $payload = []; + if ($productionVersion !== null) { + $payload['productionVersion'] = $productionVersion; + } + + $entity->method('jsonSerialize')->willReturn($payload); + return $entity; + }//end mockApplication() +}//end class diff --git a/tests/stubs/openregister-stubs.php b/tests/stubs/openregister-stubs.php index ff9f05ef..9221cac3 100644 --- a/tests/stubs/openregister-stubs.php +++ b/tests/stubs/openregister-stubs.php @@ -291,6 +291,14 @@ public function saveObject(array|\OCA\OpenRegister\Db\ObjectEntity $object, ?arr { return new \OCA\OpenRegister\Db\ObjectEntity(); } + + /** + * @return bool + */ + public function deleteObject(string $uuid, bool $_rbac = true, bool $_multitenancy = true): bool + { + return true; + } } } @@ -311,6 +319,33 @@ public function importFromApp(string $appId, array $data, string $version, bool } } } + + if (class_exists(RegisterService::class, autoload: false) === false) { + /** + * Stub RegisterService — `find`/`delete` call surface used by + * ApplicationVersionService and MigrateToVersionedModel. + */ + class RegisterService + { + /** + * @return \OCA\OpenRegister\Db\Register + */ + public function delete(\OCA\OpenRegister\Db\Register $register): \OCA\OpenRegister\Db\Register + { + return $register; + } + + /** + * @param array|null $_extend + * + * @return \OCA\OpenRegister\Db\Register + */ + public function find(int|string $id, ?array $_extend = [], bool $_multitenancy = true): \OCA\OpenRegister\Db\Register + { + return new \OCA\OpenRegister\Db\Register(); + } + } + } } namespace OCA\OpenRegister\Event { @@ -397,6 +432,108 @@ public function getAction(): string } } + if (class_exists(ObjectCreatingEvent::class, autoload: false) === false) { + /** + * Stub ObjectCreatingEvent — supports `stopPropagation`/`setErrors`/`getObject`. + */ + class ObjectCreatingEvent extends \OCP\EventDispatcher\Event implements \Psr\EventDispatcher\StoppableEventInterface + { + private bool $propagationStopped = false; + + /** + * @var array + */ + private array $errors = []; + + public function __construct(private readonly \OCA\OpenRegister\Db\ObjectEntity $object) + { + parent::__construct(); + } + + public function getObject(): \OCA\OpenRegister\Db\ObjectEntity + { + return $this->object; + } + + public function isPropagationStopped(): bool + { + return $this->propagationStopped; + } + + public function stopPropagation(): void + { + $this->propagationStopped = true; + } + + /** + * @param array $errors + */ + public function setErrors(array $errors): void + { + $this->errors = $errors; + } + + /** + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } + } + } + + if (class_exists(ObjectUpdatingEvent::class, autoload: false) === false) { + /** + * Stub ObjectUpdatingEvent — same shape as ObjectCreatingEvent. + */ + class ObjectUpdatingEvent extends \OCP\EventDispatcher\Event implements \Psr\EventDispatcher\StoppableEventInterface + { + private bool $propagationStopped = false; + + /** + * @var array + */ + private array $errors = []; + + public function __construct(private readonly \OCA\OpenRegister\Db\ObjectEntity $object) + { + parent::__construct(); + } + + public function getObject(): \OCA\OpenRegister\Db\ObjectEntity + { + return $this->object; + } + + public function isPropagationStopped(): bool + { + return $this->propagationStopped; + } + + public function stopPropagation(): void + { + $this->propagationStopped = true; + } + + /** + * @param array $errors + */ + public function setErrors(array $errors): void + { + $this->errors = $errors; + } + + /** + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } + } + } + if (class_exists(DeepLinkRegistrationEvent::class, autoload: false) === false) { /** * Stub DeepLinkRegistrationEvent — `register` call surface. diff --git a/tests/unit/Repair/SeedHelloWorldTest.php b/tests/unit/Repair/SeedHelloWorldTest.php deleted file mode 100644 index 830f1467..00000000 --- a/tests/unit/Repair/SeedHelloWorldTest.php +++ /dev/null @@ -1,254 +0,0 @@ - - * @copyright 2026 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * - * @version GIT: - * - * @link https://conduction.nl - */ - -declare(strict_types=1); - -namespace OCA\OpenBuilt\Tests\Unit\Repair; - -use OCA\OpenBuilt\Repair\SeedHelloWorld; -use OCA\OpenRegister\Db\ObjectEntity; -use OCA\OpenRegister\Service\ObjectService; -use OCP\Migration\IOutput; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; - -/** - * Tests for SeedHelloWorld::run. - */ -class SeedHelloWorldTest extends TestCase -{ - /** - * Mock logger. - * - * @var LoggerInterface&MockObject - */ - private LoggerInterface&MockObject $logger; - - /** - * Mock OR ObjectService. - * - * @var ObjectService&MockObject - */ - private ObjectService&MockObject $objectService; - - /** - * Mock IOutput. - * - * @var IOutput&MockObject - */ - private IOutput&MockObject $output; - - /** - * Set up test fixtures. - * - * @return void - */ - protected function setUp(): void - { - parent::setUp(); - - $this->logger = $this->createMock(LoggerInterface::class); - $this->output = $this->createMock(IOutput::class); - $this->objectService = $this->createMock(ObjectService::class); - }//end setUp() - - /** - * Test that getName returns a non-empty descriptive name. - * - * @return void - */ - public function testGetNameReturnsDescriptiveName(): void - { - $step = new SeedHelloWorld(logger: $this->logger, objectService: $this->objectService); - - $name = $step->getName(); - - self::assertNotEmpty($name); - self::assertStringContainsString('hello-world', $name); - }//end testGetNameReturnsDescriptiveName() - - /** - * Test idempotency — when an existing hello-world Application is found, saveObject is NOT called. - * - * @return void - */ - public function testRunIsIdempotentWhenSeedAlreadyExists(): void - { - $this->objectService->expects(self::once()) - ->method('findAll') - ->willReturn([['slug' => 'hello-world']]); - - $this->objectService->expects(self::never()) - ->method('saveObject'); - - $this->output->expects(self::atLeastOnce())->method('info'); - - $step = new SeedHelloWorld(logger: $this->logger, objectService: $this->objectService); - $step->run($this->output); - }//end testRunIsIdempotentWhenSeedAlreadyExists() - - /** - * Test fresh-install path — when no existing hello-world exists, saveObject is called for the - * Application + BuiltAppRoute + initial ApplicationVersion + currentVersion writeback + three - * HelloMessage objects = 7 total saves. - * - * Per chain spec #6 openbuilt-versioning (design.md §Seed Data) the initial snapshot is - * created at install time so the version-history panel is non-empty on the fresh-install - * hello-world Application. - * - * @return void - */ - public function testRunCreatesApplicationAndThreeMessagesOnFreshInstall(): void - { - $this->objectService->expects(self::once()) - ->method('findAll') - ->willReturn([]); - - // Returned entities must jsonSerialize() to an array carrying a uuid so the - // seed code can chain (Application uuid → snapshot, snapshot uuid → patch). - $appEntity = $this->createMock(ObjectEntity::class); - $appEntity->method('jsonSerialize')->willReturn(['@self' => ['id' => 'app-uuid-seed']]); - - $snapEntity = $this->createMock(ObjectEntity::class); - $snapEntity->method('jsonSerialize')->willReturn(['@self' => ['id' => 'snap-uuid-seed']]); - - $genericEntity = $this->createMock(ObjectEntity::class); - $genericEntity->method('jsonSerialize')->willReturn(['@self' => ['id' => 'generic']]); - - // ObjectService::saveObject(array|ObjectEntity $object, ?array $extend, mixed $register, mixed $schema): - // the named-arg call site (object/register/schema) yields positional args [object, [], register, schema]. - $schemaOf = static fn (array $args): mixed => ($args['schema'] ?? ($args[3] ?? null)); - $objectOf = static fn (array $args): mixed => ($args['object'] ?? ($args[0] ?? null)); - - $captured = []; - $this->objectService->expects(self::exactly(7)) - ->method('saveObject') - ->willReturnCallback(function (...$args) use (&$captured, $appEntity, $snapEntity, $genericEntity, $schemaOf) { - $captured[] = $args; - $schema = $schemaOf($args); - if ($schema === 'application') { - return $appEntity; - } - if ($schema === 'application-version') { - return $snapEntity; - } - return $genericEntity; - }); - - $step = new SeedHelloWorld(logger: $this->logger, objectService: $this->objectService); - $step->run($this->output); - - // Assert at least one save targets the application-version schema with a 1.0.0 manifest. - $snapshotCalls = array_values(array_filter($captured, static function (array $args) use ($schemaOf): bool { - return ($schemaOf($args) === 'application-version'); - })); - self::assertCount(1, $snapshotCalls, 'Expected exactly one initial ApplicationVersion seed save.'); - $payload = $objectOf($snapshotCalls[0]); - self::assertSame('1.0.0', $payload['version']); - self::assertSame('app-uuid-seed', $payload['applicationUuid']); - self::assertArrayHasKey('manifest', $payload); - }//end testRunCreatesApplicationAndThreeMessagesOnFreshInstall() - - /** - * Test that the seeded hello-world manifest blob is structurally valid against the - * canonical app-manifest contract (ADR-024): a version string, a menu of well-formed - * entries, and a non-empty list of well-formed pages whose ids are unique and whose - * data-bound types declare a register + schema; every menu route resolves to a page id. - * Covers bootstrap-openbuilt verification task 4.3. - * - * @return void - */ - public function testSeededHelloWorldManifestIsStructurallyValid(): void - { - $this->objectService->method('findAll')->willReturn([]); - - $captured = []; - $entity = $this->createMock(ObjectEntity::class); - $entity->method('jsonSerialize')->willReturn(['@self' => ['id' => 'app-uuid-seed']]); - $this->objectService->method('saveObject') - ->willReturnCallback(function (...$args) use (&$captured, $entity) { - $captured[] = $args; - return $entity; - }); - - $step = new SeedHelloWorld(logger: $this->logger, objectService: $this->objectService); - $step->run($this->output); - - // The Application save is the one whose schema arg is 'application'. - $schemaOf = static fn (array $args): mixed => ($args['schema'] ?? ($args[3] ?? null)); - $objectOf = static fn (array $args): mixed => ($args['object'] ?? ($args[0] ?? null)); - $appCalls = array_values(array_filter($captured, static fn (array $a): bool => $schemaOf($a) === 'application')); - self::assertNotEmpty($appCalls, 'Expected a saveObject call against the application schema.'); - - $manifest = $objectOf($appCalls[0])['manifest'] ?? null; - self::assertIsArray($manifest, 'Seeded Application carries a manifest array.'); - - // version - self::assertArrayHasKey('version', $manifest); - self::assertIsString($manifest['version']); - self::assertNotSame('', $manifest['version']); - - // pages - self::assertArrayHasKey('pages', $manifest); - self::assertIsArray($manifest['pages']); - self::assertNotEmpty($manifest['pages'], 'A virtual app needs at least one page.'); - $pageIds = []; - $allowedTypes = ['index', 'detail', 'dashboard', 'logs', 'settings', 'chat', 'files', 'form', 'custom']; - $dataBoundTypes = ['index', 'detail', 'form']; - foreach ($manifest['pages'] as $page) { - self::assertIsArray($page); - foreach (['id', 'route', 'type', 'title'] as $required) { - self::assertArrayHasKey($required, $page, "page is missing '$required'"); - self::assertIsString($page[$required]); - self::assertNotSame('', $page[$required], "page '$required' is empty"); - } - self::assertContains($page['type'], $allowedTypes, "page '{$page['id']}' has an unknown type '{$page['type']}'"); - self::assertNotContains($page['id'], $pageIds, "duplicate page id '{$page['id']}'"); - $pageIds[] = $page['id']; - if (in_array($page['type'], $dataBoundTypes, true) === true) { - self::assertArrayHasKey('config', $page, "data-bound page '{$page['id']}' needs a config"); - self::assertIsString($page['config']['register'] ?? null, "page '{$page['id']}' config needs a register"); - self::assertIsString($page['config']['schema'] ?? null, "page '{$page['id']}' config needs a schema"); - } - } - - // menu - self::assertArrayHasKey('menu', $manifest); - self::assertIsArray($manifest['menu']); - foreach ($manifest['menu'] as $entry) { - self::assertIsArray($entry); - self::assertIsString($entry['id'] ?? null); - self::assertNotSame('', $entry['id']); - self::assertIsString($entry['label'] ?? null); - self::assertNotSame('', $entry['label']); - // An entry routes to a page id, links out via href, or invokes a built-in action. - $targets = array_filter([ - isset($entry['route']) ? 'route' : null, - isset($entry['href']) ? 'href' : null, - isset($entry['action']) ? 'action' : null, - ]); - self::assertNotEmpty($targets, "menu entry '{$entry['id']}' has no route/href/action"); - if (isset($entry['route']) === true) { - self::assertContains($entry['route'], $pageIds, "menu entry '{$entry['id']}' routes to unknown page '{$entry['route']}'"); - } - } - }//end testSeededHelloWorldManifestIsStructurallyValid() -}//end class